Skip to content

Improve support for OpenAPI in minimal actions #34906

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 6, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f0d2558
Support setting content types in ProducesResponseTypeAttribute to clo…
captainsafia Jul 27, 2021
e6c4335
Add WithName extension method to resolve #34538
captainsafia Jul 27, 2021
74df1e3
Support setting endpoints on group names to resolve #34541
captainsafia Jul 27, 2021
1eefa01
Add OpenAPI extension methods to resolve #33924
captainsafia Jul 28, 2021
a85aab1
Add tests for new OpenAPI methods
captainsafia Jul 29, 2021
d21964e
Add endpoint metadata attributes
captainsafia Jul 29, 2021
57f36ec
Update PublicAPI files with deltas
captainsafia Jul 29, 2021
5add623
Add support for SuppressApi to close #34068
captainsafia Jul 30, 2021
3940f1f
Update tests to account for supporting setting content types
captainsafia Jul 30, 2021
96cec51
Fix up PublicAPI analyzer warnings
captainsafia Jul 31, 2021
d5e6e73
Clean up source files
captainsafia Jul 31, 2021
14dbfab
Address feedback from API review
captainsafia Aug 2, 2021
5c21deb
Fix typo and update type signature
captainsafia Aug 2, 2021
a914a2d
Apply feedback from second API review
captainsafia Aug 3, 2021
8885db7
Update docstrings
captainsafia Aug 3, 2021
504b9f1
Apply suggestions from code review
captainsafia Aug 4, 2021
a3e4171
Address non-test related feedback
captainsafia Aug 5, 2021
c8a1cd9
Handle setting content types for ProducesResponseType attribute
captainsafia Aug 5, 2021
63d6329
Address feedback from peer review
captainsafia Aug 6, 2021
94260c4
Add test for ProducesResponseType override scenario
captainsafia Aug 6, 2021
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
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,35 @@ public static TBuilder WithMetadata<TBuilder>(this TBuilder builder, params obje

return builder;
}

/// <summary>
/// Sets the <see cref="EndpointNameAttribute"/> for all endpoints produced
/// on the target <see cref="IEndpointConventionBuilder"/> given the <paramref name="endpointName" />.
/// The <see cref="IEndpointNameMetadata" /> on the endpoint is used for link generation and
/// is treated as the operation ID in the given endpoint's OpenAPI specification.
/// </summary>
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/>.</param>
/// <param name="endpointName">The endpoint name.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/>.</returns>
public static TBuilder WithName<TBuilder>(this TBuilder builder, string endpointName) where TBuilder : IEndpointConventionBuilder
Copy link
Member

Choose a reason for hiding this comment

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

excited about this

{
builder.WithMetadata(new EndpointNameAttribute(endpointName));
return builder;
}

/// <summary>
/// Sets the <see cref="EndpointGroupNameAttribute"/> for all endpoints produced
/// on the target <see cref="IEndpointConventionBuilder"/> given the <paramref name="endpointGroupName" />.
/// The <see cref="IEndpointGroupNameMetadata" /> on the endpoint is used to set the endpoint's
/// GroupName in the OpenAPI specification.
/// </summary>
/// <param name="builder">The <see cref="IEndpointConventionBuilder"/>.</param>
/// <param name="endpointGroupName">The endpoint group name.</param>
/// <returns>The <see cref="IEndpointConventionBuilder"/>.</returns>
public static TBuilder WithGroupName<TBuilder>(this TBuilder builder, string endpointGroupName) where TBuilder : IEndpointConventionBuilder
{
builder.WithMetadata(new EndpointGroupNameAttribute(endpointGroupName));
return builder;
}
}
}
32 changes: 32 additions & 0 deletions src/Http/Routing/src/EndpointGroupNameAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Specifies the endpoint group name in <see cref="Microsoft.AspNetCore.Http.Endpoint.Metadata"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate | AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class EndpointGroupNameAttribute : Attribute, IEndpointGroupNameMetadata
{
/// <summary>
/// Initializes an instance of the <see cref="EndpointGroupNameAttribute"/>.
/// </summary>
/// <param name="endpointGroupName">The endpoint group name.</param>
public EndpointGroupNameAttribute(string endpointGroupName)
{
if (endpointGroupName == null)
{
throw new ArgumentNullException(nameof(endpointGroupName));
}

EndpointGroupName = endpointGroupName;
}

/// <inheritdoc />
public string EndpointGroupName { get; }
}
}
36 changes: 36 additions & 0 deletions src/Http/Routing/src/EndpointNameAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Specifies the endpoint name in <see cref="Endpoint.Metadata"/>.
/// </summary>
/// <remarks>
/// Endpoint names must be unique within an application, and can be used to unambiguously
/// identify a desired endpoint for URI generation using <see cref="Microsoft.AspNetCore.Routing.LinkGenerator"/>
/// </remarks>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Delegate, Inherited = false, AllowMultiple = false)]
public sealed class EndpointNameAttribute : Attribute, IEndpointNameMetadata
{
/// <summary>
/// Initializes an instance of the EndpointNameAttribute.
/// </summary>
/// <param name="endpointName">The endpoint name.</param>
public EndpointNameAttribute(string endpointName)
{
if (endpointName == null)
{
throw new ArgumentNullException(nameof(endpointName));
}

EndpointName = endpointName;
}

/// <inheritdoc />
public string EndpointName { get; }
}
}
18 changes: 18 additions & 0 deletions src/Http/Routing/src/ExcludeFromDescriptionAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Indicates that this <see cref="Endpoint"/> should not be included in the generated API metadata.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Delegate, AllowMultiple = false, Inherited = true)]
public sealed class ExcludeFromDescriptionAttribute : Attribute, IExcludeFromDescriptionMetadata
{
/// <inheritdoc />
public bool ExcludeFromDescription => true;
}
}
18 changes: 18 additions & 0 deletions src/Http/Routing/src/IEndpointGroupNameMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Defines a contract used to specify an endpoint group name in <see cref="Endpoint.Metadata"/>.
/// </summary>
public interface IEndpointGroupNameMetadata
{
/// <summary>
/// Gets the endpoint group name.
/// </summary>
string EndpointGroupName { get; }
}
}
21 changes: 21 additions & 0 deletions src/Http/Routing/src/IExcludeFromDescriptionMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Routing
{
/// <summary>
/// Indicates whether or not that API explorer data should be emitted for this endpoint.
/// </summary>
public interface IExcludeFromDescriptionMetadata
{
/// <summary>
/// Gets a value indicating whether OpenAPI
/// data should be excluded for this endpoint. If <see langword="true"/>,
/// API metadata is not emitted.
/// </summary>
bool ExcludeFromDescription { get; }
}
}
15 changes: 15 additions & 0 deletions src/Http/Routing/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,18 @@ static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.
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!
static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapPost(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder!
static Microsoft.AspNetCore.Builder.MinimalActionEndpointRouteBuilderExtensions.MapPut(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints, string! pattern, System.Delegate! action) -> Microsoft.AspNetCore.Builder.MinimalActionEndpointConventionBuilder!
Microsoft.AspNetCore.Routing.IEndpointGroupNameMetadata
Microsoft.AspNetCore.Routing.IEndpointGroupNameMetadata.EndpointGroupName.get -> string!
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute.EndpointGroupNameAttribute(string! endpointGroupName) -> void
Microsoft.AspNetCore.Routing.EndpointGroupNameAttribute.EndpointGroupName.get -> string!
Microsoft.AspNetCore.Routing.EndpointNameAttribute
Microsoft.AspNetCore.Routing.EndpointNameAttribute.EndpointNameAttribute(string! endpointName) -> void
Microsoft.AspNetCore.Routing.EndpointNameAttribute.EndpointName.get -> string!
static Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions.WithName<TBuilder>(this TBuilder builder, string! endpointName) -> TBuilder
static Microsoft.AspNetCore.Builder.RoutingEndpointConventionBuilderExtensions.WithGroupName<TBuilder>(this TBuilder builder, string! endpointGroupName) -> TBuilder
Microsoft.AspNetCore.Routing.IExcludeFromDescriptionMetadata
Microsoft.AspNetCore.Routing.IExcludeFromDescriptionMetadata.ExcludeFromDescription.get -> bool
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute.ExcludeFromDescriptionAttribute() -> void
Microsoft.AspNetCore.Routing.ExcludeFromDescriptionAttribute.ExcludeFromDescription.get -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,38 @@ public void WithMetadata_ChainedCall_ReturnedBuilderIsDerivedType()
Assert.True(chainedBuilder.TestProperty);
}

[Fact]
public void WithName_SetsEndpointName()
{
// Arrange
var builder = CreateBuilder();

// Act
builder.WithName("SomeEndpointName");

// Assert
var endpoint = builder.Build();

var endpointName = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
Assert.Equal("SomeEndpointName", endpointName.EndpointName);
}

[Fact]
public void WithGroupName_SetsEndpointGroupName()
{
// Arrange
var builder = CreateBuilder();

// Act
builder.WithGroupName("SomeEndpointGroupName");

// Assert
var endpoint = builder.Build();

var endpointGroupName = endpoint.Metadata.GetMetadata<IEndpointGroupNameMetadata>();
Assert.Equal("SomeEndpointGroupName", endpointGroupName.EndpointGroupName);
}

private TestEndpointConventionBuilder CreateBuilder()
{
var conventionBuilder = new DefaultEndpointConventionBuilder(new RouteEndpointBuilder(
Expand Down
89 changes: 65 additions & 24 deletions src/Mvc/Mvc.ApiExplorer/src/ApiResponseTypeProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,14 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
Type defaultErrorType)
{
var contentTypes = new MediaTypeCollection();
var responseTypeMetadataProviders = _mvcOptions.OutputFormatters.OfType<IApiResponseTypeMetadataProvider>();

var responseTypes = ReadResponseMetadata(responseMetadataAttributes, type, defaultErrorType, contentTypes);
var responseTypes = ReadResponseMetadata(
responseMetadataAttributes,
type,
defaultErrorType,
contentTypes,
responseTypeMetadataProviders);

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

CalculateResponseFormats(responseTypes, contentTypes);
foreach (var apiResponse in responseTypes)
{
CalculateResponseFormatForType(apiResponse, contentTypes, responseTypeMetadataProviders, _modelMetadataProvider);
}

return responseTypes;
}
Expand All @@ -112,7 +121,9 @@ internal static List<ApiResponseType> ReadResponseMetadata(
IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
Type? type,
Type defaultErrorType,
MediaTypeCollection contentTypes)
MediaTypeCollection contentTypes,
IEnumerable<IApiResponseTypeMetadataProvider>? responseTypeMetadataProviders = null,
IModelMetadataProvider? modelMetadataProvider = null)
{
var results = new Dictionary<int, ApiResponseType>();

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

var statusCode = metadataAttribute.StatusCode;

Expand Down Expand Up @@ -157,6 +179,18 @@ internal static List<ApiResponseType> ReadResponseMetadata(
}
}

// We special case the handling of ProcuesResponseTypeAttributes since
// multiple ProducesResponseTypeAttributes are permitted on a single
// action/controller/etc. In that scenario, instead of picking the most-specific
// set of content types (like we do with the Produces attribute above) we process
// the content types for each attribute independently.
if (metadataAttribute is ProducesResponseTypeAttribute)
{
var attributeContentTypes = new MediaTypeCollection();
metadataAttribute.SetContentTypes(attributeContentTypes);
CalculateResponseFormatForType(apiResponseType, attributeContentTypes, responseTypeMetadataProviders, modelMetadataProvider);
}

if (apiResponseType.Type != null)
{
results[apiResponseType.StatusCode] = apiResponseType;
Expand All @@ -167,9 +201,15 @@ internal static List<ApiResponseType> ReadResponseMetadata(
return results.Values.ToList();
}

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

// Given the content-types that were declared for this action, determine the formatters that support the content-type for the given
// response type.
Expand All @@ -179,21 +219,20 @@ private void CalculateResponseFormats(ICollection<ApiResponseType> responseTypes
// 3. When no formatter supports the specified content-type, use the user specified value as is. This is useful in actions where the user
// dictates the content-type.
// e.g. [Produces("application/pdf")] Action() => FileStream("somefile.pdf", "application/pdf");

foreach (var apiResponse in responseTypes)
var responseType = apiResponse.Type;
if (responseType == null || responseType == typeof(void))
{
var responseType = apiResponse.Type;
if (responseType == null || responseType == typeof(void))
{
continue;
}
return;
}

apiResponse.ModelMetadata = _modelMetadataProvider.GetMetadataForType(responseType);
apiResponse.ModelMetadata = modelMetadataProvider?.GetMetadataForType(responseType);

foreach (var contentType in declaredContentTypes)
{
var isSupportedContentType = false;
foreach (var contentType in declaredContentTypes)
{
var isSupportedContentType = false;

if (responseTypeMetadataProviders != null)
{
foreach (var responseTypeMetadataProvider in responseTypeMetadataProviders)
{
var formatterSupportedContentTypes = responseTypeMetadataProvider.GetSupportedContentTypes(
Expand All @@ -216,15 +255,17 @@ private void CalculateResponseFormats(ICollection<ApiResponseType> responseTypes
});
}
}
}



if (!isSupportedContentType && contentType != null)
if (!isSupportedContentType && contentType != null)
{
// No output formatter was found that supports this content type. Add the user specified content type as-is to the result.
apiResponse.ApiResponseFormats.Add(new ApiResponseFormat
{
// No output formatter was found that supports this content type. Add the user specified content type as-is to the result.
apiResponse.ApiResponseFormats.Add(new ApiResponseFormat
{
MediaType = contentType,
});
}
MediaType = contentType,
});
}
}
}
Expand Down
Loading