Skip to content

Commit fd19f92

Browse files
authored
Add OpenAPI/Swagger support for minimal actions (#33433)
1 parent 1ead769 commit fd19f92

16 files changed

+947
-110
lines changed

src/Http/Http.Extensions/src/Microsoft.AspNetCore.Http.Extensions.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212

1313
<ItemGroup>
1414
<Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" />
15-
<Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" Link="StreamCopyOperationInternal.cs" />
15+
<Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" />
16+
<Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" />
1617
</ItemGroup>
1718

1819
<ItemGroup>

src/Http/Http.Extensions/src/RequestDelegateFactory.cs

Lines changed: 3 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Collections.Concurrent;
65
using System.Collections.Generic;
7-
using System.Globalization;
86
using System.IO;
97
using System.Linq;
108
using System.Linq.Expressions;
@@ -34,7 +32,6 @@ public static class RequestDelegateFactory
3432
private static readonly MethodInfo ResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(ExecuteResultWriteResponse), BindingFlags.NonPublic | BindingFlags.Static)!;
3533
private static readonly MethodInfo StringResultWriteResponseAsyncMethod = GetMethodInfo<Func<HttpResponse, string, Task>>((response, text) => HttpResponseWritingExtensions.WriteAsync(response, text, default));
3634
private static readonly MethodInfo JsonResultWriteResponseAsyncMethod = GetMethodInfo<Func<HttpResponse, object, Task>>((response, value) => HttpResponseJsonExtensions.WriteAsJsonAsync(response, value, default));
37-
private static readonly MethodInfo EnumTryParseMethod = GetEnumTryParseMethod();
3835
private static readonly MethodInfo LogParameterBindingFailureMethod = GetMethodInfo<Action<HttpContext, string, string, string>>((httpContext, parameterType, parameterName, sourceValue) =>
3936
Log.ParameterBindingFailed(httpContext, parameterType, parameterName, sourceValue));
4037

@@ -56,8 +53,6 @@ public static class RequestDelegateFactory
5653

5754
private static readonly BinaryExpression TempSourceStringNotNullExpr = Expression.NotEqual(TempSourceStringExpr, Expression.Constant(null));
5855

59-
private static readonly ConcurrentDictionary<Type, MethodInfo?> TryParseMethodCache = new();
60-
6156
/// <summary>
6257
/// Creates a <see cref="RequestDelegate"/> implementation for <paramref name="action"/>.
6358
/// </summary>
@@ -197,7 +192,7 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext
197192
{
198193
if (parameter.Name is null)
199194
{
200-
throw new InvalidOperationException("A parameter does not have a name! Was it genererated? All parameters must be named.");
195+
throw new InvalidOperationException("A parameter does not have a name! Was it generated? All parameters must be named.");
201196
}
202197

203198
var parameterCustomAttributes = parameter.GetCustomAttributes();
@@ -230,7 +225,7 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext
230225
{
231226
return RequestAbortedExpr;
232227
}
233-
else if (parameter.ParameterType == typeof(string) || HasTryParseMethod(parameter))
228+
else if (parameter.ParameterType == typeof(string) || TryParseMethodCache.HasTryParseMethod(parameter))
234229
{
235230
return BindParameterFromRouteValueOrQueryString(parameter, parameter.Name, factoryContext);
236231
}
@@ -477,72 +472,6 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall,
477472
};
478473
}
479474

480-
private static MethodInfo GetEnumTryParseMethod()
481-
{
482-
var staticEnumMethods = typeof(Enum).GetMethods(BindingFlags.Public | BindingFlags.Static);
483-
484-
foreach (var method in staticEnumMethods)
485-
{
486-
if (!method.IsGenericMethod || method.Name != "TryParse" || method.ReturnType != typeof(bool))
487-
{
488-
continue;
489-
}
490-
491-
var tryParseParameters = method.GetParameters();
492-
493-
if (tryParseParameters.Length == 2 &&
494-
tryParseParameters[0].ParameterType == typeof(string) &&
495-
tryParseParameters[1].IsOut)
496-
{
497-
return method;
498-
}
499-
}
500-
501-
throw new Exception("static bool System.Enum.TryParse<TEnum>(string? value, out TEnum result) does not exist!!?!?");
502-
}
503-
504-
// TODO: Use InvariantCulture where possible? Or is CurrentCulture fine because it's more flexible?
505-
private static MethodInfo? FindTryParseMethod(Type type)
506-
{
507-
static MethodInfo? Finder(Type type)
508-
{
509-
if (type.IsEnum)
510-
{
511-
return EnumTryParseMethod.MakeGenericMethod(type);
512-
}
513-
514-
var staticMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Static);
515-
516-
foreach (var method in staticMethods)
517-
{
518-
if (method.Name != "TryParse" || method.ReturnType != typeof(bool))
519-
{
520-
continue;
521-
}
522-
523-
var tryParseParameters = method.GetParameters();
524-
525-
if (tryParseParameters.Length == 2 &&
526-
tryParseParameters[0].ParameterType == typeof(string) &&
527-
tryParseParameters[1].IsOut &&
528-
tryParseParameters[1].ParameterType == type.MakeByRefType())
529-
{
530-
return method;
531-
}
532-
}
533-
534-
return null;
535-
}
536-
537-
return TryParseMethodCache.GetOrAdd(type, Finder);
538-
}
539-
540-
private static bool HasTryParseMethod(ParameterInfo parameter)
541-
{
542-
var nonNullableParameterType = Nullable.GetUnderlyingType(parameter.ParameterType) ?? parameter.ParameterType;
543-
return FindTryParseMethod(nonNullableParameterType) is not null;
544-
}
545-
546475
private static Expression GetValueFromProperty(Expression sourceExpression, string key)
547476
{
548477
var itemProperty = sourceExpression.Type.GetProperty("Item");
@@ -574,7 +503,7 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
574503
var isNotNullable = underlyingNullableType is null;
575504

576505
var nonNullableParameterType = underlyingNullableType ?? parameter.ParameterType;
577-
var tryParseMethod = FindTryParseMethod(nonNullableParameterType);
506+
var tryParseMethod = TryParseMethodCache.FindTryParseMethod(nonNullableParameterType);
578507

579508
if (tryParseMethod is null)
580509
{

src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ public void CreateThrowsInvalidOperationExceptionGivenUnnamedArgument()
479479
var unnamedParameter = Expression.Parameter(typeof(int));
480480
var lambda = Expression.Lambda(Expression.Block(), unnamedParameter);
481481
var ex = Assert.Throws<InvalidOperationException>(() => RequestDelegateFactory.Create((Action<int>)lambda.Compile(), new EmptyServiceProvdier()));
482-
Assert.Equal("A parameter does not have a name! Was it genererated? All parameters must be named.", ex.Message);
482+
Assert.Equal("A parameter does not have a name! Was it generated? All parameters must be named.", ex.Message);
483483
}
484484

485485
[Fact]

src/Http/Routing/src/Builder/MinimalActionEndpointRouteBuilderExtensions.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public static MinimalActionEndpointConventionBuilder MapPost(
6161
/// <param name="endpoints">The <see cref="IEndpointRouteBuilder"/> to add the route to.</param>
6262
/// <param name="pattern">The route pattern.</param>
6363
/// <param name="action">The delegate executed when the endpoint is matched.</param>
64-
/// <returns>A <see cref="IEndpointConventionBuilder"/> that canaction be used to further customize the endpoint.</returns>
64+
/// <returns>A <see cref="IEndpointConventionBuilder"/> that can be used to further customize the endpoint.</returns>
6565
public static MinimalActionEndpointConventionBuilder MapPut(
6666
this IEndpointRouteBuilder endpoints,
6767
string pattern,
@@ -166,6 +166,12 @@ public static MinimalActionEndpointConventionBuilder Map(
166166
DisplayName = pattern.RawText ?? pattern.DebuggerToString(),
167167
};
168168

169+
// REVIEW: Should we add an IActionMethodMetadata with just MethodInfo on it so we are
170+
// explicit about the MethodInfo representing the "action" and not the RequestDelegate?
171+
172+
// Add MethodInfo as metadata to assist with OpenAPI generation for the endpoint.
173+
builder.Metadata.Add(action.Method);
174+
169175
// Add delegate attributes as metadata
170176
var attributes = action.Method.GetCustomAttributes();
171177

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,16 @@ void TestAction()
4242
var dataSource = Assert.Single(builder.DataSources);
4343
var endpoint = Assert.Single(dataSource.Endpoints);
4444

45-
var metadataArray = endpoint.Metadata.Where(m => m is not CompilerGeneratedAttribute).ToArray();
45+
var metadataArray = endpoint.Metadata.OfType<IHttpMethodMetadata>().ToArray();
46+
47+
static string GetMethod(IHttpMethodMetadata metadata) => Assert.Single(metadata.HttpMethods);
4648

4749
Assert.Equal(3, metadataArray.Length);
4850
Assert.Equal("ATTRIBUTE", GetMethod(metadataArray[0]));
4951
Assert.Equal("METHOD", GetMethod(metadataArray[1]));
5052
Assert.Equal("BUILDER", GetMethod(metadataArray[2]));
5153

5254
Assert.Equal("BUILDER", endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()!.HttpMethods.Single());
53-
54-
string GetMethod(object metadata)
55-
{
56-
var httpMethodMetadata = Assert.IsAssignableFrom<IHttpMethodMetadata>(metadata);
57-
return Assert.Single(httpMethodMetadata.HttpMethods);
58-
}
5955
}
6056

6157
[Fact]

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

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,48 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
7777
IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
7878
Type? type,
7979
Type defaultErrorType)
80+
{
81+
var contentTypes = new MediaTypeCollection();
82+
83+
var responseTypes = ReadResponseMetadata(responseMetadataAttributes, type, defaultErrorType, contentTypes);
84+
85+
// Set the default status only when no status has already been set explicitly
86+
if (responseTypes.Count == 0 && type != null)
87+
{
88+
responseTypes.Add(new ApiResponseType
89+
{
90+
StatusCode = StatusCodes.Status200OK,
91+
Type = type,
92+
});
93+
}
94+
95+
if (contentTypes.Count == 0)
96+
{
97+
// None of the IApiResponseMetadataProvider specified a content type. This is common for actions that
98+
// specify one or more ProducesResponseType but no ProducesAttribute. In this case, formatters will participate in conneg
99+
// and respond to the incoming request.
100+
// Querying IApiResponseTypeMetadataProvider.GetSupportedContentTypes with "null" should retrieve all supported
101+
// content types that each formatter may respond in.
102+
contentTypes.Add((string)null!);
103+
}
104+
105+
CalculateResponseFormats(responseTypes, contentTypes);
106+
107+
return responseTypes;
108+
}
109+
110+
// Shared with EndpointMetadataApiDescriptionProvider
111+
internal static List<ApiResponseType> ReadResponseMetadata(
112+
IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
113+
Type? type,
114+
Type defaultErrorType,
115+
MediaTypeCollection contentTypes)
80116
{
81117
var results = new Dictionary<int, ApiResponseType>();
82118

83119
// Get the content type that the action explicitly set to support.
84120
// Walk through all 'filter' attributes in order, and allow each one to see or override
85121
// the results of the previous ones. This is similar to the execution path for content-negotiation.
86-
var contentTypes = new MediaTypeCollection();
87122
if (responseMetadataAttributes != null)
88123
{
89124
foreach (var metadataAttribute in responseMetadataAttributes)
@@ -105,7 +140,7 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
105140
{
106141
// ProducesResponseTypeAttribute's constructor defaults to setting "Type" to void when no value is specified.
107142
// In this event, use the action's return type for 200 or 201 status codes. This lets you decorate an action with a
108-
// [ProducesResponseType(201)] instead of [ProducesResponseType(201, typeof(Person)] when typeof(Person) can be inferred
143+
// [ProducesResponseType(201)] instead of [ProducesResponseType(typeof(Person), 201] when typeof(Person) can be inferred
109144
// from the return type.
110145
apiResponseType.Type = type;
111146
}
@@ -129,29 +164,7 @@ private ICollection<ApiResponseType> GetApiResponseTypes(
129164
}
130165
}
131166

132-
// Set the default status only when no status has already been set explicitly
133-
if (results.Count == 0 && type != null)
134-
{
135-
results[StatusCodes.Status200OK] = new ApiResponseType
136-
{
137-
StatusCode = StatusCodes.Status200OK,
138-
Type = type,
139-
};
140-
}
141-
142-
if (contentTypes.Count == 0)
143-
{
144-
// None of the IApiResponseMetadataProvider specified a content type. This is common for actions that
145-
// specify one or more ProducesResponseType but no ProducesAttribute. In this case, formatters will participate in conneg
146-
// and respond to the incoming request.
147-
// Querying IApiResponseTypeMetadataProvider.GetSupportedContentTypes with "null" should retrieve all supported
148-
// content types that each formatter may respond in.
149-
contentTypes.Add((string)null!);
150-
}
151-
152-
var responseTypes = results.Values;
153-
CalculateResponseFormats(responseTypes, contentTypes);
154-
return responseTypes;
167+
return results.Values.ToList();
155168
}
156169

157170
private void CalculateResponseFormats(ICollection<ApiResponseType> responseTypes, MediaTypeCollection declaredContentTypes)

src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ private IReadOnlyList<ApiRequestFormat> GetSupportedFormats(MediaTypeCollection
429429
return results;
430430
}
431431

432-
private static MediaTypeCollection GetDeclaredContentTypes(IApiRequestMetadataProvider[]? requestMetadataAttributes)
432+
internal static MediaTypeCollection GetDeclaredContentTypes(IReadOnlyList<IApiRequestMetadataProvider>? requestMetadataAttributes)
433433
{
434434
// Walk through all 'filter' attributes in order, and allow each one to see or override
435435
// the results of the previous ones. This is similar to the execution path for content-negotiation.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Http;
5+
using Microsoft.AspNetCore.Mvc.ApiExplorer;
6+
using Microsoft.AspNetCore.Mvc.Infrastructure;
7+
using Microsoft.Extensions.DependencyInjection.Extensions;
8+
9+
namespace Microsoft.Extensions.DependencyInjection
10+
{
11+
/// <summary>
12+
/// Extensions for configuring ApiExplorer using <see cref="Endpoint.Metadata"/>.
13+
/// </summary>
14+
public static class EndpointMetadataApiExplorerServiceCollectionExtensions
15+
{
16+
/// <summary>
17+
/// Configures ApiExplorer using <see cref="Endpoint.Metadata"/>.
18+
/// </summary>
19+
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
20+
public static IServiceCollection AddEndpointsApiExplorer(this IServiceCollection services)
21+
{
22+
// Try to add default services in case MVC services aren't added.
23+
services.TryAddSingleton<IActionDescriptorCollectionProvider, DefaultActionDescriptorCollectionProvider>();
24+
services.TryAddSingleton<IApiDescriptionGroupCollectionProvider, ApiDescriptionGroupCollectionProvider>();
25+
26+
services.TryAddEnumerable(
27+
ServiceDescriptor.Transient<IApiDescriptionProvider, EndpointMetadataApiDescriptionProvider>());
28+
29+
return services;
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)