Skip to content

Commit fb0be97

Browse files
Provide API Explorer parity with OpenApiRouteHandlerExtensions. Fixes #812
1 parent 39af0ed commit fb0be97

File tree

5 files changed

+474
-0
lines changed

5 files changed

+474
-0
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) .NET Foundation and contributors. All rights reserved.
2+
3+
namespace Asp.Versioning.Builder;
4+
5+
using Microsoft.AspNetCore.Builder;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Http.Metadata;
8+
9+
/// <summary>
10+
/// Represents a builder for <see cref="IAcceptsMetadata"/>.
11+
/// </summary>
12+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
13+
[CLSCompliant( false )]
14+
public class AcceptsMetadataBuilder<TBuilder>
15+
where TBuilder : IEndpointConventionBuilder
16+
{
17+
private Type? requestType;
18+
private string? contentType;
19+
private string[]? additionalContentTypes;
20+
21+
/// <summary>
22+
/// Initializes a new instance of the <see cref="AcceptsMetadataBuilder{TBuilder}"/> class.
23+
/// </summary>
24+
/// <param name="builder">The associated endpoint builder.</param>
25+
/// <param name="isOptional">Sets a value that determines if the request body is optional.</param>
26+
public AcceptsMetadataBuilder( TBuilder builder, bool isOptional )
27+
{
28+
Builder = builder;
29+
IsOptional = isOptional;
30+
}
31+
32+
/// <summary>
33+
/// Gets the associated endpoint builder.
34+
/// </summary>
35+
/// <value>The associated endpoint builder.</value>
36+
protected TBuilder Builder { get; }
37+
38+
/// <summary>
39+
/// Gets a value indicating whether the request body is optional.
40+
/// </summary>
41+
/// <value>True if the request body is optional; otherwise, false.</value>
42+
protected bool IsOptional { get; }
43+
44+
/// <summary>
45+
/// Adds the type of response that will be returned.
46+
/// </summary>
47+
/// <typeparam name="TBody">The type of request body.</typeparam>
48+
/// <returns>The original instance.</returns>
49+
public virtual AcceptsMetadataBuilder<TBuilder> Body<TBody>() where TBody : notnull
50+
{
51+
requestType = typeof( TBody );
52+
return this;
53+
}
54+
55+
/// <summary>
56+
/// Adds the content types that the request can be formatted as.
57+
/// </summary>
58+
/// <param name="contentType">The request content type that endpoint accepts.</param>
59+
/// <param name="additionalContentTypes">Additional request content types the endpoint accepts.</param>
60+
/// <returns>The original instance.</returns>
61+
public virtual AcceptsMetadataBuilder<TBuilder> FormattedAs(
62+
string contentType,
63+
params string[] additionalContentTypes )
64+
{
65+
this.contentType = contentType;
66+
this.additionalContentTypes = additionalContentTypes;
67+
return this;
68+
}
69+
70+
/// <summary>
71+
/// Builds the underlying <see cref="IAcceptsMetadata"/>.
72+
/// </summary>
73+
public virtual void Build() =>
74+
Builder.Accepts(
75+
requestType ?? throw new InvalidOperationException( SR.RequestTypeUnconfigured ),
76+
IsOptional,
77+
contentType ?? "application/json",
78+
additionalContentTypes ?? Array.Empty<string>() );
79+
}
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Copyright (c) .NET Foundation and contributors. All rights reserved.
2+
3+
// NOTE: if the ASP.NET team fixes the design in .NET 7, then this entire file and class should go away
4+
// REF: https://github.com/dotnet/aspnetcore/issues/39604
5+
namespace Microsoft.AspNetCore.Http;
6+
7+
using Asp.Versioning;
8+
using Asp.Versioning.Builder;
9+
using Microsoft.AspNetCore.Builder;
10+
using Microsoft.AspNetCore.Http.Metadata;
11+
using Microsoft.AspNetCore.Mvc;
12+
using Microsoft.AspNetCore.Routing;
13+
using static System.Linq.Expressions.Expression;
14+
15+
/// <summary>
16+
/// Provides API Explorer extension methods for <see cref="IEndpointConventionBuilder"/>.
17+
/// </summary>
18+
[CLSCompliant( false )]
19+
public static class ApiExplorerBuilderExtensions
20+
{
21+
private static ExcludeFromDescriptionAttribute? excludeFromDescriptionMetadataAttribute;
22+
private static Func<Type, int, IProducesResponseTypeMetadata>? newProducesResponseTypeMetadata2;
23+
private static Func<Type, int, string, string[], IProducesResponseTypeMetadata>? newProducesResponseTypeMetadata4;
24+
private static Func<Type, bool, string[], IAcceptsMetadata>? newAcceptsMetadata3;
25+
26+
/// <summary>
27+
/// Adds the <see cref="IExcludeFromDescriptionMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints.
28+
/// </summary>
29+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
30+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
31+
/// <returns>The original <paramref name="builder"/>.</returns>
32+
public static TBuilder ExcludeFromDescription<TBuilder>( this TBuilder builder )
33+
where TBuilder : IEndpointConventionBuilder
34+
{
35+
excludeFromDescriptionMetadataAttribute ??= new();
36+
builder.WithMetadata( excludeFromDescriptionMetadataAttribute );
37+
return builder;
38+
}
39+
40+
/// <summary>
41+
/// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints.
42+
/// </summary>
43+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
44+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
45+
/// <param name="build">The <see cref="Action{T}"/> used to build the response metadata.</param>
46+
/// <param name="statusCode">The response status code. Defaults to <see cref="StatusCodes.Status200OK"/>.</param>
47+
/// <returns>The original <paramref name="builder"/>.</returns>
48+
public static TBuilder Produces<TBuilder>(
49+
this TBuilder builder,
50+
Action<ProducesResponseMetadataBuilder<TBuilder>> build,
51+
int statusCode = StatusCodes.Status200OK )
52+
where TBuilder : IEndpointConventionBuilder
53+
{
54+
if ( build == null )
55+
{
56+
throw new ArgumentNullException( nameof( build ) );
57+
}
58+
59+
var metadata = new ProducesResponseMetadataBuilder<TBuilder>( builder, statusCode );
60+
build( metadata );
61+
metadata.Build();
62+
return builder;
63+
}
64+
65+
/// <summary>
66+
/// Adds an <see cref="IProducesResponseTypeMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints.
67+
/// </summary>
68+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
69+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
70+
/// <param name="statusCode">The response status code.</param>
71+
/// <param name="responseType">The type of the response. Defaults to null.</param>
72+
/// <param name="contentType">The response content type. Defaults to "application/json" if responseType is not null, otherwise defaults to null.</param>
73+
/// <param name="additionalContentTypes">Additional response content types the endpoint produces for the supplied status code.</param>
74+
/// <returns>The original <paramref name="builder"/>.</returns>
75+
public static TBuilder Produces<TBuilder>(
76+
this TBuilder builder,
77+
int statusCode,
78+
Type? responseType = null,
79+
string? contentType = null,
80+
params string[] additionalContentTypes )
81+
where TBuilder : IEndpointConventionBuilder
82+
{
83+
if ( responseType is not null && string.IsNullOrEmpty( contentType ) )
84+
{
85+
contentType = "application/json";
86+
}
87+
88+
responseType ??= typeof( void );
89+
IProducesResponseTypeMetadata metadata;
90+
91+
if ( contentType is null )
92+
{
93+
newProducesResponseTypeMetadata2 ??= NewProducesResponseTypeMetadataFunc2();
94+
metadata = newProducesResponseTypeMetadata2( responseType, statusCode );
95+
}
96+
else
97+
{
98+
newProducesResponseTypeMetadata4 ??= NewProducesResponseTypeMetadataFunc4();
99+
metadata = newProducesResponseTypeMetadata4( responseType, statusCode, contentType, additionalContentTypes );
100+
}
101+
102+
builder.WithMetadata( metadata );
103+
return builder;
104+
}
105+
106+
/// <summary>
107+
/// Adds an <see cref="IProducesResponseTypeMetadata"/> with a <see cref="ProblemDetails"/> type
108+
/// to <see cref="EndpointBuilder.Metadata"/> for all endpoints.
109+
/// </summary>
110+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
111+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
112+
/// <param name="statusCode">The response status code.</param>
113+
/// <param name="contentType">The response content type. Defaults to "application/problem+json".</param>
114+
/// <returns>The original <paramref name="builder"/>.</returns>
115+
public static TBuilder ProducesProblem<TBuilder>(
116+
this TBuilder builder,
117+
int statusCode,
118+
string? contentType = null )
119+
where TBuilder : IEndpointConventionBuilder
120+
{
121+
if ( string.IsNullOrEmpty( contentType ) )
122+
{
123+
contentType = ProblemDetailsDefaults.MediaType.Json;
124+
}
125+
126+
return Produces( builder, statusCode, typeof( ProblemDetails ), contentType );
127+
}
128+
129+
/// <summary>
130+
/// Adds an <see cref="IProducesResponseTypeMetadata"/> with a <see cref="HttpValidationProblemDetails"/> type
131+
/// to <see cref="EndpointBuilder.Metadata"/> for all endpoints.
132+
/// </summary>
133+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
134+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
135+
/// <param name="statusCode">The response status code. Defaults to <see cref="StatusCodes.Status400BadRequest"/>.</param>
136+
/// <param name="contentType">The response content type. Defaults to "application/problem+json".</param>
137+
/// <returns>The original <paramref name="builder"/>.</returns>
138+
public static TBuilder ProducesValidationProblem<TBuilder>(
139+
this TBuilder builder,
140+
int statusCode = StatusCodes.Status400BadRequest,
141+
string? contentType = null )
142+
where TBuilder : IEndpointConventionBuilder
143+
{
144+
if ( string.IsNullOrEmpty( contentType ) )
145+
{
146+
contentType = ProblemDetailsDefaults.MediaType.Json;
147+
}
148+
149+
return Produces( builder, statusCode, typeof( HttpValidationProblemDetails ), contentType );
150+
}
151+
152+
/// <summary>
153+
/// Adds the <see cref="ITagsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints.
154+
/// </summary>
155+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
156+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
157+
/// <param name="tags">A collection of tags to be associated with the endpoint.</param>
158+
/// <returns>The original <paramref name="builder"/>.</returns>
159+
/// <remarks>When used with OpenAPI, the specification supports a tags classification to categorize
160+
/// operations into related groups. These tags are typically included in the generated specification
161+
/// and are typically used to group operations by tags in the UI.</remarks>
162+
public static TBuilder WithTags<TBuilder>( this TBuilder builder, params string[] tags )
163+
where TBuilder : IEndpointConventionBuilder => builder.WithMetadata( new TagsAttribute( tags ) );
164+
165+
/// <summary>
166+
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints.
167+
/// </summary>
168+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
169+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
170+
/// <param name="build">The <see cref="Action{T}"/> used to build the request metadata.</param>
171+
/// <param name="isOptional">Sets a value that determines if the request body is optional.</param>
172+
/// <returns>The original <paramref name="builder"/>.</returns>
173+
public static TBuilder Accepts<TBuilder>(
174+
this TBuilder builder,
175+
Action<AcceptsMetadataBuilder<TBuilder>> build,
176+
bool isOptional = false)
177+
where TBuilder : IEndpointConventionBuilder
178+
{
179+
if ( build == null )
180+
{
181+
throw new ArgumentNullException( nameof( build ) );
182+
}
183+
184+
var metadata = new AcceptsMetadataBuilder<TBuilder>( builder, isOptional );
185+
build( metadata );
186+
metadata.Build();
187+
return builder;
188+
}
189+
190+
/// <summary>
191+
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints.
192+
/// </summary>
193+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
194+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
195+
/// <param name="requestType">The type of the request body.</param>
196+
/// <param name="contentType">The request content type that the endpoint accepts.</param>
197+
/// <param name="additionalContentTypes">The list of additional request content types that the endpoint accepts.</param>
198+
/// <returns>The original <paramref name="builder"/>.</returns>
199+
public static TBuilder Accepts<TBuilder>(
200+
this TBuilder builder,
201+
Type requestType,
202+
string contentType,
203+
params string[] additionalContentTypes )
204+
where TBuilder : IEndpointConventionBuilder
205+
{
206+
newAcceptsMetadata3 ??= NewAcceptsMetadataFunc3();
207+
208+
var allContentTypes = GetAllContentTypes( contentType, additionalContentTypes ?? Array.Empty<string>() );
209+
var metadata = newAcceptsMetadata3( requestType, false, allContentTypes );
210+
211+
return builder.WithMetadata( metadata );
212+
}
213+
214+
/// <summary>
215+
/// Adds <see cref="IAcceptsMetadata"/> to <see cref="EndpointBuilder.Metadata"/> for all endpoints
216+
/// produced by <paramref name="builder"/>.
217+
/// </summary>
218+
/// <typeparam name="TBuilder">The type of <see cref="IEndpointConventionBuilder"/>.</typeparam>
219+
/// <param name="builder">The extended <see cref="IEndpointConventionBuilder">endpoint convention builder</see>.</param>
220+
/// <param name="requestType">The type of the request body.</param>
221+
/// <param name="isOptional">Sets a value that determines if the request body is optional.</param>
222+
/// <param name="contentType">The request content type that the endpoint accepts.</param>
223+
/// <param name="additionalContentTypes">The list of additional request content types that the endpoint accepts.</param>
224+
/// <returns>The original <paramref name="builder"/>.</returns>
225+
public static TBuilder Accepts<TBuilder>(
226+
this TBuilder builder,
227+
Type requestType,
228+
bool isOptional,
229+
string contentType,
230+
params string[] additionalContentTypes )
231+
where TBuilder : IEndpointConventionBuilder
232+
{
233+
newAcceptsMetadata3 ??= NewAcceptsMetadataFunc3();
234+
235+
var allContentTypes = GetAllContentTypes( contentType, additionalContentTypes ?? Array.Empty<string>() );
236+
var metadata = newAcceptsMetadata3( requestType, isOptional, allContentTypes );
237+
238+
return builder.WithMetadata( metadata );
239+
}
240+
241+
private static string[] GetAllContentTypes( string contentType, string[] additionalContentTypes )
242+
{
243+
var allContentTypes = new string[additionalContentTypes.Length + 1];
244+
allContentTypes[0] = contentType;
245+
246+
for ( var i = 0; i < additionalContentTypes.Length; i++ )
247+
{
248+
allContentTypes[i + 1] = additionalContentTypes[i];
249+
}
250+
251+
return allContentTypes;
252+
}
253+
254+
// HACK: >_< these are internal types and can't be forked due to internal logic and members
255+
// REF: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/ApiExplorerTypes/ProducesResponseTypeMetadata.cs
256+
// REF: https://github.com/dotnet/aspnetcore/blob/main/src/Shared/RoutingMetadata/AcceptsMetadata.cs
257+
private static class TypeNames
258+
{
259+
private const string Assembly = "Microsoft.AspNetCore.Routing";
260+
private const string Namespace = "Microsoft.AspNetCore.Http";
261+
262+
public const string ProducesResponseTypeMetadata = $"{Namespace}.{nameof( ProducesResponseTypeMetadata )}, {Assembly}";
263+
public const string AcceptsMetadata = $"{Namespace}.Metadata.{nameof( AcceptsMetadata )}, {Assembly}";
264+
}
265+
266+
private static Func<Type, int, IProducesResponseTypeMetadata> NewProducesResponseTypeMetadataFunc2()
267+
{
268+
var @class = Type.GetType( TypeNames.ProducesResponseTypeMetadata, throwOnError: true, ignoreCase: false )!;
269+
var type = Parameter( typeof( Type ), "type" );
270+
var statusCode = Parameter( typeof( int ), "statusCode" );
271+
var ctor = @class.GetConstructor( new[] { typeof( Type ), typeof( int ) } )!;
272+
var body = New( ctor, type, statusCode );
273+
var lambda = Lambda<Func<Type, int, IProducesResponseTypeMetadata>>( body, type, statusCode );
274+
275+
return lambda.Compile();
276+
}
277+
278+
private static Func<Type, int, string, string[], IProducesResponseTypeMetadata> NewProducesResponseTypeMetadataFunc4()
279+
{
280+
var @class = Type.GetType( TypeNames.ProducesResponseTypeMetadata, throwOnError: true, ignoreCase: false )!;
281+
var type = Parameter( typeof( Type ), "type" );
282+
var statusCode = Parameter( typeof( int ), "statusCode" );
283+
var contentType = Parameter( typeof( string ), "contentType" );
284+
var additionalContentTypes = Parameter( typeof( string[] ), "additionalContentTypes" );
285+
var ctor = @class.GetConstructor( new[] { typeof( Type ), typeof( int ), typeof( string ), typeof( string[] ) } )!;
286+
var body = New( ctor, type, statusCode, contentType, additionalContentTypes );
287+
var lambda = Lambda<Func<Type, int, string, string[], IProducesResponseTypeMetadata>>( body, type, statusCode, contentType, additionalContentTypes );
288+
289+
return lambda.Compile();
290+
}
291+
292+
private static Func<Type, bool, string[], IAcceptsMetadata> NewAcceptsMetadataFunc3()
293+
{
294+
var @class = Type.GetType( TypeNames.AcceptsMetadata, throwOnError: true, ignoreCase: false )!;
295+
var type = Parameter( typeof( Type ), "type" );
296+
var isOptional = Parameter( typeof( bool ), "isOptional" );
297+
var contentTypes = Parameter( typeof( string[] ), "contentTypes" );
298+
var ctor = @class.GetConstructor( new[] { typeof( Type ), typeof( bool ), typeof( string[] ) } )!;
299+
var body = New( ctor, type, isOptional, contentTypes );
300+
var lambda = Lambda<Func<Type, bool, string[], IAcceptsMetadata>>( body, type, isOptional, contentTypes );
301+
302+
return lambda.Compile();
303+
}
304+
}

0 commit comments

Comments
 (0)