Skip to content

Commit c2f1caa

Browse files
Improve support for OpenAPI in minimal actions (#34906)
* Support setting content types in ProducesResponseTypeAttribute to close #34542 * Add WithName extension method to resolve #34538 * Support setting endpoints on group names to resolve #34541 * Add OpenAPI extension methods to resolve #33924 * Add tests for new OpenAPI methods * Add endpoint metadata attributes * Update PublicAPI files with deltas * Add support for SuppressApi to close #34068 * Update tests to account for supporting setting content types * Fix up PublicAPI analyzer warnings * Clean up source files * Address feedback from API review * Fix typo and update type signature * Apply feedback from second API review * Update docstrings * Apply suggestions from code review Co-authored-by: Martin Costello <[email protected]> * Address non-test related feedback * Handle setting content types for ProducesResponseType attribute * Address feedback from peer review * Add test for ProducesResponseType override scenario Co-authored-by: Martin Costello <[email protected]>
1 parent a77a440 commit c2f1caa

19 files changed

+857
-55
lines changed

src/Http/Routing/src/Builder/RoutingEndpointConventionBuilderExtensions.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,35 @@ public static TBuilder WithMetadata<TBuilder>(this TBuilder builder, params obje
118118

119119
return builder;
120120
}
121+
122+
/// <summary>
123+
/// Sets the <see cref="EndpointNameAttribute"/> for all endpoints produced
124+
/// on the target <see cref="IEndpointConventionBuilder"/> given the <paramref name="endpointName" />.
125+
/// The <see cref="IEndpointNameMetadata" /> on the endpoint is used for link generation and
126+
/// is treated as the operation ID in the given endpoint's OpenAPI specification.
127+
/// </summary>
128+
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/>.</param>
129+
/// <param name="endpointName">The endpoint name.</param>
130+
/// <returns>The <see cref="IEndpointConventionBuilder"/>.</returns>
131+
public static TBuilder WithName<TBuilder>(this TBuilder builder, string endpointName) where TBuilder : IEndpointConventionBuilder
132+
{
133+
builder.WithMetadata(new EndpointNameAttribute(endpointName));
134+
return builder;
135+
}
136+
137+
/// <summary>
138+
/// Sets the <see cref="EndpointGroupNameAttribute"/> for all endpoints produced
139+
/// on the target <see cref="IEndpointConventionBuilder"/> given the <paramref name="endpointGroupName" />.
140+
/// The <see cref="IEndpointGroupNameMetadata" /> on the endpoint is used to set the endpoint's
141+
/// GroupName in the OpenAPI specification.
142+
/// </summary>
143+
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/>.</param>
144+
/// <param name="endpointGroupName">The endpoint group name.</param>
145+
/// <returns>The <see cref="IEndpointConventionBuilder"/>.</returns>
146+
public static TBuilder WithGroupName<TBuilder>(this TBuilder builder, string endpointGroupName) where TBuilder : IEndpointConventionBuilder
147+
{
148+
builder.WithMetadata(new EndpointGroupNameAttribute(endpointGroupName));
149+
return builder;
150+
}
121151
}
122152
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace Microsoft.AspNetCore.Routing
8+
{
9+
/// <summary>
10+
/// Specifies the endpoint group name in <see cref="Microsoft.AspNetCore.Http.Endpoint.Metadata"/>.
11+
/// </summary>
12+
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate | AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
13+
public sealed class EndpointGroupNameAttribute : Attribute, IEndpointGroupNameMetadata
14+
{
15+
/// <summary>
16+
/// Initializes an instance of the <see cref="EndpointGroupNameAttribute"/>.
17+
/// </summary>
18+
/// <param name="endpointGroupName">The endpoint group name.</param>
19+
public EndpointGroupNameAttribute(string endpointGroupName)
20+
{
21+
if (endpointGroupName == null)
22+
{
23+
throw new ArgumentNullException(nameof(endpointGroupName));
24+
}
25+
26+
EndpointGroupName = endpointGroupName;
27+
}
28+
29+
/// <inheritdoc />
30+
public string EndpointGroupName { get; }
31+
}
32+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace Microsoft.AspNetCore.Routing
8+
{
9+
/// <summary>
10+
/// Specifies the endpoint name in <see cref="Endpoint.Metadata"/>.
11+
/// </summary>
12+
/// <remarks>
13+
/// Endpoint names must be unique within an application, and can be used to unambiguously
14+
/// identify a desired endpoint for URI generation using <see cref="Microsoft.AspNetCore.Routing.LinkGenerator"/>
15+
/// </remarks>
16+
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate, Inherited = false, AllowMultiple = false)]
17+
public sealed class EndpointNameAttribute : Attribute, IEndpointNameMetadata
18+
{
19+
/// <summary>
20+
/// Initializes an instance of the EndpointNameAttribute.
21+
/// </summary>
22+
/// <param name="endpointName">The endpoint name.</param>
23+
public EndpointNameAttribute(string endpointName)
24+
{
25+
if (endpointName == null)
26+
{
27+
throw new ArgumentNullException(nameof(endpointName));
28+
}
29+
30+
EndpointName = endpointName;
31+
}
32+
33+
/// <inheritdoc />
34+
public string EndpointName { get; }
35+
}
36+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace Microsoft.AspNetCore.Routing
8+
{
9+
/// <summary>
10+
/// Indicates that this <see cref="Endpoint"/> should not be included in the generated API metadata.
11+
/// </summary>
12+
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Delegate, AllowMultiple = false, Inherited = true)]
13+
public sealed class ExcludeFromDescriptionAttribute : Attribute, IExcludeFromDescriptionMetadata
14+
{
15+
/// <inheritdoc />
16+
public bool ExcludeFromDescription => true;
17+
}
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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.Http;
5+
6+
namespace Microsoft.AspNetCore.Routing
7+
{
8+
/// <summary>
9+
/// Defines a contract used to specify an endpoint group name in <see cref="Endpoint.Metadata"/>.
10+
/// </summary>
11+
public interface IEndpointGroupNameMetadata
12+
{
13+
/// <summary>
14+
/// Gets the endpoint group name.
15+
/// </summary>
16+
string EndpointGroupName { get; }
17+
}
18+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace Microsoft.AspNetCore.Routing
8+
{
9+
/// <summary>
10+
/// Indicates whether or not that API explorer data should be emitted for this endpoint.
11+
/// </summary>
12+
public interface IExcludeFromDescriptionMetadata
13+
{
14+
/// <summary>
15+
/// Gets a value indicating whether OpenAPI
16+
/// data should be excluded for this endpoint. If <see langword="true"/>,
17+
/// API metadata is not emitted.
18+
/// </summary>
19+
bool ExcludeFromDescription { get; }
20+
}
21+
}

src/Http/Routing/src/PublicAPI.Unshipped.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,18 @@ static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.
2525
static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapMethods(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Collections.Generic.IEnumerable<string!>! httpMethods, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder!
2626
static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapPost(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder!
2727
static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapPut(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder!
28+
Microsoft.AspNetCore.Routing.IEndpointGroupNameMetadata
29+
Microsoft.AspNetCore.Routing.IEndpointGroupNameMetadata.EndpointGroupName.get -> string!
30+
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute
31+
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute.EndpointGroupNameAttribute(string! endpointGroupName) -> void
32+
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute.EndpointGroupName.get -> string!
33+
Microsoft.AspNetCore.Routing.EndpointNameAttribute
34+
Microsoft.AspNetCore.Routing.EndpointNameAttribute.EndpointNameAttribute(string! endpointName) -> void
35+
Microsoft.AspNetCore.Routing.EndpointNameAttribute.EndpointName.get -> string!
36+
static Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions.WithName<TBuilder>(this TBuilder builder, string! endpointName) -> TBuilder
37+
static Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions.WithGroupName<TBuilder>(this TBuilder builder, string! endpointGroupName) -> TBuilder
38+
Microsoft.AspNetCore.Routing.IExcludeFromDescriptionMetadata
39+
Microsoft.AspNetCore.Routing.IExcludeFromDescriptionMetadata.ExcludeFromDescription.get -> bool
40+
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute
41+
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute.ExcludeFromDescriptionAttribute() -> void
42+
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute.ExcludeFromDescription.get -> bool

src/Http/Routing/test/UnitTests/Builder/RoutingEndpointConventionBuilderExtensionsTest.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,38 @@ public void WithMetadata_ChainedCall_ReturnedBuilderIsDerivedType()
115115
Assert.True(chainedBuilder.TestProperty);
116116
}
117117

118+
[Fact]
119+
public void WithName_SetsEndpointName()
120+
{
121+
// Arrange
122+
var builder = CreateBuilder();
123+
124+
// Act
125+
builder.WithName("SomeEndpointName");
126+
127+
// Assert
128+
var endpoint = builder.Build();
129+
130+
var endpointName = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
131+
Assert.Equal("SomeEndpointName", endpointName.EndpointName);
132+
}
133+
134+
[Fact]
135+
public void WithGroupName_SetsEndpointGroupName()
136+
{
137+
// Arrange
138+
var builder = CreateBuilder();
139+
140+
// Act
141+
builder.WithGroupName("SomeEndpointGroupName");
142+
143+
// Assert
144+
var endpoint = builder.Build();
145+
146+
var endpointGroupName = endpoint.Metadata.GetMetadata<IEndpointGroupNameMetadata>();
147+
Assert.Equal("SomeEndpointGroupName", endpointGroupName.EndpointGroupName);
148+
}
149+
118150
private TestEndpointConventionBuilder CreateBuilder()
119151
{
120152
var conventionBuilder = new DefaultEndpointConventionBuilder(new RouteEndpointBuilder(

src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,14 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
7979
Type defaultErrorType)
8080
{
8181
var contentTypes = new MediaTypeCollection();
82+
var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType<IApiResponseTypeMetadataProvider>();
8283

83-
var responseTypes = ReadResponseMetadata(responseMetadataAttributes, type, defaultErrorType, contentTypes);
84+
var responseTypes = ReadResponseMetadata(
85+
responseMetadataAttributes,
86+
type,
87+
defaultErrorType,
88+
contentTypes,
89+
responseTypeMetadataProviders);
8490

8591
// Set the default status only when no status has already been set explicitly
8692
if (responseTypes.Count == 0 && type != null)
@@ -102,7 +108,10 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
102108
contentTypes.Add((string)null!);
103109
}
104110

105-
CalculateResponseFormats(responseTypes, contentTypes);
111+
foreach (var apiResponse in responseTypes)
112+
{
113+
CalculateResponseFormatForType(apiResponse, contentTypes, responseTypeMetadataProviders, _modelMetadataProvider);
114+
}
106115

107116
return responseTypes;
108117
}
@@ -112,7 +121,9 @@ internal static List<ApiResponseType> ReadResponseMetadata(
112121
IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
113122
Type? type,
114123
Type defaultErrorType,
115-
MediaTypeCollection contentTypes)
124+
MediaTypeCollection contentTypes,
125+
IEnumerable<IApiResponseTypeMetadataProvider>? responseTypeMetadataProviders = null,
126+
IModelMetadataProvider? modelMetadataProvider = null)
116127
{
117128
var results = new Dictionary<int, ApiResponseType>();
118129

@@ -123,7 +134,18 @@ internal static List<ApiResponseType> ReadResponseMetadata(
123134
{
124135
foreach (var metadataAttribute in responseMetadataAttributes)
125136
{
126-
metadataAttribute.SetContentTypes(contentTypes);
137+
// All ProducesXAttributes, except for ProducesResponseTypeAttribute do
138+
// not allow multiple instances on the same method/class/etc. For those
139+
// scenarios, the `SetContentTypes` method on the attribute continuously
140+
// clears out more general content types in favor of more specific ones
141+
// since we iterate through the attributes in order. For example, if a
142+
// Produces exists on both a controller and an action within the controller,
143+
// we favor the definition in the action. This is a semantic that does not
144+
// apply to ProducesResponseType, which allows multiple instances on an target.
145+
if (metadataAttribute is not ProducesResponseTypeAttribute)
146+
{
147+
metadataAttribute.SetContentTypes(contentTypes);
148+
}
127149

128150
var statusCode = metadataAttribute.StatusCode;
129151

@@ -157,6 +179,18 @@ internal static List<ApiResponseType> ReadResponseMetadata(
157179
}
158180
}
159181

182+
// We special case the handling of ProcuesResponseTypeAttributes since
183+
// multiple ProducesResponseTypeAttributes are permitted on a single
184+
// action/controller/etc. In that scenario, instead of picking the most-specific
185+
// set of content types (like we do with the Produces attribute above) we process
186+
// the content types for each attribute independently.
187+
if (metadataAttribute is ProducesResponseTypeAttribute)
188+
{
189+
var attributeContentTypes = new MediaTypeCollection();
190+
metadataAttribute.SetContentTypes(attributeContentTypes);
191+
CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider);
192+
}
193+
160194
if (apiResponseType.Type != null)
161195
{
162196
results[apiResponseType.StatusCode] = apiResponseType;
@@ -167,9 +201,15 @@ internal static List<ApiResponseType> ReadResponseMetadata(
167201
return results.Values.ToList();
168202
}
169203

170-
private void CalculateResponseFormats(ICollection<ApiResponseType> responseTypes, MediaTypeCollection declaredContentTypes)
204+
private static void CalculateResponseFormatForType(ApiResponseType apiResponse, MediaTypeCollection declaredContentTypes, IEnumerable<IApiResponseTypeMetadataProvider>? responseTypeMetadataProviders, IModelMetadataProvider? modelMetadataProvider)
171205
{
172-
var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType<IApiResponseTypeMetadataProvider>();
206+
// If response formats have already been calculate for this type,
207+
// then exit early. This avoids populating the ApiResponseFormat for
208+
// types that have already been handled, specifically ProducesResponseTypes.
209+
if (apiResponse.ApiResponseFormats.Count > 0)
210+
{
211+
return;
212+
}
173213

174214
// Given the content-types that were declared for this action, determine the formatters that support the content-type for the given
175215
// response type.
@@ -179,21 +219,20 @@ private void CalculateResponseFormats(ICollection<ApiResponseType> responseTypes
179219
// 3. When no formatter supports the specified content-type, use the user specified value as is. This is useful in actions where the user
180220
// dictates the content-type.
181221
// e.g. [Produces("application/pdf")] Action() => FileStream("somefile.pdf", "application/pdf");
182-
183-
foreach (var apiResponse in responseTypes)
222+
var responseType = apiResponse.Type;
223+
if (responseType == null || responseType == typeof(void))
184224
{
185-
var responseType = apiResponse.Type;
186-
if (responseType == null || responseType == typeof(void))
187-
{
188-
continue;
189-
}
225+
return;
226+
}
190227

191-
apiResponse.ModelMetadata = _modelMetadataProvider.GetMetadataForType(responseType);
228+
apiResponse.ModelMetadata = modelMetadataProvider?.GetMetadataForType(responseType);
192229

193-
foreach (var contentType in declaredContentTypes)
194-
{
195-
var isSupportedContentType = false;
230+
foreach (var contentType in declaredContentTypes)
231+
{
232+
var isSupportedContentType = false;
196233

234+
if (responseTypeMetadataProviders != null)
235+
{
197236
foreach (var responseTypeMetadataProvider in responseTypeMetadataProviders)
198237
{
199238
var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes(
@@ -216,15 +255,17 @@ private void CalculateResponseFormats(ICollection<ApiResponseType> responseTypes
216255
});
217256
}
218257
}
258+
}
259+
260+
219261

220-
if (!isSupportedContentType && contentType != null)
262+
if (!isSupportedContentType && contentType != null)
263+
{
264+
// No output formatter was found that supports this content type. Add the user specified content type as-is to the result.
265+
apiResponse.ApiResponseFormats.Add(new ApiResponseFormat
221266
{
222-
// No output formatter was found that supports this content type. Add the user specified content type as-is to the result.
223-
apiResponse.ApiResponseFormats.Add(new ApiResponseFormat
224-
{
225-
MediaType = contentType,
226-
});
227-
}
267+
MediaType = contentType,
268+
});
228269
}
229270
}
230271
}

0 commit comments

Comments
 (0)