Skip to content

Commit 72f4723

Browse files
authored
Support generating OpenAPI operation and associated fields (#54903)
* Support generating OpenAPI operation and associated fields * Address feedback
1 parent 1c6cfeb commit 72f4723

File tree

4 files changed

+269
-7
lines changed

4 files changed

+269
-7
lines changed

src/OpenApi/sample/Program.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@
2020
var v2 = app.MapGroup("v2")
2121
.WithGroupName("v2");
2222

23-
v1.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo));
24-
v1.MapGet("/todos/{id}", (int id) => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now));
23+
v1.MapPost("/todos", (Todo todo) => Results.Created($"/todos/{todo.Id}", todo))
24+
.WithSummary("Creates a new todo item.");
25+
v1.MapGet("/todos/{id}", (int id) => new TodoWithDueDate(1, "Test todo", false, DateTime.Now.AddDays(1), DateTime.Now))
26+
.WithDescription("Returns a specific todo item.");
27+
28+
v2.MapGet("/users", () => new [] { "alice", "bob" })
29+
.WithTags("users");
2530

2631
v2.MapPost("/users", () => Results.Created("/users/1", new { Id = 1, Name = "Test user" }));
2732

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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.OpenApi.Models;
5+
6+
namespace Microsoft.AspNetCore.OpenApi;
7+
8+
/// <summary>
9+
/// This comparer is used to maintain a globally unique list of tags encountered
10+
/// in a particular OpenAPI document.
11+
/// </summary>
12+
internal class OpenApiTagComparer : IEqualityComparer<OpenApiTag>
13+
{
14+
public static OpenApiTagComparer Instance { get; } = new OpenApiTagComparer();
15+
16+
public bool Equals(OpenApiTag? x, OpenApiTag? y)
17+
{
18+
if (x is null && y is null)
19+
{
20+
return true;
21+
}
22+
if (x is null || y is null)
23+
{
24+
return false;
25+
}
26+
// Tag comparisons are case-sensitive by default. Although the OpenAPI specification
27+
// only outlines case sensitivity for property names, we extend this principle to
28+
// property values for tag names as well.
29+
// See https://spec.openapis.org/oas/v3.1.0#format.
30+
return string.Equals(x.Name, y.Name, StringComparison.Ordinal);
31+
}
32+
33+
public int GetHashCode(OpenApiTag obj) => obj.Name.GetHashCode();
34+
}

src/OpenApi/src/Services/OpenApiDocumentService.cs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Diagnostics;
55
using System.Linq;
6+
using Microsoft.AspNetCore.Http.Metadata;
67
using Microsoft.AspNetCore.Mvc.ApiExplorer;
78
using Microsoft.Extensions.DependencyInjection;
89
using Microsoft.Extensions.Hosting;
@@ -21,10 +22,14 @@ internal sealed class OpenApiDocumentService(
2122

2223
public Task<OpenApiDocument> GetOpenApiDocumentAsync()
2324
{
25+
// For good hygiene, operation-level tags must also appear in the document-level
26+
// tags collection. This set captures all tags that have been seen so far.
27+
HashSet<OpenApiTag> capturedTags = new(OpenApiTagComparer.Instance);
2428
var document = new OpenApiDocument
2529
{
2630
Info = GetOpenApiInfo(),
27-
Paths = GetOpenApiPaths()
31+
Paths = GetOpenApiPaths(capturedTags),
32+
Tags = [.. capturedTags]
2833
};
2934
return Task.FromResult(document);
3035
}
@@ -48,7 +53,7 @@ internal OpenApiInfo GetOpenApiInfo()
4853
/// the object to support filtering each
4954
/// description instance into its appropriate document.
5055
/// </remarks>
51-
private OpenApiPaths GetOpenApiPaths()
56+
private OpenApiPaths GetOpenApiPaths(HashSet<OpenApiTag> capturedTags)
5257
{
5358
var descriptionsByPath = apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items
5459
.SelectMany(group => group.Items)
@@ -58,18 +63,55 @@ private OpenApiPaths GetOpenApiPaths()
5863
foreach (var descriptions in descriptionsByPath)
5964
{
6065
Debug.Assert(descriptions.Key != null, "Relative path mapped to OpenApiPath key cannot be null.");
61-
paths.Add(descriptions.Key, new OpenApiPathItem { Operations = GetOperations(descriptions) });
66+
paths.Add(descriptions.Key, new OpenApiPathItem { Operations = GetOperations(descriptions, capturedTags) });
6267
}
6368
return paths;
6469
}
6570

66-
private static Dictionary<OperationType, OpenApiOperation> GetOperations(IGrouping<string?, ApiDescription> descriptions)
71+
private static Dictionary<OperationType, OpenApiOperation> GetOperations(IGrouping<string?, ApiDescription> descriptions, HashSet<OpenApiTag> capturedTags)
6772
{
6873
var operations = new Dictionary<OperationType, OpenApiOperation>();
6974
foreach (var description in descriptions)
7075
{
71-
operations[description.GetOperationType()] = new OpenApiOperation();
76+
operations[description.GetOperationType()] = GetOperation(description, capturedTags);
7277
}
7378
return operations;
7479
}
80+
81+
private static OpenApiOperation GetOperation(ApiDescription description, HashSet<OpenApiTag> capturedTags)
82+
{
83+
var tags = GetTags(description);
84+
if (tags != null)
85+
{
86+
foreach (var tag in tags)
87+
{
88+
capturedTags.Add(tag);
89+
}
90+
}
91+
var operation = new OpenApiOperation
92+
{
93+
Summary = GetSummary(description),
94+
Description = GetDescription(description),
95+
Tags = tags,
96+
};
97+
return operation;
98+
}
99+
100+
private static string? GetSummary(ApiDescription description)
101+
=> description.ActionDescriptor.EndpointMetadata.OfType<IEndpointSummaryMetadata>().LastOrDefault()?.Summary;
102+
103+
private static string? GetDescription(ApiDescription description)
104+
=> description.ActionDescriptor.EndpointMetadata.OfType<IEndpointDescriptionMetadata>().LastOrDefault()?.Description;
105+
106+
private static List<OpenApiTag>? GetTags(ApiDescription description)
107+
{
108+
var actionDescriptor = description.ActionDescriptor;
109+
if (actionDescriptor.EndpointMetadata?.OfType<ITagsMetadata>().LastOrDefault() is { } tagsMetadata)
110+
{
111+
return tagsMetadata.Tags.Select(tag => new OpenApiTag { Name = tag }).ToList();
112+
}
113+
// If no tags are specified, use the controller name as the tag. This effectively
114+
// allows us to group endpoints by the "resource" concept (e.g. users, todos, etc.)
115+
return [new OpenApiTag { Name = description.ActionDescriptor.RouteValues["controller"] }];
116+
}
75117
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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.Builder;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.OpenApi.Models;
7+
8+
public partial class OpenApiDocumentServiceTests
9+
{
10+
[Fact]
11+
public async Task GetOpenApiOperation_CapturesSummary()
12+
{
13+
// Arrange
14+
var builder = CreateBuilder();
15+
var summary = "Get all todos";
16+
17+
// Act
18+
builder.MapGet("/api/todos", () => { }).WithSummary(summary);
19+
20+
// Assert
21+
await VerifyOpenApiDocument(builder, document =>
22+
{
23+
var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
24+
Assert.Equal(summary, operation.Summary);
25+
});
26+
}
27+
28+
[Fact]
29+
public async Task GetOpenApiOperation_CapturesLastSummary()
30+
{
31+
// Arrange
32+
var builder = CreateBuilder();
33+
var summary = "Get all todos";
34+
35+
// Act
36+
builder.MapGet("/api/todos", () => { }).WithSummary(summary).WithSummary(summary + "1");
37+
38+
// Assert
39+
await VerifyOpenApiDocument(builder, document =>
40+
{
41+
var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
42+
Assert.Equal(summary + "1", operation.Summary);
43+
});
44+
}
45+
46+
[Fact]
47+
public async Task GetOpenApiOperation_CapturesDescription()
48+
{
49+
// Arrange
50+
var builder = CreateBuilder();
51+
var description = "Returns all the todos provided in an array.";
52+
53+
// Act
54+
builder.MapGet("/api/todos", () => { }).WithDescription(description);
55+
56+
// Assert
57+
await VerifyOpenApiDocument(builder, document =>
58+
{
59+
var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
60+
Assert.Equal(description, operation.Description);
61+
});
62+
}
63+
64+
[Fact]
65+
public async Task GetOpenApiOperation_CapturesDescriptionLastDescription()
66+
{
67+
// Arrange
68+
var builder = CreateBuilder();
69+
var description = "Returns all the todos provided in an array.";
70+
71+
// Act
72+
builder.MapGet("/api/todos", () => { }).WithDescription(description).WithDescription(description + "1");
73+
74+
// Assert
75+
await VerifyOpenApiDocument(builder, document =>
76+
{
77+
var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
78+
Assert.Equal(description + "1", operation.Description);
79+
});
80+
}
81+
82+
[Fact]
83+
public async Task GetOpenApiOperation_CapturesTags()
84+
{
85+
// Arrange
86+
var builder = CreateBuilder();
87+
88+
// Act
89+
builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]);
90+
91+
// Assert
92+
await VerifyOpenApiDocument(builder, document =>
93+
{
94+
var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
95+
Assert.Collection(operation.Tags, tag =>
96+
{
97+
Assert.Equal("todos", tag.Name);
98+
},
99+
tag =>
100+
{
101+
Assert.Equal("v1", tag.Name);
102+
});
103+
});
104+
}
105+
106+
[Fact]
107+
public async Task GetOpenApiOperation_CapturesTagsLastTags()
108+
{
109+
// Arrange
110+
var builder = CreateBuilder();
111+
112+
// Act
113+
builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]).WithTags(["todos", "v2"]);
114+
115+
// Assert
116+
await VerifyOpenApiDocument(builder, document =>
117+
{
118+
var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
119+
Assert.Collection(operation.Tags, tag =>
120+
{
121+
Assert.Equal("todos", tag.Name);
122+
},
123+
tag =>
124+
{
125+
Assert.Equal("v2", tag.Name);
126+
});
127+
});
128+
}
129+
130+
[Fact]
131+
public async Task GetOpenApiOperation_SetsDefaultValueForTags()
132+
{
133+
// Arrange
134+
var builder = CreateBuilder();
135+
136+
// Act
137+
builder.MapGet("/api/todos", () => { });
138+
139+
// Assert
140+
await VerifyOpenApiDocument(builder, document =>
141+
{
142+
var operation = document.Paths["/api/todos"].Operations[OperationType.Get];
143+
Assert.Collection(document.Tags, tag =>
144+
{
145+
Assert.Equal(nameof(OpenApiDocumentServiceTests), tag.Name);
146+
});
147+
Assert.Collection(operation.Tags, tag =>
148+
{
149+
Assert.Equal(nameof(OpenApiDocumentServiceTests), tag.Name);
150+
});
151+
});
152+
}
153+
154+
[Fact]
155+
public async Task GetOpenApiOperation_CapturesTagsInDocument()
156+
{
157+
// Arrange
158+
var builder = CreateBuilder();
159+
160+
// Act
161+
builder.MapGet("/api/todos", () => { }).WithTags(["todos", "v1"]);
162+
builder.MapGet("/api/users", () => { }).WithTags(["users", "v1"]);
163+
164+
// Assert
165+
await VerifyOpenApiDocument(builder, document =>
166+
{
167+
Assert.Collection(document.Tags, tag =>
168+
{
169+
Assert.Equal("todos", tag.Name);
170+
},
171+
tag =>
172+
{
173+
Assert.Equal("v1", tag.Name);
174+
},
175+
tag =>
176+
{
177+
Assert.Equal("users", tag.Name);
178+
});
179+
});
180+
}
181+
}

0 commit comments

Comments
 (0)