diff --git a/.editorconfig b/.editorconfig index 4c2ddb8f..ecc12f91 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,11 +13,11 @@ indent_size = 4 # xml project files [*.{csproj,vbproj,proj,projitems,shproj}] -indent_size = 2 +indent_size = 1 # xml config files [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] -indent_size = 2 +indent_size = 1 # json files [*.json] diff --git a/build/common.props b/build/common.props index f69d871b..faf2e28c 100644 --- a/build/common.props +++ b/build/common.props @@ -7,6 +7,10 @@ © $(Company). All rights reserved. en en-US + + + + latest \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Versioning/Abstractions/ActionDescriptorExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Versioning/Abstractions/ActionDescriptorExtensions.cs index 23860b2c..154a096d 100644 --- a/src/Microsoft.AspNetCore.Mvc.Versioning/Abstractions/ActionDescriptorExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Versioning/Abstractions/ActionDescriptorExtensions.cs @@ -2,8 +2,6 @@ { using ApplicationModels; using System; - using System.Collections.Generic; - using System.Diagnostics.Contracts; using System.Linq; using Versioning; @@ -13,30 +11,6 @@ [CLSCompliant( false )] public static class ActionDescriptorExtensions { - const string VersionPolicyIsAppliedKey = "MS_" + nameof( VersionPolicyIsApplied ); - - static void VersionPolicyIsApplied( this ActionDescriptor action, bool value ) => action.Properties[VersionPolicyIsAppliedKey] = value; - - internal static bool VersionPolicyIsApplied( this ActionDescriptor action ) => action.Properties.GetOrDefault( VersionPolicyIsAppliedKey, false ); - - internal static void AggregateAllVersions( this ActionDescriptor action, IEnumerable matchingActions ) - { - Contract.Requires( action != null ); - Contract.Requires( matchingActions != null ); - - if ( action.VersionPolicyIsApplied() ) - { - return; - } - - action.VersionPolicyIsApplied( true ); - - var model = action.GetProperty(); - Contract.Assume( model != null ); - - action.SetProperty( model.Aggregate( matchingActions.Select( a => a.GetProperty() ).Where( m => m != null ) ) ); - } - /// /// Returns a value indicating whether the provided action implicitly maps to the specified version. /// diff --git a/src/Microsoft.AspNetCore.Mvc.Versioning/Microsoft.Extensions.DependencyInjection/IServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Versioning/Microsoft.Extensions.DependencyInjection/IServiceCollectionExtensions.cs index 93720977..dd6f9221 100644 --- a/src/Microsoft.AspNetCore.Mvc.Versioning/Microsoft.Extensions.DependencyInjection/IServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Versioning/Microsoft.Extensions.DependencyInjection/IServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using Extensions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; + using Microsoft.AspNetCore.Mvc.Abstractions; using Options; using System; using System.Diagnostics.Contracts; @@ -54,6 +55,7 @@ public static IServiceCollection AddApiVersioning( this IServiceCollection servi if ( options.ReportApiVersions ) { services.TryAddSingleton(); + services.AddTransient(); } else { diff --git a/src/Microsoft.AspNetCore.Mvc.Versioning/Routing/DefaultApiVersionRoutePolicy.cs b/src/Microsoft.AspNetCore.Mvc.Versioning/Routing/DefaultApiVersionRoutePolicy.cs index 4c8d96dc..9ba180b8 100644 --- a/src/Microsoft.AspNetCore.Mvc.Versioning/Routing/DefaultApiVersionRoutePolicy.cs +++ b/src/Microsoft.AspNetCore.Mvc.Versioning/Routing/DefaultApiVersionRoutePolicy.cs @@ -164,9 +164,7 @@ protected virtual void OnSingleMatch( RouteContext context, ActionSelectionResul Arg.NotNull( match, nameof( match ) ); var handler = new DefaultActionHandler( ActionInvokerFactory, ActionContextAccessor, selectionResult, match ); - var candidates = selectionResult.CandidateActions.SelectMany( kvp => kvp.Value ); - match.Action.AggregateAllVersions( candidates ); context.RouteData = match.RouteData; context.Handler = handler.Invoke; } diff --git a/src/Microsoft.AspNetCore.Mvc.Versioning/Versioning/ApiVersionActionSelector.cs b/src/Microsoft.AspNetCore.Mvc.Versioning/Versioning/ApiVersionActionSelector.cs index 706f603c..c2ea9edc 100644 --- a/src/Microsoft.AspNetCore.Mvc.Versioning/Versioning/ApiVersionActionSelector.cs +++ b/src/Microsoft.AspNetCore.Mvc.Versioning/Versioning/ApiVersionActionSelector.cs @@ -166,6 +166,7 @@ public virtual ActionDescriptor SelectBestCandidate( RouteContext context, IRead } else { + AppendPossibleMatches( new[] { selectedAction }, context, selectionResult ); return selectedAction; } } @@ -261,7 +262,7 @@ static ActionDescriptor SelectActionWithApiVersionPolicyApplied( IReadOnlyList + /// Represents an object that collates API versions per action. + /// + [CLSCompliant( false )] + public class ApiVersionCollator : IActionDescriptorProvider + { + /// + public int Order { get; protected set; } + + /// + public virtual void OnProvidersExecuted( ActionDescriptorProviderContext context ) + { + foreach ( var actions in GroupActionsByController( context.Results ) ) + { + var collatedModel = CollateModel( actions ); + + foreach ( var action in actions ) + { + var model = action.GetProperty(); + + if ( model != null ) + { + action.SetProperty( model.Aggregate( collatedModel ) ); + } + } + } + } + + /// + public virtual void OnProvidersExecuting( ActionDescriptorProviderContext context ) + { + } + + /// + /// Resolves and returns the logical controller name for the specified action. + /// + /// The action to get the controller name from. + /// The logical name of the associated controller. + /// + /// + /// The logical controller name is used to collate actions together and aggregate API versions. The + /// default implementation uses the "controller" route parameter and falls back to the + /// property when available. + /// + /// + /// The default implementation will also trim trailing numbers in the controller name by convention. For example, + /// the type "Values2Controller" will have the controller name "Values2", which will be trimmed to just "Values". + /// This behavior can be changed by using the or overriding the default + /// implementation. + /// + /// + protected virtual string GetControllerName( ActionDescriptor action ) + { + Arg.NotNull( action, nameof( action ) ); + + if ( !action.RouteValues.TryGetValue( "controller", out var key ) ) + { + if ( action is ControllerActionDescriptor controllerAction ) + { + key = controllerAction.ControllerName; + } + } + + return TrimTrailingNumbers( key ); + } + + IEnumerable> GroupActionsByController( IEnumerable actions ) + { + Contract.Requires( actions != null ); + Contract.Ensures( Contract.Result>>() != null ); + + var groups = new Dictionary>( StringComparer.OrdinalIgnoreCase ); + + foreach ( var action in actions ) + { + var key = GetControllerName( action ); + + if ( string.IsNullOrEmpty( key ) ) + { + continue; + } + + if ( !groups.TryGetValue( key, out var values ) ) + { + groups.Add( key, values = new List() ); + } + + values.Add( action ); + } + + foreach ( var value in groups.Values ) + { + yield return value; + } + } + + static string TrimTrailingNumbers( string name ) + { + if ( string.IsNullOrEmpty( name ) ) + { + return name; + } + + var last = name.Length - 1; + + for ( var i = last; i >= 0; i-- ) + { + if ( !char.IsNumber( name[i] ) ) + { + if ( i < last ) + { + return name.Substring( 0, i + 1 ); + } + + return name; + } + } + + return name; + } + + static ApiVersionModel CollateModel( IEnumerable actions ) => + actions.Select( a => a.GetProperty() ).Where( m => m != null ).Aggregate(); + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.Versioning.Tests/Versioning/ApiVersionActionSelectorTest.cs b/test/Microsoft.AspNetCore.Mvc.Versioning.Tests/Versioning/ApiVersionActionSelectorTest.cs index a882bd43..fd593563 100644 --- a/test/Microsoft.AspNetCore.Mvc.Versioning.Tests/Versioning/ApiVersionActionSelectorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Versioning.Tests/Versioning/ApiVersionActionSelectorTest.cs @@ -452,7 +452,7 @@ public async Task select_best_candidate_should_return_correct_controller_for_ver var deprecated = new[] { new ApiVersion( 4, 0 ) }; var implemented = supported.Union( deprecated ).OrderBy( v => v ).ToArray(); - using ( var server = new WebServer() ) + using ( var server = new WebServer( o => o.ReportApiVersions = true ) ) { await server.Client.GetAsync( $"api/{versionSegment}/attributed" ); diff --git a/test/Microsoft.AspNetCore.Mvc.Versioning.Tests/Versioning/ApiVersionCollatorTest.cs b/test/Microsoft.AspNetCore.Mvc.Versioning.Tests/Versioning/ApiVersionCollatorTest.cs new file mode 100644 index 00000000..9cb3700e --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Versioning.Tests/Versioning/ApiVersionCollatorTest.cs @@ -0,0 +1,140 @@ +namespace Microsoft.AspNetCore.Mvc.Versioning +{ + using FluentAssertions; + using Microsoft.AspNetCore.Mvc.Abstractions; + using Microsoft.AspNetCore.Mvc.Controllers; + using System.Collections.Generic; + using System.Linq; + using Xunit; + + public class ApiVersionCollatorTest + { + [Theory] + [MemberData( nameof( ActionDescriptorProviderContexts ) )] + public void on_providers_executed_should_aggregate_api_version_models_by_controller( ActionDescriptorProviderContext context ) + { + // arrange + var collator = new ApiVersionCollator(); + var expected = new[] { new ApiVersion( 1, 0 ), new ApiVersion( 2, 0 ), new ApiVersion( 3, 0 ) }; + + // act + collator.OnProvidersExecuted( context ); + + // assert + var actions = context.Results.Where( a => a.GetProperty() != null ); + + actions.All( a => a.GetProperty().SupportedApiVersions.SequenceEqual( expected ) ).Should().BeTrue(); + } + + public static IEnumerable ActionDescriptorProviderContexts + { + get + { + yield return new object[] { ActionsWithRouteValues }; + yield return new object[] { ActionsByControllerName }; + } + } + + private static ActionDescriptorProviderContext ActionsWithRouteValues => + new ActionDescriptorProviderContext() + { + Results = + { + new ActionDescriptor() + { + RouteValues = new Dictionary() + { + ["controller"] = "Values", + ["action"] = "Get", + }, + Properties = new Dictionary() + { + [typeof(ApiVersionModel)] = new ApiVersionModel( new ApiVersion( 1, 0 ) ), + }, + }, + new ActionDescriptor() + { + RouteValues = new Dictionary() + { + ["page"] = "/Some/Page", + }, + }, + new ActionDescriptor() + { + RouteValues = new Dictionary() + { + ["controller"] = "Values", + ["action"] = "Get", + }, + Properties = new Dictionary() + { + [typeof(ApiVersionModel)] = new ApiVersionModel( new ApiVersion( 2, 0 ) ), + }, + }, + new ActionDescriptor() + { + RouteValues = new Dictionary() + { + ["controller"] = "Values", + ["action"] = "Get", + }, + Properties = new Dictionary() + { + [typeof(ApiVersionModel)] = new ApiVersionModel( new ApiVersion( 3, 0 ) ), + }, + }, + }, + }; + + private static ActionDescriptorProviderContext ActionsByControllerName => + new ActionDescriptorProviderContext() + { + Results = + { + new ControllerActionDescriptor() + { + ControllerName = "Values", + RouteValues = new Dictionary() + { + ["action"] = "Get", + }, + Properties = new Dictionary() + { + [typeof(ApiVersionModel)] = new ApiVersionModel( new ApiVersion( 1, 0 ) ), + }, + }, + new ActionDescriptor() + { + RouteValues = new Dictionary() + { + ["page"] = "/Some/Page", + }, + }, + new ControllerActionDescriptor() + { + ControllerName = "Values2", + RouteValues = new Dictionary() + { + ["action"] = "Get", + }, + Properties = new Dictionary() + { + [typeof(ApiVersionModel)] = new ApiVersionModel( new ApiVersion( 2, 0 ) ), + }, + }, + new ControllerActionDescriptor() + { + ControllerName = "Values3", + RouteValues = new Dictionary() + { + ["action"] = "Get", + }, + Properties = new Dictionary() + { + [typeof(ApiVersionModel)] = new ApiVersionModel( new ApiVersion( 3, 0 ) ), + }, + }, + }, + }; + } +} \ No newline at end of file