diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj index 955a0d05..43ec7680 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData.ApiExplorer/Asp.Versioning.OData.ApiExplorer.csproj @@ -1,7 +1,7 @@  - 6.3.0 + 6.3.1 6.3.0.0 net6.0;netcoreapp3.1 Asp.Versioning diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj b/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj index 23be8a44..9ea5d6db 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/Asp.Versioning.OData.csproj @@ -1,7 +1,7 @@  - 6.3.0 + 6.3.1 6.3.0.0 net6.0;netcoreapp3.1 Asp.Versioning diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/VersionedODataTemplateTranslator.cs b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/VersionedODataTemplateTranslator.cs index 84ef5d76..5d58b2b5 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/VersionedODataTemplateTranslator.cs +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/OData/VersionedODataTemplateTranslator.cs @@ -6,6 +6,7 @@ namespace Asp.Versioning.OData; using Microsoft.AspNetCore.OData.Routing.Template; using Microsoft.OData.Edm; using Microsoft.OData.UriParser; +using System.Runtime.CompilerServices; /// /// Represents a versioned OData template translator. @@ -30,9 +31,7 @@ public sealed class VersionedODataTemplateTranslator : IODataTemplateTranslator if ( apiVersion == null ) { - var metadata = context.Endpoint.Metadata.GetMetadata(); - - if ( metadata == null || !metadata.IsApiVersionNeutral ) + if ( !IsVersionNeutral( context ) ) { return default; } @@ -42,7 +41,13 @@ public sealed class VersionedODataTemplateTranslator : IODataTemplateTranslator var model = context.Model; var otherApiVersion = model.GetAnnotationValue( model )?.ApiVersion; - if ( !apiVersion.Equals( otherApiVersion ) ) + // HACK: a version-neutral endpoint can fail to match here because odata tries to match the + // first endpoint metadata when there could be multiple. such an endpoint is expected to be + // the same in all versions so allow it to flow through. revisit if/when odata fixes this. + // + // REF: https://github.com/OData/AspNetCoreOData/issues/753 + // REF: https://github.com/OData/AspNetCoreOData/blob/main/src/Microsoft.AspNetCore.OData/Routing/ODataRoutingMatcherPolicy.cs#L86 + if ( !apiVersion.Equals( otherApiVersion ) && !IsVersionNeutral( context ) ) { return default; } @@ -58,4 +63,9 @@ public sealed class VersionedODataTemplateTranslator : IODataTemplateTranslator return new( context.Segments ); } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static bool IsVersionNeutral( ODataTemplateTranslateContext context ) => + context.Endpoint.Metadata.GetMetadata() is ApiVersionMetadata metadata + && metadata.IsApiVersionNeutral; } \ No newline at end of file diff --git a/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt b/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt index 5f282702..d980a834 100644 --- a/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt +++ b/src/AspNetCore/OData/src/Asp.Versioning.OData/ReleaseNotes.txt @@ -1 +1 @@ - \ No newline at end of file +Added workaround for [OData #753](https://github.com/OData/AspNetCoreOData/issues/753) \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionMetadataCollationCollection.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionMetadataCollationCollection.cs new file mode 100644 index 00000000..ee58aed9 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionMetadataCollationCollection.cs @@ -0,0 +1,135 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using System.Collections; + +/// +/// Represents a collection of collated API version metadata. +/// +public class ApiVersionMetadataCollationCollection : IList, IReadOnlyList +{ + private readonly List items; + private readonly List groups; + + /// + /// Initializes a new instance of the class. + /// + public ApiVersionMetadataCollationCollection() + { + items = new(); + groups = new(); + } + + /// + /// Initializes a new instance of the class. + /// + /// The initial capacity of the collection. + public ApiVersionMetadataCollationCollection( int capacity ) + { + items = new( capacity ); + groups = new( capacity ); + } + + /// + /// Gets the item in the list at the specified index. + /// + /// The zero-based index of the item to retrieve. + /// The item at the specified index. + public ApiVersionMetadata this[int index] => items[index]; + + ApiVersionMetadata IList.this[int index] + { + get => items[index]; + set => throw new NotSupportedException(); + } + + /// + public int Count => items.Count; + +#pragma warning disable CA1033 // Interface methods should be callable by child types + bool ICollection.IsReadOnly => ( (ICollection) items ).IsReadOnly; +#pragma warning restore CA1033 // Interface methods should be callable by child types + + /// + public void Add( ApiVersionMetadata item ) => Insert( Count, item, default ); + + /// + /// Adds an item to the collection. + /// + /// The item to add. + /// The associated group name, if any. + public void Add( ApiVersionMetadata item, string? groupName ) => Insert( Count, item, groupName ); + + /// + public void Clear() + { + items.Clear(); + groups.Clear(); + } + + /// + public bool Contains( ApiVersionMetadata item ) => item != null && items.Contains( item ); + + /// + public void CopyTo( ApiVersionMetadata[] array, int arrayIndex ) => items.CopyTo( array, arrayIndex ); + + /// + public IEnumerator GetEnumerator() => items.GetEnumerator(); + + /// + public int IndexOf( ApiVersionMetadata item ) => item == null ? -1 : items.IndexOf( item ); + + /// + public void Insert( int index, ApiVersionMetadata item ) => Insert( index, item, default ); + + /// + /// Inserts an item into the collection. + /// + /// The zero-based index where insertion takes place. + /// The item to insert. + /// The associated group name, if any. + public void Insert( int index, ApiVersionMetadata item, string? groupName ) + { + items.Insert( index, item ?? throw new ArgumentNullException( nameof( item ) ) ); + groups.Insert( index, groupName ); + } + + /// + public bool Remove( ApiVersionMetadata item ) + { + if ( item == null ) + { + return false; + } + + var index = items.IndexOf( item ); + + if ( index < 0 ) + { + return false; + } + + RemoveAt( index ); + return true; + } + + /// + public void RemoveAt( int index ) + { + items.RemoveAt( index ); + groups.RemoveAt( index ); + } + + IEnumerator IEnumerable.GetEnumerator() => ( (IEnumerable) items ).GetEnumerator(); + + /// + /// Gets the group name for the item at the specified index. + /// + /// The zero-based index of the item to get the group name for. + /// The associated group name or null. + /// If the specified is out of range, null + /// is returned. + public string? GroupName( int index ) => + index < 0 || index >= groups.Count ? default : groups[index]; +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionMetadataCollationContext.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionMetadataCollationContext.cs new file mode 100644 index 00000000..dc1dfa25 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/ApiVersionMetadataCollationContext.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +/// +/// Represents the context used during API version metadata collation. +/// +public class ApiVersionMetadataCollationContext +{ + /// + /// Gets the read-only list of collation results. + /// + /// The read-only list of collation results. + public ApiVersionMetadataCollationCollection Results { get; } = new(); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/EndpointApiVersionMetadataCollationProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/EndpointApiVersionMetadataCollationProvider.cs new file mode 100644 index 00000000..370d4f39 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/EndpointApiVersionMetadataCollationProvider.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Primitives; + +/// +/// Represents the API version metadata collection provider for endpoints. +/// +[CLSCompliant( false )] +public sealed class EndpointApiVersionMetadataCollationProvider : IApiVersionMetadataCollationProvider +{ + private readonly EndpointDataSource endpointDataSource; + private int version; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying endpoint data source. + public EndpointApiVersionMetadataCollationProvider( EndpointDataSource endpointDataSource ) + { + this.endpointDataSource = endpointDataSource ?? throw new ArgumentNullException( nameof( endpointDataSource ) ); + ChangeToken.OnChange( endpointDataSource.GetChangeToken, () => ++version ); + } + + /// + public int Version => version; + + /// + public void Execute( ApiVersionMetadataCollationContext context ) + { + if ( context == null ) + { + throw new ArgumentNullException( nameof( context ) ); + } + + var endpoints = endpointDataSource.Endpoints; + + for ( var i = 0; i < endpoints.Count; i++ ) + { + var endpoint = endpoints[i]; + + if ( endpoint.Metadata.GetMetadata() is not ApiVersionMetadata item ) + { + continue; + } + +#if NETCOREAPP3_1 + // this code path doesn't appear to exist for netcoreapp3.1 + // REF: https://github.com/dotnet/aspnetcore/blob/release/3.1/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs#L74 + context.Results.Add( item ); +#else + var groupName = endpoint.Metadata.OfType().LastOrDefault()?.EndpointGroupName; + context.Results.Add( item, groupName ); +#endif + } + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IApiVersionMetadataCollationProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IApiVersionMetadataCollationProvider.cs new file mode 100644 index 00000000..04a386a6 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ApiExplorer/IApiVersionMetadataCollationProvider.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +/// +/// Defines the behavior of an API version metadata collation provider. +/// +public interface IApiVersionMetadataCollationProvider +{ + /// + /// Gets version of the underlying provider results. + /// + /// The version of the provider results. This can be used to detect changes. + int Version { get; } + + /// + /// Executes the provider using the given context. + /// + /// The collation context. + void Execute( ApiVersionMetadataCollationContext context ); +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj index dce50659..e94f1972 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj @@ -1,7 +1,7 @@  - 6.3.0 + 6.3.1 6.3.0.0 net6.0;netcoreapp3.1 Asp.Versioning diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs index 48da8a2b..a31dfe04 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/DependencyInjection/IServiceCollectionExtensions.cs @@ -3,12 +3,14 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; +using Asp.Versioning.ApiExplorer; #if !NETCOREAPP3_1 using Asp.Versioning.Builder; #endif using Asp.Versioning.Routing; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor; @@ -61,7 +63,15 @@ private static void AddApiVersioningServices( IServiceCollection services ) services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddEnumerable( Transient, ApiVersioningRouteOptionsSetup>() ); - services.TryAddEnumerable( Singleton() ); + //// UNDONE: explicit constructor choice to avoid breaking change; revert in next major release + services.TryAddEnumerable( Singleton( + sp => new ApiVersionMatcherPolicy( + sp.GetRequiredService(), + sp.GetServices(), + sp.GetRequiredService>(), + sp.GetRequiredService>() ) ) ); + + services.TryAddEnumerable( Singleton() ); services.Replace( WithLinkGeneratorDecorator( services ) ); } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt index 5f282702..e508e614 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt @@ -1 +1 @@ - \ No newline at end of file +[Fixed #922](https://github.com/dotnet/aspnet-api-versioning/discussions/922) \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs index 46b93a13..78a9cf4d 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Routing/ApiVersionMatcherPolicy.cs @@ -2,6 +2,7 @@ namespace Asp.Versioning.Routing; +using Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Matching; @@ -22,6 +23,7 @@ public sealed class ApiVersionMatcherPolicy : MatcherPolicy, IEndpointSelectorPo private readonly IOptions options; private readonly IApiVersionParser apiVersionParser; private readonly ILogger logger; + private readonly ApiVersionCollator? collator; /// /// Initializes a new instance of the class. @@ -39,6 +41,20 @@ public ApiVersionMatcherPolicy( this.logger = logger ?? throw new ArgumentNullException( nameof( logger ) ); } + // TODO: avoid a breaking change or surface area change in 6.3; unify and make it public in 7.0 + // the functionality is still achievable for extenders + internal ApiVersionMatcherPolicy( + IApiVersionParser apiVersionParser, + IEnumerable providers, + IOptions options, + ILogger logger ) + { + this.apiVersionParser = apiVersionParser; + this.options = options; + this.logger = logger; + collator = new( providers, options ); + } + /// public override int Order { get; } = BeforeDefaultMatcherPolicy(); @@ -219,17 +235,21 @@ public IReadOnlyList GetEdges( IReadOnlyList endpoints builder.Add( endpoint, version, metadata ); } } + } - if ( neutralEndpoints is null ) - { - continue; - } + if ( neutralEndpoints != null && collator != null ) + { + var allVersions = collator.Items; // add an edge for all known versions because version-neutral endpoints can map to any api version - for ( var j = 0; j < neutralEndpoints.Count; j++ ) + for ( var i = 0; i < neutralEndpoints.Count; i++ ) { - var (endpoint, metadata) = neutralEndpoints[j]; - builder.Add( endpoint, version, metadata ); + var (endpoint, metadata) = neutralEndpoints[i]; + + for ( var j = 0; j < allVersions.Count; j++ ) + { + builder.Add( endpoint, allVersions[j], metadata ); + } } } @@ -498,4 +518,93 @@ internal int CompareTo( in Match other ) return result == 0 ? IsExplicit.CompareTo( other.IsExplicit ) : result; } } + + private sealed class ApiVersionCollator + { + private readonly IApiVersionMetadataCollationProvider[] providers; + private readonly IOptions options; + private readonly object syncRoot = new(); + private IReadOnlyList? items; + private int version; + + internal ApiVersionCollator( + IEnumerable providers, + IOptions options ) + { + this.providers = providers.ToArray(); + this.options = options; + } + + public IReadOnlyList Items + { + get + { + if ( items is not null && version == ComputeVersion() ) + { + return items; + } + + lock ( syncRoot ) + { + var currentVersion = ComputeVersion(); + + if ( items is not null && version == currentVersion ) + { + return items; + } + + var context = new ApiVersionMetadataCollationContext(); + + for ( var i = 0; i < providers.Length; i++ ) + { + providers[i].Execute( context ); + } + + var results = context.Results; + var versions = new SortedSet(); + + for ( var i = 0; i < results.Count; i++ ) + { + var model = results[i].Map( Explicit | Implicit ); + var declared = model.DeclaredApiVersions; + + for ( var j = 0; j < declared.Count; j++ ) + { + versions.Add( declared[j] ); + } + } + + if ( versions.Count == 0 ) + { + versions.Add( options.Value.DefaultApiVersion ); + } + + items = versions.ToArray(); + version = currentVersion; + } + + return items; + } + } + + private int ComputeVersion() => + providers.Length switch + { + 0 => 0, + 1 => providers[0].Version, + _ => ComputeVersion( providers ), + }; + + private static int ComputeVersion( IApiVersionMetadataCollationProvider[] providers ) + { + var hash = default( HashCode ); + + for ( var i = 0; i < providers.Length; i++ ) + { + hash.Add( providers[i].Version ); + } + + return hash.ToHashCode(); + } + } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj index c8d61eec..ff2d9ae4 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/Asp.Versioning.Mvc.ApiExplorer.csproj @@ -1,7 +1,7 @@  - 6.3.0 + 6.3.1 6.3.0.0 net6.0;netcoreapp3.1 Asp.Versioning.ApiExplorer diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs index 1edc2155..a3921e13 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/DefaultApiVersionDescriptionProvider.cs @@ -3,11 +3,9 @@ namespace Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; using static Asp.Versioning.ApiVersionMapping; using static System.Globalization.CultureInfo; @@ -35,7 +33,16 @@ public DefaultApiVersionDescriptionProvider( ISunsetPolicyManager sunsetPolicyManager, IOptions apiExplorerOptions ) { - collection = new( this, endpointDataSource, actionDescriptorCollectionProvider ); + var collators = new IApiVersionMetadataCollationProvider[] + { + new EndpointApiVersionMetadataCollationProvider( + endpointDataSource ?? + throw new ArgumentNullException( nameof( endpointDataSource ) ) ), + new ActionApiVersionMetadataCollationProvider( + actionDescriptorCollectionProvider ?? + throw new ArgumentNullException( nameof( actionDescriptorCollectionProvider ) ) ), + }; + collection = new( this, collators ); SunsetPolicyManager = sunsetPolicyManager; options = apiExplorerOptions; } @@ -138,56 +145,45 @@ private void AppendDescriptions( ICollection descriptions private sealed class ApiVersionDescriptionCollection { private readonly object syncRoot = new(); - private readonly DefaultApiVersionDescriptionProvider apiVersionDescriptionProvider; - private readonly EndpointApiVersionMetadataCollection endpoints; - private readonly ActionApiVersionMetadataCollection actions; + private readonly DefaultApiVersionDescriptionProvider provider; + private readonly IApiVersionMetadataCollationProvider[] collators; private IReadOnlyList? items; - private long version; + private int version; public ApiVersionDescriptionCollection( - DefaultApiVersionDescriptionProvider apiVersionDescriptionProvider, - EndpointDataSource endpointDataSource, - IActionDescriptorCollectionProvider actionDescriptorCollectionProvider ) + DefaultApiVersionDescriptionProvider provider, + IEnumerable collators ) { - this.apiVersionDescriptionProvider = apiVersionDescriptionProvider; - endpoints = new( endpointDataSource ); - actions = new( actionDescriptorCollectionProvider ); + this.provider = provider; + this.collators = collators.ToArray(); } public IReadOnlyList Items { get { - if ( items is not null && version == CurrentVersion ) + if ( items is not null && version == ComputeVersion() ) { return items; } lock ( syncRoot ) { - var (items1, version1) = endpoints; - var (items2, version2) = actions; - var currentVersion = ComputeVersion( version1, version2 ); + var currentVersion = ComputeVersion(); if ( items is not null && version == currentVersion ) { return items; } - var capacity = items1.Count + items2.Count; - var metadata = new List( capacity ); - - for ( var i = 0; i < items1.Count; i++ ) - { - metadata.Add( items1[i] ); - } + var context = new ApiVersionMetadataCollationContext(); - for ( var i = 0; i < items2.Count; i++ ) + for ( var i = 0; i < collators.Length; i++ ) { - metadata.Add( items2[i] ); + collators[i].Execute( context ); } - items = apiVersionDescriptionProvider.Describe( metadata ); + items = provider.Describe( context.Results ); version = currentVersion; } @@ -195,158 +191,24 @@ public IReadOnlyList Items } } - private long CurrentVersion - { - get + private int ComputeVersion() => + collators.Length switch { - lock ( syncRoot ) - { - return ComputeVersion( endpoints.Version, actions.Version ); - } - } - } + 0 => 0, + 1 => collators[0].Version, + _ => ComputeVersion( collators ), + }; - private static long ComputeVersion( int version1, int version2 ) => ( ( (long) version1 ) << 32 ) | (long) version2; - } - - private sealed class EndpointApiVersionMetadataCollection - { - private readonly object syncRoot = new(); - private readonly EndpointDataSource endpointDataSource; - private List? list; - private int version; - private int currentVersion; - - public EndpointApiVersionMetadataCollection( EndpointDataSource endpointDataSource ) + private static int ComputeVersion( IApiVersionMetadataCollationProvider[] providers ) { - this.endpointDataSource = endpointDataSource ?? throw new ArgumentNullException( nameof( endpointDataSource ) ); - ChangeToken.OnChange( endpointDataSource.GetChangeToken, IncrementVersion ); - } - - public int Version => version; + var hash = default( HashCode ); - public IReadOnlyList Items - { - get + for ( var i = 0; i < providers.Length; i++ ) { - if ( list is not null && version == currentVersion ) - { - return list; - } - - lock ( syncRoot ) - { - if ( list is not null && version == currentVersion ) - { - return list; - } - - var endpoints = endpointDataSource.Endpoints; - - if ( list == null ) - { - list = new( capacity: endpoints.Count ); - } - else - { - list.Clear(); - list.Capacity = endpoints.Count; - } - - for ( var i = 0; i < endpoints.Count; i++ ) - { - if ( endpoints[i].Metadata.GetMetadata() is ApiVersionMetadata item ) - { - list.Add( item ); - } - } - - version = currentVersion; - } - - return list; + hash.Add( providers[i].Version ); } - } - public void Deconstruct( out IReadOnlyList items, out int version ) - { - lock ( syncRoot ) - { - version = this.version; - items = Items; - } - } - - private void IncrementVersion() - { - lock ( syncRoot ) - { - currentVersion++; - } - } - } - - private sealed class ActionApiVersionMetadataCollection - { - private readonly object syncRoot = new(); - private readonly IActionDescriptorCollectionProvider provider; - private List? list; - private int version; - - public ActionApiVersionMetadataCollection( IActionDescriptorCollectionProvider actionDescriptorCollectionProvider ) => - provider = actionDescriptorCollectionProvider ?? throw new ArgumentNullException( nameof( actionDescriptorCollectionProvider ) ); - - public int Version => version; - - public IReadOnlyList Items - { - get - { - var collection = provider.ActionDescriptors; - - if ( list is not null && collection.Version == version ) - { - return list; - } - - lock ( syncRoot ) - { - if ( list is not null && collection.Version == version ) - { - return list; - } - - var actions = collection.Items; - - if ( list == null ) - { - list = new( capacity: actions.Count ); - } - else - { - list.Clear(); - list.Capacity = actions.Count; - } - - for ( var i = 0; i < actions.Count; i++ ) - { - list.Add( actions[i].GetApiVersionMetadata() ); - } - - version = collection.Version; - } - - return list; - } - } - - public void Deconstruct( out IReadOnlyList items, out int version ) - { - lock ( syncRoot ) - { - version = this.version; - items = Items; - } + return hash.ToHashCode(); } } } \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs index d15d45fb..43b66302 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/GroupedApiVersionDescriptionProvider.cs @@ -3,12 +3,9 @@ namespace Asp.Versioning.ApiExplorer; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; using System.Buffers; using static Asp.Versioning.ApiVersionMapping; using static System.Globalization.CultureInfo; @@ -37,7 +34,16 @@ public GroupedApiVersionDescriptionProvider( ISunsetPolicyManager sunsetPolicyManager, IOptions apiExplorerOptions ) { - collection = new( this, endpointDataSource, actionDescriptorCollectionProvider ); + var collators = new IApiVersionMetadataCollationProvider[] + { + new EndpointApiVersionMetadataCollationProvider( + endpointDataSource ?? + throw new ArgumentNullException( nameof( endpointDataSource ) ) ), + new ActionApiVersionMetadataCollationProvider( + actionDescriptorCollectionProvider ?? + throw new ArgumentNullException( nameof( actionDescriptorCollectionProvider ) ) ), + }; + collection = new( this, collators ); SunsetPolicyManager = sunsetPolicyManager; options = apiExplorerOptions; } @@ -157,242 +163,78 @@ private void AppendDescriptions( private sealed class ApiVersionDescriptionCollection { private readonly object syncRoot = new(); - private readonly GroupedApiVersionDescriptionProvider apiVersionDescriptionProvider; - private readonly EndpointApiVersionMetadataCollection endpoints; - private readonly ActionApiVersionMetadataCollection actions; + private readonly GroupedApiVersionDescriptionProvider provider; + private readonly IApiVersionMetadataCollationProvider[] collators; private IReadOnlyList? items; - private long version; + private int version; public ApiVersionDescriptionCollection( - GroupedApiVersionDescriptionProvider apiVersionDescriptionProvider, - EndpointDataSource endpointDataSource, - IActionDescriptorCollectionProvider actionDescriptorCollectionProvider ) + GroupedApiVersionDescriptionProvider provider, + IEnumerable collators ) { - this.apiVersionDescriptionProvider = apiVersionDescriptionProvider; - endpoints = new( endpointDataSource ); - actions = new( actionDescriptorCollectionProvider ); + this.provider = provider; + this.collators = collators.ToArray(); } public IReadOnlyList Items { get { - if ( items is not null && version == CurrentVersion ) + if ( items is not null && version == ComputeVersion() ) { return items; } lock ( syncRoot ) { - var (items1, version1) = endpoints; - var (items2, version2) = actions; - var currentVersion = ComputeVersion( version1, version2 ); + var currentVersion = ComputeVersion(); if ( items is not null && version == currentVersion ) { return items; } - var capacity = items1.Count + items2.Count; - var metadata = new List( capacity ); - - for ( var i = 0; i < items1.Count; i++ ) - { - metadata.Add( items1[i] ); - } - - for ( var i = 0; i < items2.Count; i++ ) - { - metadata.Add( items2[i] ); - } - - items = apiVersionDescriptionProvider.Describe( metadata ); - version = currentVersion; - } - - return items; - } - } - - private long CurrentVersion - { - get - { - lock ( syncRoot ) - { - return ComputeVersion( endpoints.Version, actions.Version ); - } - } - } - - private static long ComputeVersion( int version1, int version2 ) => ( ( (long) version1 ) << 32 ) | (long) version2; - } - - private sealed class EndpointApiVersionMetadataCollection - { - private readonly object syncRoot = new(); - private readonly EndpointDataSource endpointDataSource; - private List? list; - private int version; - private int currentVersion; - - public EndpointApiVersionMetadataCollection( EndpointDataSource endpointDataSource ) - { - this.endpointDataSource = endpointDataSource ?? throw new ArgumentNullException( nameof( endpointDataSource ) ); - ChangeToken.OnChange( endpointDataSource.GetChangeToken, IncrementVersion ); - } - - public int Version => version; - - public IReadOnlyList Items - { - get - { - if ( list is not null && version == currentVersion ) - { - return list; - } + var context = new ApiVersionMetadataCollationContext(); - lock ( syncRoot ) - { - if ( list is not null && version == currentVersion ) + for ( var i = 0; i < collators.Length; i++ ) { - return list; + collators[i].Execute( context ); } - var endpoints = endpointDataSource.Endpoints; + var results = context.Results; + var metadata = new GroupedApiVersionMetadata[results.Count]; - if ( list == null ) + for ( var i = 0; i < metadata.Length; i++ ) { - list = new( capacity: endpoints.Count ); - } - else - { - list.Clear(); - list.Capacity = endpoints.Count; - } - - for ( var i = 0; i < endpoints.Count; i++ ) - { - var metadata = endpoints[i].Metadata; - - if ( metadata.GetMetadata() is ApiVersionMetadata item ) - { -#if NETCOREAPP3_1 - // this code path doesn't appear to exist for netcoreapp3.1 - // REF: https://github.com/dotnet/aspnetcore/blob/release/3.1/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs#L74 - list.Add( new( default, item ) ); -#else - var groupName = metadata.OfType().LastOrDefault()?.EndpointGroupName; - list.Add( new( groupName, item ) ); -#endif - } + metadata[i] = new( context.Results.GroupName( i ), results[i] ); } + items = provider.Describe( metadata ); version = currentVersion; } - return list; - } - } - - public void Deconstruct( out IReadOnlyList items, out int version ) - { - lock ( syncRoot ) - { - version = this.version; - items = Items; - } - } - - private void IncrementVersion() - { - lock ( syncRoot ) - { - currentVersion++; + return items; } } - } - - private sealed class ActionApiVersionMetadataCollection - { - private readonly object syncRoot = new(); - private readonly IActionDescriptorCollectionProvider provider; - private List? list; - private int version; - public ActionApiVersionMetadataCollection( IActionDescriptorCollectionProvider actionDescriptorCollectionProvider ) => - provider = actionDescriptorCollectionProvider ?? throw new ArgumentNullException( nameof( actionDescriptorCollectionProvider ) ); - - public int Version => version; - - public IReadOnlyList Items - { - get + private int ComputeVersion() => + collators.Length switch { - var collection = provider.ActionDescriptors; - - if ( list is not null && collection.Version == version ) - { - return list; - } - - lock ( syncRoot ) - { - if ( list is not null && collection.Version == version ) - { - return list; - } - - var actions = collection.Items; - - if ( list == null ) - { - list = new( capacity: actions.Count ); - } - else - { - list.Clear(); - list.Capacity = actions.Count; - } - - for ( var i = 0; i < actions.Count; i++ ) - { - var action = actions[i]; - list.Add( new( GetGroupName( action ), action.GetApiVersionMetadata() ) ); - } + 0 => 0, + 1 => collators[0].Version, + _ => ComputeVersion( collators ), + }; - version = collection.Version; - } - - return list; - } - } - - // REF: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs - private static string? GetGroupName( ActionDescriptor action ) + private static int ComputeVersion( IApiVersionMetadataCollationProvider[] providers ) { -#if NETCOREAPP3_1 - return action.GetProperty()?.GroupName; -#else - var endpointGroupName = action.EndpointMetadata.OfType().LastOrDefault(); + var hash = default( HashCode ); - if ( endpointGroupName is null ) + for ( var i = 0; i < providers.Length; i++ ) { - return action.GetProperty()?.GroupName; + hash.Add( providers[i].Version ); } - return endpointGroupName.EndpointGroupName; -#endif - } - - public void Deconstruct( out IReadOnlyList items, out int version ) - { - lock ( syncRoot ) - { - version = this.version; - items = Items; - } + return hash.ToHashCode(); } } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt index 5f282702..7bc8848b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/ReleaseNotes.txt @@ -1 +1,2 @@ - \ No newline at end of file +[Fixed #922](https://github.com/dotnet/aspnet-api-versioning/discussions/922) +[Fixed #923](https://github.com/dotnet/aspnet-api-versioning/issues/923) \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs index e71c67d0..c84f9d06 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc.ApiExplorer/VersionedApiDescriptionProvider.cs @@ -156,11 +156,11 @@ public virtual void OnProvidersExecuted( ApiDescriptionProviderContext context ) var groupResult = result.Clone(); var metadata = action.GetApiVersionMetadata(); - if ( string.IsNullOrEmpty( groupResult.GroupName ) || formatGroupName is null ) + if ( string.IsNullOrEmpty( groupResult.GroupName ) ) { groupResult.GroupName = formattedVersion; } - else + else if ( formatGroupName is not null ) { groupResult.GroupName = formatGroupName( groupResult.GroupName, formattedVersion ); } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiExplorer/ActionApiVersionMetadataCollationProvider.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiExplorer/ActionApiVersionMetadataCollationProvider.cs new file mode 100644 index 00000000..81a06c74 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiExplorer/ActionApiVersionMetadataCollationProvider.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning.ApiExplorer; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; + +/// +/// Represents an API version metadata collection provider for controller actions. +/// +[CLSCompliant( false )] +public sealed class ActionApiVersionMetadataCollationProvider : IApiVersionMetadataCollationProvider +{ + private readonly IActionDescriptorCollectionProvider provider; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying + /// action descriptor collection provider. + public ActionApiVersionMetadataCollationProvider( IActionDescriptorCollectionProvider actionDescriptorCollectionProvider ) => + provider = actionDescriptorCollectionProvider ?? throw new ArgumentNullException( nameof( actionDescriptorCollectionProvider ) ); + + /// + public int Version => provider.ActionDescriptors.Version; + + /// + public void Execute( ApiVersionMetadataCollationContext context ) + { + if ( context == null ) + { + throw new ArgumentNullException( nameof( context ) ); + } + + var actions = provider.ActionDescriptors.Items; + + for ( var i = 0; i < actions.Count; i++ ) + { + var action = actions[i]; + var item = action.GetApiVersionMetadata(); + var groupName = GetGroupName( action ); + + context.Results.Add( item, groupName ); + } + } + + // REF: https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.ApiExplorer/src/DefaultApiDescriptionProvider.cs + private static string? GetGroupName( ActionDescriptor action ) + { +#if NETCOREAPP3_1 + return action.GetProperty()?.GroupName; +#else + var endpointGroupName = action.EndpointMetadata.OfType().LastOrDefault(); + + if ( endpointGroupName is null ) + { + return action.GetProperty()?.GroupName; + } + + return endpointGroupName.EndpointGroupName; +#endif + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs index f0e2b64c..bbbbcd71 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ApiVersionCollator.cs @@ -2,10 +2,8 @@ namespace Asp.Versioning; -using Asp.Versioning.ApplicationModels; using Asp.Versioning.Conventions; using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Controllers; using System.Runtime.CompilerServices; using static Asp.Versioning.ApiVersionMapping; diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj index 3ed03b13..483d4ea7 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/Asp.Versioning.Mvc.csproj @@ -1,7 +1,7 @@  - 6.3.0 + 6.3.1 6.3.0.0 net6.0;netcoreapp3.1 Asp.Versioning diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs index 348c5a76..afa02343 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -3,6 +3,7 @@ namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; +using Asp.Versioning.ApiExplorer; using Asp.Versioning.ApplicationModels; using Asp.Versioning.Conventions; using Asp.Versioning.Routing; @@ -73,6 +74,7 @@ private static void AddServices( IServiceCollection services ) services.TryAddEnumerable( Transient() ); services.TryAddEnumerable( Transient() ); services.TryAddEnumerable( Transient() ); + services.TryAddEnumerable( Singleton() ); services.Replace( WithUrlHelperFactoryDecorator( services ) ); services.TryReplace( typeof( DefaultProblemDetailsFactory ), Singleton() ); } diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt index 5f282702..e508e614 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/ReleaseNotes.txt @@ -1 +1 @@ - \ No newline at end of file +[Fixed #922](https://github.com/dotnet/aspnet-api-versioning/discussions/922) \ No newline at end of file diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/TestActionDescriptorCollectionProvider.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/TestActionDescriptorCollectionProvider.cs index 3361f93e..0ccce57b 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/TestActionDescriptorCollectionProvider.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/TestActionDescriptorCollectionProvider.cs @@ -9,6 +9,26 @@ internal sealed class TestActionDescriptorCollectionProvider : IActionDescriptor { private readonly Lazy collection = new( CreateActionDescriptors ); + public TestActionDescriptorCollectionProvider() { } + + public TestActionDescriptorCollectionProvider( ActionDescriptor action, params ActionDescriptor[] otherActions ) + { + ActionDescriptor[] actions; + + if ( otherActions.Length == 0 ) + { + actions = new ActionDescriptor[] { action }; + } + else + { + actions = new ActionDescriptor[otherActions.Length]; + actions[0] = action; + Array.Copy( otherActions, 0, actions, 1, otherActions.Length ); + } + + collection = new( () => new( actions, 0 ) ); + } + public ActionDescriptorCollection ActionDescriptors => collection.Value; private static ActionDescriptorCollection CreateActionDescriptors() diff --git a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs index f5eec3d5..45a50e05 100644 --- a/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs +++ b/src/AspNetCore/WebApi/test/Asp.Versioning.Mvc.ApiExplorer.Tests/VersionedApiDescriptionProviderTest.cs @@ -2,6 +2,7 @@ namespace Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; @@ -92,6 +93,62 @@ public void versioned_api_explorer_should_apply_sunset_policy() .BeTrue(); } + [Fact] + public void versioned_api_explorer_should_preserve_group_name() + { + // arrange + var metadata = new ApiVersionMetadata( ApiVersionModel.Empty, new ApiVersionModel( ApiVersion.Default ) ); + var descriptor = new ActionDescriptor() { EndpointMetadata = new[] { metadata } }; + var actionProvider = new TestActionDescriptorCollectionProvider( descriptor ); + var context = new ApiDescriptionProviderContext( actionProvider.ActionDescriptors.Items ); + var apiExplorer = new VersionedApiDescriptionProvider( + Mock.Of(), + NewModelMetadataProvider(), + Options.Create( new ApiExplorerOptions() ) ); + + context.Results.Add( new() + { + ActionDescriptor = descriptor, + GroupName = "Test", + } ); + + // act + apiExplorer.OnProvidersExecuted( context ); + + // assert + context.Results.Single().GroupName.Should().Be( "Test" ); + } + + [Fact] + public void versioned_api_explorer_should_use_custom_group_name() + { + // arrange + var metadata = new ApiVersionMetadata( ApiVersionModel.Empty, new ApiVersionModel( ApiVersion.Default ) ); + var descriptor = new ActionDescriptor() { EndpointMetadata = new[] { metadata } }; + var actionProvider = new TestActionDescriptorCollectionProvider( descriptor ); + var context = new ApiDescriptionProviderContext( actionProvider.ActionDescriptors.Items ); + var options = new ApiExplorerOptions() + { + FormatGroupName = ( group, version ) => $"{group}-{version}", + }; + var apiExplorer = new VersionedApiDescriptionProvider( + Mock.Of(), + NewModelMetadataProvider(), + Options.Create( options ) ); + + context.Results.Add( new() + { + ActionDescriptor = descriptor, + GroupName = "Test", + } ); + + // act + apiExplorer.OnProvidersExecuted( context ); + + // assert + context.Results.Single().GroupName.Should().Be( "Test-1.0" ); + } + private static IModelMetadataProvider NewModelMetadataProvider() { var provider = new Mock();