Skip to content

Commit 5608eca

Browse files
authored
Support resolving OpenApiPaths entries from document (#54847)
* Add support for generating OpenAPI info and paths * Address feedback * Fix sample and options injection * Address more feedback
1 parent b082c5f commit 5608eca

11 files changed

+573
-77
lines changed

src/Mvc/Mvc.ApiExplorer/src/Microsoft.AspNetCore.Mvc.ApiExplorer.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@
2020

2121
<ItemGroup>
2222
<InternalsVisibleTo Include="Microsoft.AspNetCore.Mvc.ApiExplorer.Test" />
23+
<InternalsVisibleTo Include="Microsoft.AspNetCore.OpenApi.Tests" />
2324
</ItemGroup>
2425
</Project>

src/OpenApi/sample/Program.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
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 Microsoft.AspNetCore.Builder;
5-
using Microsoft.AspNetCore.Mvc;
6-
74
var builder = WebApplication.CreateBuilder(args);
85

96
builder.Services.AddOpenApi("v1");
@@ -18,14 +15,16 @@
1815
}
1916

2017
var v1 = app.MapGroup("v1")
21-
.WithMetadata(new ApiExplorerSettingsAttribute { GroupName = "v1" });
18+
.WithGroupName("v1");
2219

2320
var v2 = app.MapGroup("v2")
24-
.WithMetadata(new ApiExplorerSettingsAttribute { GroupName = "v2" });
21+
.WithGroupName("v2");
2522

2623
v1.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo));
2724
v1.MapGet("/todos/{id}", (int id) => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now));
2825

26+
v2.MapPost("/users", () => Results.Created("/users/1", new { Id = 1, Name = "Test user" }));
27+
2928
app.Run();
3029

3130
public record Todo(int Id, string Title, bool Completed, DateTime CreatedAt);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using System.Text;
6+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
7+
using Microsoft.AspNetCore.Routing.Patterns;
8+
using Microsoft.OpenApi.Models;
9+
10+
internal static class ApiDescriptionExtensions
11+
{
12+
/// <summary>
13+
/// Maps the HTTP method of the ApiDescription to the OpenAPI <see cref="OperationType"/> .
14+
/// </summary>
15+
/// <param name="apiDescription">The ApiDescription to resolve an operation type from.</param>
16+
/// <returns>The <see cref="OperationType"/> associated with the given <paramref name="apiDescription"/>.</returns>
17+
public static OperationType GetOperationType(this ApiDescription apiDescription) =>
18+
apiDescription.HttpMethod?.ToUpperInvariant() switch
19+
{
20+
"GET" => OperationType.Get,
21+
"POST" => OperationType.Post,
22+
"PUT" => OperationType.Put,
23+
"DELETE" => OperationType.Delete,
24+
"PATCH" => OperationType.Patch,
25+
"HEAD" => OperationType.Head,
26+
"OPTIONS" => OperationType.Options,
27+
"TRACE" => OperationType.Trace,
28+
_ => throw new InvalidOperationException($"Unsupported HTTP method: {apiDescription.HttpMethod}"),
29+
};
30+
31+
/// <summary>
32+
/// Maps the relative path included in the ApiDescription to the path
33+
/// that should be included in the OpenApiDocument. This typically
34+
/// consists of removing any constraints from route parameter parts
35+
/// and retaining only the literals.
36+
/// </summary>
37+
/// <param name="apiDescription">The ApiDescription to resolve an item path from.</param>
38+
/// <returns>The resolved item path for the given <paramref name="apiDescription"/>.</returns>
39+
public static string MapRelativePathToItemPath(this ApiDescription apiDescription)
40+
{
41+
Debug.Assert(apiDescription.RelativePath != null, "Relative path cannot be null.");
42+
// "" -> "/"
43+
if (string.IsNullOrEmpty(apiDescription.RelativePath))
44+
{
45+
return "/";
46+
}
47+
var strippedRoute = new StringBuilder();
48+
var routePattern = RoutePatternFactory.Parse(apiDescription.RelativePath);
49+
for (var i = 0; i < routePattern.PathSegments.Count; i++)
50+
{
51+
strippedRoute.Append('/');
52+
var segment = routePattern.PathSegments[i];
53+
foreach (var part in segment.Parts)
54+
{
55+
if (part is RoutePatternLiteralPart literalPart)
56+
{
57+
strippedRoute.Append(literalPart.Content);
58+
}
59+
else if (part is RoutePatternParameterPart parameterPart)
60+
{
61+
strippedRoute.Append('{');
62+
strippedRoute.Append(parameterPart.Name);
63+
strippedRoute.Append('}');
64+
}
65+
else if (part is RoutePatternSeparatorPart separatorPart)
66+
{
67+
strippedRoute.Append(separatorPart.Content);
68+
}
69+
}
70+
}
71+
return strippedRoute.ToString();
72+
}
73+
}
Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,75 @@
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.Diagnostics;
5+
using System.Linq;
6+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
7+
using Microsoft.Extensions.DependencyInjection;
48
using Microsoft.Extensions.Hosting;
9+
using Microsoft.Extensions.Options;
510
using Microsoft.OpenApi.Models;
611

712
namespace Microsoft.AspNetCore.OpenApi;
813

9-
internal sealed class OpenApiDocumentService(IHostEnvironment hostEnvironment)
14+
internal sealed class OpenApiDocumentService(
15+
[ServiceKey] string documentName,
16+
IApiDescriptionGroupCollectionProvider apiDescriptionGroupCollectionProvider,
17+
IHostEnvironment hostEnvironment,
18+
IOptionsMonitor<OpenApiOptions> optionsMonitor)
1019
{
20+
private readonly OpenApiOptions _options = optionsMonitor.Get(documentName);
21+
1122
public Task<OpenApiDocument> GetOpenApiDocumentAsync()
1223
{
1324
var document = new OpenApiDocument
1425
{
15-
Info = new OpenApiInfo
16-
{
17-
Title = hostEnvironment.ApplicationName,
18-
Version = OpenApiConstants.DefaultOpenApiVersion
19-
}
26+
Info = GetOpenApiInfo(),
27+
Paths = GetOpenApiPaths()
2028
};
2129
return Task.FromResult(document);
2230
}
31+
32+
// Note: Internal for testing.
33+
internal OpenApiInfo GetOpenApiInfo()
34+
{
35+
return new OpenApiInfo
36+
{
37+
Title = $"{hostEnvironment.ApplicationName} | {documentName}",
38+
Version = OpenApiConstants.DefaultOpenApiVersion
39+
};
40+
}
41+
42+
/// <summary>
43+
/// Gets the OpenApiPaths for the document based on the ApiDescriptions.
44+
/// </summary>
45+
/// <remarks>
46+
/// At this point in the construction of the OpenAPI document, we run
47+
/// each API description through the `ShouldInclude` delegate defined in
48+
/// the object to support filtering each
49+
/// description instance into its appropriate document.
50+
/// </remarks>
51+
private OpenApiPaths GetOpenApiPaths()
52+
{
53+
var descriptionsByPath = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items
54+
.SelectMany(group => group.Items)
55+
.Where(_options.ShouldInclude)
56+
.GroupBy(apiDescription => apiDescription.MapRelativePathToItemPath());
57+
var paths = new OpenApiPaths();
58+
foreach (var descriptions in descriptionsByPath)
59+
{
60+
Debug.Assert(descriptions.Key != null, "Relative path mapped to OpenApiPath key cannot be null.");
61+
paths.Add(descriptions.Key, new OpenApiPathItem { Operations = GetOperations(descriptions) });
62+
}
63+
return paths;
64+
}
65+
66+
private static Dictionary<OperationType, OpenApiOperation> GetOperations(IGrouping<string?, ApiDescription> descriptions)
67+
{
68+
var operations = new Dictionary<OperationType, OpenApiOperation>();
69+
foreach (var description in descriptions)
70+
{
71+
operations[description.GetOperationType()] = new OpenApiOperation();
72+
}
73+
return operations;
74+
}
2375
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
5+
using Microsoft.OpenApi.Models;
6+
7+
public class ApiDescriptionExtensionsTests
8+
{
9+
[Theory]
10+
[InlineData("api/todos", "/api/todos")]
11+
[InlineData("api/todos/{id}", "/api/todos/{id}")]
12+
[InlineData("api/todos/{id:int:min(10)}", "/api/todos/{id}")]
13+
[InlineData("{a}/{b}/{c=19}", "/{a}/{b}/{c}")]
14+
[InlineData("{a}/{b}/{c?}", "/{a}/{b}/{c}")]
15+
[InlineData("{a:int}/{b}/{c:int}", "/{a}/{b}/{c}")]
16+
[InlineData("", "/")]
17+
[InlineData("api", "/api")]
18+
[InlineData("{p1}/{p2}.{p3?}", "/{p1}/{p2}.{p3}")]
19+
public void MapRelativePathToItemPath_ReturnsItemPathForApiDescription(string relativePath, string expectedItemPath)
20+
{
21+
// Arrange
22+
var apiDescription = new ApiDescription
23+
{
24+
RelativePath = relativePath
25+
};
26+
27+
// Act
28+
var itemPath = apiDescription.MapRelativePathToItemPath();
29+
30+
// Assert
31+
Assert.Equal(expectedItemPath, itemPath);
32+
}
33+
34+
[Theory]
35+
[InlineData("GET", OperationType.Get)]
36+
[InlineData("POST", OperationType.Post)]
37+
[InlineData("PUT", OperationType.Put)]
38+
[InlineData("DELETE", OperationType.Delete)]
39+
[InlineData("PATCH", OperationType.Patch)]
40+
[InlineData("HEAD", OperationType.Head)]
41+
[InlineData("OPTIONS", OperationType.Options)]
42+
[InlineData("TRACE", OperationType.Trace)]
43+
[InlineData("gEt", OperationType.Get)]
44+
public void ToOperationType_ReturnsOperationTypeForApiDescription(string httpMethod, OperationType expectedOperationType)
45+
{
46+
// Arrange
47+
var apiDescription = new ApiDescription
48+
{
49+
HttpMethod = httpMethod
50+
};
51+
52+
// Act
53+
var operationType = apiDescription.GetOperationType();
54+
55+
// Assert
56+
Assert.Equal(expectedOperationType, operationType);
57+
}
58+
59+
[Theory]
60+
[InlineData("UNKNOWN")]
61+
[InlineData("unknown")]
62+
public void ToOperationType_ThrowsForUnknownHttpMethod(string methodName)
63+
{
64+
// Arrange
65+
var apiDescription = new ApiDescription
66+
{
67+
HttpMethod = methodName
68+
};
69+
70+
// Act & Assert
71+
var exception = Assert.Throws<InvalidOperationException>(() => apiDescription.GetOperationType());
72+
Assert.Equal($"Unsupported HTTP method: {methodName}", exception.Message);
73+
}
74+
}

0 commit comments

Comments
 (0)