Skip to content

Commit 5a78948

Browse files
author
Chris Martinez
committed
Refactor action selection to address 1.1 RC regression. Fixes #133. Fixes #130. Fixes #131. Resolves #134. Resolves #135.
1 parent 43753d0 commit 5a78948

File tree

15 files changed

+362
-50
lines changed

15 files changed

+362
-50
lines changed

src/Microsoft.AspNetCore.Mvc.Versioning/Routing/DefaultApiVersionRoutePolicy.cs

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,24 @@ public DefaultApiVersionRoutePolicy(
9898
public virtual Task RouteAsync( RouteContext context )
9999
{
100100
var selectionResult = context.HttpContext.ApiVersionProperties().SelectionResult;
101+
var match = selectionResult.BestMatch;
101102

102-
switch ( selectionResult.MatchingActions.Count )
103+
if ( match == null )
103104
{
104-
case 0:
105-
OnUnmatched( context, selectionResult );
106-
break;
107-
case 1:
108-
OnSingleMatch( context, selectionResult, selectionResult.MatchingActions.First() );
109-
break;
110-
default:
105+
var hasAnyMatches = selectionResult.MatchingActions.SelectMany( i => i.Value ).Any();
106+
107+
if ( hasAnyMatches )
108+
{
111109
OnMultipleMatches( context, selectionResult );
112-
break;
110+
}
111+
else
112+
{
113+
OnUnmatched( context, selectionResult );
114+
}
115+
}
116+
else
117+
{
118+
OnSingleMatch( context, selectionResult, selectionResult.BestMatch );
113119
}
114120

115121
return CompletedTask;
@@ -128,8 +134,9 @@ protected virtual void OnSingleMatch( RouteContext context, ActionSelectionResul
128134
Arg.NotNull( match, nameof( match ) );
129135

130136
var handler = new DefaultActionHandler( ActionInvokerFactory, ActionContextAccessor, selectionResult, match );
137+
var candidates = selectionResult.CandidateActions.SelectMany( kvp => kvp.Value );
131138

132-
match.Action.AggregateAllVersions( selectionResult.CandidateActions );
139+
match.Action.AggregateAllVersions( candidates );
133140
context.Handler = handler.Invoke;
134141
}
135142

@@ -157,7 +164,8 @@ protected virtual void OnMultipleMatches( RouteContext context, ActionSelectionR
157164
Arg.NotNull( context, nameof( context ) );
158165
Arg.NotNull( selectionResult, nameof( selectionResult ) );
159166

160-
var actionNames = Join( NewLine, selectionResult.MatchingActions.Select( match => match.Action.DisplayName ) );
167+
var matchingActions = selectionResult.MatchingActions.OrderBy( kvp => kvp.Key ).SelectMany( kvp => kvp.Value ).Distinct();
168+
var actionNames = Join( NewLine, matchingActions.Select( match => match.Action.DisplayName ) );
161169

162170
Logger.AmbiguousActions( actionNames );
163171

@@ -172,9 +180,9 @@ RequestHandler ClientError( RouteContext context, ActionSelectionResult selectio
172180
Contract.Requires( selectionResult != null );
173181

174182
const RequestHandler NotFound = default( RequestHandler );
175-
var candidates = selectionResult.CandidateActions;
183+
var candidates = selectionResult.CandidateActions.OrderBy( kvp => kvp.Key ).SelectMany( kvp => kvp.Value ).Distinct().ToArray();
176184

177-
if ( candidates.Count == 0 )
185+
if ( candidates.Length == 0 )
178186
{
179187
return NotFound;
180188
}

src/Microsoft.AspNetCore.Mvc.Versioning/Versioning/ActionSelectionResult.cs

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,162 @@
33
using Microsoft.AspNetCore.Mvc.Abstractions;
44
using System;
55
using System.Collections.Generic;
6+
using System.Linq;
67

78
/// <summary>
89
/// Represents an API versioning action selection result for which a versioning policy can be applied.
910
/// </summary>
1011
public class ActionSelectionResult
1112
{
13+
readonly Dictionary<int, ICollection<ActionDescriptor>> candidateActions = new Dictionary<int, ICollection<ActionDescriptor>>();
14+
readonly Dictionary<int, ICollection<ActionDescriptorMatch>> matchingActions = new Dictionary<int, ICollection<ActionDescriptorMatch>>();
15+
16+
/// <summary>
17+
/// Gets the number of action selection iterations that have occurred.
18+
/// </summary>
19+
/// <value>The total number of action selection iterations that have occurred. The default value is zero.</value>
20+
public int Iterations { get; private set; }
21+
22+
/// <summary>
23+
/// Gets the best action descriptor match.
24+
/// </summary>
25+
/// <value>The best <see cref="ActionDescriptorMatch">action descriptor match</see> or <c>null</c>.</value>
26+
/// <remarks>This property returns the first occurrence of a single match in the earliest iteration. If
27+
/// no matches exist in any iteration or multiple matches exist, this property returns <c>null</c>.</remarks>
28+
[CLSCompliant( false )]
29+
public ActionDescriptorMatch BestMatch
30+
{
31+
get
32+
{
33+
foreach ( var iteration in matchingActions )
34+
{
35+
switch ( iteration.Value.Count )
36+
{
37+
case 0:
38+
break;
39+
case 1:
40+
return iteration.Value.ElementAt( 0 );
41+
default:
42+
return null;
43+
}
44+
}
45+
46+
return null;
47+
}
48+
}
49+
50+
/// <summary>
51+
/// Gets a collection of candidate actions grouped by action selection iteration.
52+
/// </summary>
53+
/// <value>A <see cref="IReadOnlyDictionary{TKey, TValue}">read-only dictionary</see> of candidate
54+
/// <see cref="ActionDescriptor">actions</see> per action selection iteration.</value>
55+
[CLSCompliant( false )]
56+
public IReadOnlyDictionary<int, ICollection<ActionDescriptor>> CandidateActions => candidateActions;
57+
1258
/// <summary>
13-
/// Gets the collection of all candidate controller actions for the current route.
59+
/// Gets a collection of matching actions grouped by action selection iteration.
1460
/// </summary>
15-
/// <value>A <see cref="ICollection{T}">collection</see> of <see cref="ActionDescriptor">actions</see> candidate actions for the current route.</value>
61+
/// <value>A <see cref="IReadOnlyDictionary{TKey, TValue}">read-only dictionary</see> of
62+
/// <see cref="ActionDescriptorMatch">matching actions</see> per action selection iteration.</value>
1663
[CLSCompliant( false )]
17-
public ICollection<ActionDescriptor> CandidateActions { get; } = new HashSet<ActionDescriptor>();
64+
public IReadOnlyDictionary<int, ICollection<ActionDescriptorMatch>> MatchingActions => matchingActions;
1865

1966
/// <summary>
20-
/// Gets the collection of controller actions matching the current route.
67+
/// Adds the specified candidate actions to the selection result.
2168
/// </summary>
22-
/// <value>A <see cref="ICollection{T}">collection</see> of <see cref="ActionDescriptorMatch">matching actions</see> for the current route.</value>
69+
/// <param name="actions">The array of <see cref="ActionDescriptor">actions</see> to add to the selection result.</param>
2370
[CLSCompliant( false )]
24-
public ICollection<ActionDescriptorMatch> MatchingActions { get; } = new HashSet<ActionDescriptorMatch>();
71+
public void AddCandidates( params ActionDescriptor[] actions )
72+
{
73+
Arg.NotNull( actions, nameof( actions ) );
74+
75+
var key = Iterations;
76+
77+
if ( !candidateActions.TryGetValue( key, out var collection ) )
78+
{
79+
candidateActions[key] = collection = new HashSet<ActionDescriptor>();
80+
}
81+
82+
collection.AddRange( actions );
83+
}
84+
85+
/// <summary>
86+
/// Adds the specified candidate actions to the selection result.
87+
/// </summary>
88+
/// <param name="actions">The <see cref="IEnumerable{T}">sequence</see> of <see cref="ActionDescriptor">actions</see>
89+
/// to add to the selection result.</param>
90+
[CLSCompliant( false )]
91+
public void AddCandidates( IEnumerable<ActionDescriptor> actions )
92+
{
93+
Arg.NotNull( actions, nameof( actions ) );
94+
95+
var key = Iterations;
96+
97+
if ( !candidateActions.TryGetValue( key, out var collection ) )
98+
{
99+
candidateActions[key] = collection = new HashSet<ActionDescriptor>();
100+
}
101+
102+
collection.AddRange( actions );
103+
}
104+
105+
/// <summary>
106+
/// Adds the specified matching actions to the selection result.
107+
/// </summary>
108+
/// <param name="matches">The array of <see cref="ActionDescriptorMatch">matching actions</see> to add to the selection result.</param>
109+
[CLSCompliant( false )]
110+
public void AddMatches( params ActionDescriptorMatch[] matches )
111+
{
112+
Arg.NotNull( matches, nameof( matches ) );
113+
114+
var key = Iterations;
115+
116+
if ( !matchingActions.TryGetValue( key, out var collection ) )
117+
{
118+
matchingActions[key] = collection = new HashSet<ActionDescriptorMatch>();
119+
}
120+
121+
collection.AddRange( matches );
122+
}
123+
124+
/// <summary>
125+
/// Adds the specified matching actions to the selection result.
126+
/// </summary>
127+
/// <param name="matches">The <see cref="IEnumerable{T}">sequence</see> of <see cref="ActionDescriptorMatch">matching actions</see>
128+
/// to add to the selection result.</param>
129+
[CLSCompliant( false )]
130+
public void AddMatches( IEnumerable<ActionDescriptorMatch> matches )
131+
{
132+
Arg.NotNull( matches, nameof( matches ) );
133+
134+
var key = Iterations;
135+
136+
if ( !matchingActions.TryGetValue( key, out var collection ) )
137+
{
138+
matchingActions[key] = collection = new HashSet<ActionDescriptorMatch>();
139+
}
140+
141+
collection.AddRange( matches );
142+
}
143+
144+
/// <summary>
145+
/// Ends the current action selection iteration.
146+
/// </summary>
147+
public void EndIteration()
148+
{
149+
var key = Iterations;
150+
151+
if ( !candidateActions.ContainsKey( key ) )
152+
{
153+
candidateActions.Add( key, new ActionDescriptor[0] );
154+
}
155+
156+
if ( !matchingActions.ContainsKey( key ) )
157+
{
158+
matchingActions.Add( key, new ActionDescriptorMatch[0] );
159+
}
160+
161+
++Iterations;
162+
}
25163
}
26164
}

src/Microsoft.AspNetCore.Mvc.Versioning/Versioning/ApiVersionActionSelector.cs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ public virtual ActionDescriptor SelectBestCandidate( RouteContext context, IRead
106106
var selectionResult = properties.SelectionResult;
107107

108108
properties.ApiVersion = selectionContext.RequestedVersion;
109-
selectionResult.CandidateActions.AddRange( candidates );
109+
selectionResult.AddCandidates( candidates );
110110

111111
if ( finalMatches == null )
112112
{
113+
selectionResult.EndIteration();
113114
return null;
114115
}
115116

@@ -128,12 +129,12 @@ public virtual ActionDescriptor SelectBestCandidate( RouteContext context, IRead
128129
if ( finalMatches.Count > 0 )
129130
{
130131
var routeData = new RouteData( context.RouteData );
131-
selectionResult.MatchingActions.AddRange( finalMatches.Select( action => new ActionDescriptorMatch( action, routeData ) ) );
132+
selectionResult.AddMatches( finalMatches.Select( action => new ActionDescriptorMatch( action, routeData ) ) );
132133
}
133134

134-
// note: even though we may have had a successful match, this method could be called multiple times.
135-
// the final decision is made by the IApiVersionRoutePolicy. we return here to make sure all candidates
136-
// have been considered.
135+
// note: even though we may have had a successful match, this method could be called multiple times. the final decision
136+
// is made by the IApiVersionRoutePolicy. we return here to make sure all candidates have been considered.
137+
selectionResult.EndIteration();
137138
return null;
138139
}
139140

test/Microsoft.AspNetCore.Mvc.Acceptance.Tests/AcceptanceTest.cs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,20 @@
55
using Builder;
66
using Extensions.DependencyInjection;
77
using Hosting;
8+
using Microsoft.AspNetCore.Mvc.Razor;
9+
using Microsoft.Extensions.PlatformAbstractions;
810
using System;
911
using System.Collections.Generic;
12+
using System.IO;
13+
using System.Linq;
1014
using System.Net.Http;
1115
using System.Reflection;
1216
using TestHost;
1317
using Versioning;
1418
using Xunit;
19+
using static Microsoft.CodeAnalysis.MetadataReference;
1520
using static Microsoft.Extensions.DependencyInjection.ServiceDescriptor;
21+
using static System.Reflection.Assembly;
1622

1723
[Trait( "Framework", "ASP.NET Core" )]
1824
public abstract partial class AcceptanceTest : IDisposable
@@ -60,8 +66,9 @@ protected virtual void Dispose( bool disposing )
6066
TestServer CreateServer()
6167
{
6268
var builder = new WebHostBuilder()
63-
.Configure( app => app.UseMvc( OnConfigureRoutes ).UseApiVersioning() )
64-
.ConfigureServices( OnConfigureServices );
69+
.Configure( app => app.UseMvc( OnConfigureRoutes ).UseMvcWithDefaultRoute().UseApiVersioning() )
70+
.ConfigureServices( OnConfigureServices )
71+
.UseContentRoot( GetContentRoot() );
6572

6673
return new TestServer( builder );
6774
}
@@ -82,6 +89,33 @@ void OnConfigureServices( IServiceCollection services )
8289
services.Add( Singleton( partManager ) );
8390
services.AddMvc();
8491
services.AddApiVersioning( OnAddApiVersioning );
92+
services.Configure<RazorViewEngineOptions>( options =>
93+
{
94+
options.CompilationCallback += context =>
95+
{
96+
var assembly = GetType().GetTypeInfo().Assembly;
97+
var assemblies = assembly.GetReferencedAssemblies().Select( x => CreateFromFile( Load( x ).Location ) ).ToList();
98+
99+
assemblies.Add( CreateFromFile( Load( new AssemblyName( "mscorlib" ) ).Location ) );
100+
assemblies.Add( CreateFromFile( Load( new AssemblyName( "System.Private.Corelib" ) ).Location ) );
101+
assemblies.Add( CreateFromFile( Load( new AssemblyName( "System.Dynamic.Runtime" ) ).Location ) );
102+
assemblies.Add( CreateFromFile( Load( new AssemblyName( "Microsoft.AspNetCore.Razor" ) ).Location ) );
103+
context.Compilation = context.Compilation.AddReferences( assemblies );
104+
};
105+
} );
106+
}
107+
108+
string GetContentRoot()
109+
{
110+
var startupAssembly = GetType().GetTypeInfo().Assembly.GetName().Name;
111+
var contentRoot = new DirectoryInfo( PlatformServices.Default.Application.ApplicationBasePath );
112+
113+
while ( contentRoot.Name != startupAssembly )
114+
{
115+
contentRoot = contentRoot.Parent;
116+
}
117+
118+
return contentRoot.FullName;
85119
}
86120

87121
protected abstract void OnAddApiVersioning( ApiVersioningOptions options );

test/Microsoft.AspNetCore.Mvc.Acceptance.Tests/Basic/BasicAcceptanceTest.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ protected BasicAcceptanceTest()
1111
FilteredControllerTypes.Add( typeof( ValuesController ).GetTypeInfo() );
1212
FilteredControllerTypes.Add( typeof( Values2Controller ).GetTypeInfo() );
1313
FilteredControllerTypes.Add( typeof( HelloWorldController ).GetTypeInfo() );
14+
FilteredControllerTypes.Add( typeof( HelloWorld2Controller ).GetTypeInfo() );
15+
FilteredControllerTypes.Add( typeof( HomeController ).GetTypeInfo() );
1416
}
1517

1618
protected override void OnAddApiVersioning( ApiVersioningOptions options ) => options.ReportApiVersions = true;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
namespace Microsoft.AspNetCore.Mvc.Basic.Controllers
2+
{
3+
using AspNetCore.Routing;
4+
using Microsoft.AspNetCore.Mvc;
5+
using System;
6+
7+
[ApiVersion( "2.0" )]
8+
[Route( "api/v{version:apiVersion}/HelloWorld" )]
9+
public class HelloWorld2Controller : Controller
10+
{
11+
[HttpGet]
12+
public IActionResult Get() => Ok( new { Controller = GetType().Name, Version = HttpContext.GetRequestedApiVersion().ToString() } );
13+
14+
[HttpGet( "{id:int}", Name = "GetMessageById-V2" )]
15+
public IActionResult Get( int id ) => Ok( new { Controller = GetType().Name, Id = id, Version = HttpContext.GetRequestedApiVersion().ToString() } );
16+
17+
[HttpPost]
18+
public IActionResult Post() => CreatedAtRoute( "GetMessageById-V2", new { id = 42 }, null );
19+
20+
[HttpGet( "search" )]
21+
public IActionResult Search( string query ) => Ok( new { Controller = GetType().Name, Query = query, Version = HttpContext.GetRequestedApiVersion().ToString() } );
22+
}
23+
}

test/Microsoft.AspNetCore.Mvc.Acceptance.Tests/Basic/Controllers/HelloWorldController.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@
44
using Microsoft.AspNetCore.Mvc;
55
using System;
66

7+
[ApiVersion( "1.0" )]
78
[Route( "api/v{version:apiVersion}/[controller]" )]
89
public class HelloWorldController : Controller
910
{
1011
[HttpGet]
11-
public IActionResult Get() => Ok( new { Controller = nameof( HelloWorldController ), Version = HttpContext.GetRequestedApiVersion().ToString() } );
12+
public IActionResult Get() => Ok( new { Controller = GetType().Name, Version = HttpContext.GetRequestedApiVersion().ToString() } );
1213

13-
[HttpGet( "{id:int}", Name = "GetMessageById" )]
14-
public IActionResult Get( int id ) => Ok( new { Controller = GetType().Name, Id = id, Version = HttpContext.GetRequestedApiVersion().ToString() } );
14+
[HttpGet( "{id}", Name = "GetMessageById" )]
15+
public IActionResult Get( string id ) => Ok( new { Controller = GetType().Name, Id = id, Version = HttpContext.GetRequestedApiVersion().ToString() } );
1516

1617
[HttpPost]
1718
public IActionResult Post() => CreatedAtRoute( "GetMessageById", new { id = 42 }, null );
19+
20+
[HttpGet( "search" )]
21+
public IActionResult Search( string query ) => Ok( new { Controller = GetType().Name, Query = query, Version = HttpContext.GetRequestedApiVersion().ToString() } );
1822
}
1923
}

0 commit comments

Comments
 (0)