Skip to content

Commit 8c4184b

Browse files
Fix API version descriptions with new startup or mixed with Minimal APIs. Related to #812
1 parent 93bd8dc commit 8c4184b

File tree

3 files changed

+203
-31
lines changed

3 files changed

+203
-31
lines changed

src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs

Lines changed: 195 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace Asp.Versioning.ApiExplorer;
44

55
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Mvc.Abstractions;
7+
using Microsoft.AspNetCore.Mvc.Infrastructure;
68
using Microsoft.AspNetCore.Routing;
79
using Microsoft.Extensions.Options;
810
using Microsoft.Extensions.Primitives;
@@ -22,15 +24,18 @@ public class DefaultApiVersionDescriptionProvider : IApiVersionDescriptionProvid
2224
/// Initializes a new instance of the <see cref="DefaultApiVersionDescriptionProvider"/> class.
2325
/// </summary>
2426
/// <param name="endpointDataSource">The <see cref="EndpointDataSource">data source</see> for <see cref="Endpoint">endpoints</see>.</param>
27+
/// <param name="actionDescriptorCollectionProvider">The <see cref="IActionDescriptorCollectionProvider">provider</see>
28+
/// used to enumerate the actions within an application.</param>
2529
/// <param name="sunsetPolicyManager">The <see cref="ISunsetPolicyManager">manager</see> used to resolve sunset policies.</param>
2630
/// <param name="apiExplorerOptions">The <see cref="IOptions{TOptions}">container</see> of configured
2731
/// <see cref="ApiExplorerOptions">API explorer options</see>.</param>
2832
public DefaultApiVersionDescriptionProvider(
2933
EndpointDataSource endpointDataSource,
34+
IActionDescriptorCollectionProvider actionDescriptorCollectionProvider,
3035
ISunsetPolicyManager sunsetPolicyManager,
3136
IOptions<ApiExplorerOptions> apiExplorerOptions )
3237
{
33-
collection = new( this, endpointDataSource );
38+
collection = new( this, endpointDataSource, actionDescriptorCollectionProvider );
3439
SunsetPolicyManager = sunsetPolicyManager;
3540
options = apiExplorerOptions;
3641
}
@@ -51,43 +56,38 @@ public DefaultApiVersionDescriptionProvider(
5156
public IReadOnlyList<ApiVersionDescription> ApiVersionDescriptions => collection.Items;
5257

5358
/// <summary>
54-
/// Enumerates all API versions within an application.
59+
/// Provides a list of API version descriptions from a list of application API version metadata.
5560
/// </summary>
56-
/// <param name="endpointDataSource">The <see cref="EndpointDataSource">data source</see> used to enumerate the endpoints within an application.</param>
61+
/// <param name="metadata">The <see cref="IReadOnlyList{T}">read-only list</see> of <see cref="ApiVersionMetadata">API version metadata</see>
62+
/// within the application.</param>
5763
/// <returns>A <see cref="IReadOnlyList{T}">read-only list</see> of <see cref="ApiVersionDescription">API version descriptions</see>.</returns>
58-
protected virtual IReadOnlyList<ApiVersionDescription> EnumerateApiVersions( EndpointDataSource endpointDataSource )
64+
protected virtual IReadOnlyList<ApiVersionDescription> Describe( IReadOnlyList<ApiVersionMetadata> metadata )
5965
{
60-
if ( endpointDataSource == null )
66+
if ( metadata == null )
6167
{
62-
throw new ArgumentNullException( nameof( endpointDataSource ) );
68+
throw new ArgumentNullException( nameof( metadata ) );
6369
}
6470

65-
var endpoints = endpointDataSource.Endpoints;
66-
var descriptions = new List<ApiVersionDescription>( capacity: endpoints.Count );
71+
var descriptions = new List<ApiVersionDescription>( capacity: metadata.Count );
6772
var supported = new HashSet<ApiVersion>();
6873
var deprecated = new HashSet<ApiVersion>();
6974

70-
BucketizeApiVersions( endpoints, supported, deprecated );
75+
BucketizeApiVersions( metadata, supported, deprecated );
7176
AppendDescriptions( descriptions, supported, deprecated: false );
7277
AppendDescriptions( descriptions, deprecated, deprecated: true );
7378

7479
return descriptions.OrderBy( d => d.ApiVersion ).ToArray();
7580
}
7681

77-
private void BucketizeApiVersions( IReadOnlyList<Endpoint> endpoints, ISet<ApiVersion> supported, ISet<ApiVersion> deprecated )
82+
private void BucketizeApiVersions( IReadOnlyList<ApiVersionMetadata> metadata, ISet<ApiVersion> supported, ISet<ApiVersion> deprecated )
7883
{
7984
var declared = new HashSet<ApiVersion>();
8085
var advertisedSupported = new HashSet<ApiVersion>();
8186
var advertisedDeprecated = new HashSet<ApiVersion>();
8287

83-
for ( var i = 0; i < endpoints.Count; i++ )
88+
for ( var i = 0; i < metadata.Count; i++ )
8489
{
85-
if ( endpoints[i].Metadata.GetMetadata<ApiVersionMetadata>() is not ApiVersionMetadata metadata )
86-
{
87-
continue;
88-
}
89-
90-
var model = metadata.Map( Explicit | Implicit );
90+
var model = metadata[i].Map( Explicit | Implicit );
9191
var versions = model.DeclaredApiVersions;
9292

9393
for ( var j = 0; j < versions.Count; j++ )
@@ -138,47 +138,214 @@ private void AppendDescriptions( ICollection<ApiVersionDescription> descriptions
138138
private sealed class ApiVersionDescriptionCollection
139139
{
140140
private readonly object syncRoot = new();
141-
private readonly EndpointDataSource endpointDataSource;
142141
private readonly DefaultApiVersionDescriptionProvider apiVersionDescriptionProvider;
142+
private readonly EndpointApiVersionMetadataCollection endpoints;
143+
private readonly ActionApiVersionMetadataCollection actions;
143144
private IReadOnlyList<ApiVersionDescription>? items;
145+
private long version;
144146

145147
public ApiVersionDescriptionCollection(
146148
DefaultApiVersionDescriptionProvider apiVersionDescriptionProvider,
147-
EndpointDataSource endpointDataSource )
149+
EndpointDataSource endpointDataSource,
150+
IActionDescriptorCollectionProvider actionDescriptorCollectionProvider )
148151
{
149152
this.apiVersionDescriptionProvider = apiVersionDescriptionProvider;
150-
this.endpointDataSource = endpointDataSource ?? throw new ArgumentNullException( nameof( endpointDataSource ) );
151-
ChangeToken.OnChange( endpointDataSource.GetChangeToken, UpdateItems );
153+
endpoints = new( endpointDataSource );
154+
actions = new( actionDescriptorCollectionProvider );
152155
}
153156

154157
public IReadOnlyList<ApiVersionDescription> Items
155158
{
156159
get
157160
{
158-
Initialize();
159-
return items!;
161+
if ( items is not null && version == CurrentVersion )
162+
{
163+
return items;
164+
}
165+
166+
lock ( syncRoot )
167+
{
168+
var (items1, version1) = endpoints;
169+
var (items2, version2) = actions;
170+
var currentVersion = ComputeVersion( version1, version2 );
171+
172+
if ( items is not null && version == currentVersion )
173+
{
174+
return items;
175+
}
176+
177+
var capacity = items1.Count + items2.Count;
178+
var metadata = new List<ApiVersionMetadata>( capacity );
179+
180+
for ( var i = 0; i < items1.Count; i++ )
181+
{
182+
metadata.Add( items1[i] );
183+
}
184+
185+
for ( var i = 0; i < items2.Count; i++ )
186+
{
187+
metadata.Add( items2[i] );
188+
}
189+
190+
items = apiVersionDescriptionProvider.Describe( metadata );
191+
version = currentVersion;
192+
}
193+
194+
return items;
195+
}
196+
}
197+
198+
private long CurrentVersion
199+
{
200+
get
201+
{
202+
lock ( syncRoot )
203+
{
204+
return ComputeVersion( endpoints.Version, actions.Version );
205+
}
160206
}
161207
}
162208

163-
private void Initialize()
209+
private static long ComputeVersion( int version1, int version2 ) => ( ( (long) version1 ) << 32 ) | (long) version2;
210+
}
211+
212+
private sealed class EndpointApiVersionMetadataCollection
213+
{
214+
private readonly object syncRoot = new();
215+
private readonly EndpointDataSource endpointDataSource;
216+
private List<ApiVersionMetadata>? items;
217+
private int version;
218+
private int currentVersion;
219+
220+
public EndpointApiVersionMetadataCollection( EndpointDataSource endpointDataSource )
221+
{
222+
this.endpointDataSource = endpointDataSource ?? throw new ArgumentNullException( nameof( endpointDataSource ) );
223+
ChangeToken.OnChange( endpointDataSource.GetChangeToken, IncrementVersion );
224+
}
225+
226+
public int Version => version;
227+
228+
public IReadOnlyList<ApiVersionMetadata> Items
164229
{
165-
if ( items == null )
230+
get
166231
{
232+
if ( items is not null && version == currentVersion )
233+
{
234+
return items;
235+
}
236+
167237
lock ( syncRoot )
168238
{
239+
if ( items is not null && version == currentVersion )
240+
{
241+
return items;
242+
}
243+
244+
var endpoints = endpointDataSource.Endpoints;
245+
169246
if ( items == null )
170247
{
171-
UpdateItems();
248+
items = new( capacity: endpoints.Count );
172249
}
250+
else
251+
{
252+
items.Clear();
253+
items.Capacity = endpoints.Count;
254+
}
255+
256+
for ( var i = 0; i < endpoints.Count; i++ )
257+
{
258+
if ( endpoints[i].Metadata.GetMetadata<ApiVersionMetadata>() is ApiVersionMetadata item )
259+
{
260+
items.Add( item );
261+
}
262+
}
263+
264+
version = currentVersion;
173265
}
266+
267+
return items;
268+
}
269+
}
270+
271+
public void Deconstruct( out IReadOnlyList<ApiVersionMetadata> items, out int version )
272+
{
273+
lock ( syncRoot )
274+
{
275+
version = this.version;
276+
items = Items;
277+
}
278+
}
279+
280+
private void IncrementVersion()
281+
{
282+
lock ( syncRoot )
283+
{
284+
currentVersion++;
285+
}
286+
}
287+
}
288+
289+
private sealed class ActionApiVersionMetadataCollection
290+
{
291+
private readonly object syncRoot = new();
292+
private readonly IActionDescriptorCollectionProvider provider;
293+
private List<ApiVersionMetadata>? items;
294+
private int version;
295+
296+
public ActionApiVersionMetadataCollection( IActionDescriptorCollectionProvider actionDescriptorCollectionProvider ) =>
297+
provider = actionDescriptorCollectionProvider ?? throw new ArgumentNullException( nameof( actionDescriptorCollectionProvider ) );
298+
299+
public int Version => version;
300+
301+
public IReadOnlyList<ApiVersionMetadata> Items
302+
{
303+
get
304+
{
305+
var collection = provider.ActionDescriptors;
306+
307+
if ( items is not null && collection.Version == version )
308+
{
309+
return items;
310+
}
311+
312+
lock ( syncRoot )
313+
{
314+
if ( items is not null && collection.Version == version )
315+
{
316+
return items;
317+
}
318+
319+
var actions = collection.Items;
320+
321+
if ( items == null )
322+
{
323+
items = new( capacity: actions.Count );
324+
}
325+
else
326+
{
327+
items.Clear();
328+
items.Capacity = actions.Count;
329+
}
330+
331+
for ( var i = 0; i < actions.Count; i++ )
332+
{
333+
items.Add( actions[i].GetApiVersionMetadata() );
334+
}
335+
336+
version = collection.Version;
337+
}
338+
339+
return items;
174340
}
175341
}
176342

177-
private void UpdateItems()
343+
public void Deconstruct( out IReadOnlyList<ApiVersionMetadata> items, out int version )
178344
{
179345
lock ( syncRoot )
180346
{
181-
items = apiVersionDescriptionProvider.EnumerateApiVersions( endpointDataSource );
347+
version = this.version;
348+
items = Items;
182349
}
183350
}
184351
}

src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/IEndpointRouteBuilderExtensions.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace Microsoft.AspNetCore.Builder;
44

55
using Asp.Versioning;
66
using Asp.Versioning.ApiExplorer;
7+
using Microsoft.AspNetCore.Mvc.Infrastructure;
78
using Microsoft.AspNetCore.Routing;
89
using Microsoft.Extensions.DependencyInjection;
910
using Microsoft.Extensions.Options;
@@ -34,10 +35,12 @@ public static IReadOnlyList<ApiVersionDescription> DescribeApiVersions( this IEn
3435
// setup. unfortunately, the behavior cannot simply be changed by replacing IApiVersionDescriptionProvider
3536
// in the container for minimal apis, but that is not a common scenario. all the types and pieces
3637
// necessary to change this behavior is still possible outside of this method, but it's on the developer
38+
var services = endpoints.ServiceProvider;
3739
var source = new CompositeEndpointDataSource( endpoints.DataSources );
38-
var policyManager = endpoints.ServiceProvider.GetRequiredService<ISunsetPolicyManager>();
39-
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<ApiExplorerOptions>>();
40-
var provider = new DefaultApiVersionDescriptionProvider( source, policyManager, options );
40+
var actions = services.GetRequiredService<IActionDescriptorCollectionProvider>();
41+
var policyManager = services.GetRequiredService<ISunsetPolicyManager>();
42+
var options = services.GetRequiredService<IOptions<ApiExplorerOptions>>();
43+
var provider = new DefaultApiVersionDescriptionProvider( source, actions, policyManager, options );
4144

4245
return provider.ApiVersionDescriptions;
4346
}

src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/DefaultApiVersionDescriptionProviderTest.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public void api_version_descriptions_should_collate_expected_versions()
1212
// arrange
1313
var descriptionProvider = new DefaultApiVersionDescriptionProvider(
1414
new TestEndpointDataSource(),
15+
new TestActionDescriptorCollectionProvider(),
1516
Mock.Of<ISunsetPolicyManager>(),
1617
Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) );
1718

@@ -41,6 +42,7 @@ public void api_version_descriptions_should_apply_sunset_policy()
4142

4243
var descriptionProvider = new DefaultApiVersionDescriptionProvider(
4344
new TestEndpointDataSource(),
45+
new TestActionDescriptorCollectionProvider(),
4446
policyManager.Object,
4547
Options.Create( new ApiExplorerOptions() { GroupNameFormat = "'v'VVV" } ) );
4648

0 commit comments

Comments
 (0)