Removing Overloading and Automatic verb-mapping
This change removes WebAPI-style method parameter overloading and the automatic mapping of 'unnamed' actions based on method names. For all practicaly purposes, this change restores the MVC5 behavior for action selection. WebAPI-style overloading will be brought back in the future via a set of opt-in constructs.
This commit is contained in:
parent
fee3b3cc6c
commit
414c009b80
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNet.Mvc
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// An exception which indicates multiple matches in action selection.
|
||||||
|
/// </summary>
|
||||||
|
#if ASPNET50
|
||||||
|
[Serializable]
|
||||||
|
#endif
|
||||||
|
public class AmbiguousActionException : InvalidOperationException
|
||||||
|
{
|
||||||
|
public AmbiguousActionException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
#if ASPNET50
|
||||||
|
protected AmbiguousActionException(SerializationInfo info, StreamingContext context)
|
||||||
|
: base(info, context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Microsoft.AspNet.Mvc.Routing;
|
using Microsoft.AspNet.Mvc.Routing;
|
||||||
|
|
||||||
|
|
@ -12,26 +11,6 @@ namespace Microsoft.AspNet.Mvc
|
||||||
{
|
{
|
||||||
public class DefaultActionDiscoveryConventions : IActionDiscoveryConventions
|
public class DefaultActionDiscoveryConventions : IActionDiscoveryConventions
|
||||||
{
|
{
|
||||||
private static readonly string[] _supportedHttpMethodsByConvention =
|
|
||||||
{
|
|
||||||
"GET",
|
|
||||||
"POST",
|
|
||||||
"PUT",
|
|
||||||
"DELETE",
|
|
||||||
"PATCH",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly string[] _supportedHttpMethodsForDefaultMethod =
|
|
||||||
{
|
|
||||||
"GET",
|
|
||||||
"POST"
|
|
||||||
};
|
|
||||||
|
|
||||||
public virtual string DefaultMethodName
|
|
||||||
{
|
|
||||||
get { return "Index"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual bool IsController([NotNull] TypeInfo typeInfo)
|
public virtual bool IsController([NotNull] TypeInfo typeInfo)
|
||||||
{
|
{
|
||||||
if (!typeInfo.IsClass ||
|
if (!typeInfo.IsClass ||
|
||||||
|
|
@ -73,17 +52,20 @@ namespace Microsoft.AspNet.Mvc
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
actionInfos = GetActionsForMethodsWithoutCustomAttributes(methodInfo, controllerTypeInfo);
|
// By default the action is just matched by name.
|
||||||
|
actionInfos = new ActionInfo[]
|
||||||
|
{
|
||||||
|
new ActionInfo()
|
||||||
|
{
|
||||||
|
ActionName = methodInfo.Name,
|
||||||
|
RequireActionNameMatch = true,
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return actionInfos;
|
return actionInfos;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected virtual bool IsDefaultActionMethod([NotNull] MethodInfo methodInfo)
|
|
||||||
{
|
|
||||||
return String.Equals(methodInfo.Name, DefaultMethodName, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines whether the method is a valid action.
|
/// Determines whether the method is a valid action.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -107,18 +89,6 @@ namespace Microsoft.AspNet.Mvc
|
||||||
method.GetBaseDefinition().DeclaringType != typeof(object);
|
method.GetBaseDefinition().DeclaringType != typeof(object);
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual IEnumerable<string> GetSupportedHttpMethods(MethodInfo methodInfo)
|
|
||||||
{
|
|
||||||
var supportedHttpMethods =
|
|
||||||
_supportedHttpMethodsByConvention.FirstOrDefault(
|
|
||||||
httpMethod => methodInfo.Name.Equals(httpMethod, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (supportedHttpMethods != null)
|
|
||||||
{
|
|
||||||
yield return supportedHttpMethods;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool HasCustomAttributes(MethodInfo methodInfo)
|
private bool HasCustomAttributes(MethodInfo methodInfo)
|
||||||
{
|
{
|
||||||
var actionAttributes = GetActionCustomAttributes(methodInfo);
|
var actionAttributes = GetActionCustomAttributes(methodInfo);
|
||||||
|
|
@ -256,70 +226,6 @@ namespace Microsoft.AspNet.Mvc
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<ActionInfo> GetActionsForMethodsWithoutCustomAttributes(
|
|
||||||
MethodInfo methodInfo,
|
|
||||||
TypeInfo controllerTypeInfo)
|
|
||||||
{
|
|
||||||
var actionInfos = new List<ActionInfo>();
|
|
||||||
var httpMethods = GetSupportedHttpMethods(methodInfo);
|
|
||||||
if (httpMethods != null && httpMethods.Any())
|
|
||||||
{
|
|
||||||
return new[]
|
|
||||||
{
|
|
||||||
new ActionInfo()
|
|
||||||
{
|
|
||||||
HttpMethods = httpMethods.ToArray(),
|
|
||||||
ActionName = methodInfo.Name,
|
|
||||||
RequireActionNameMatch = false,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For Default Method add an action Info with GET, POST Http Method constraints.
|
|
||||||
// Only constraints (out of GET and POST) for which there are no convention based actions available are
|
|
||||||
// added. If there are existing action infos with http constraints for GET and POST, this action info is
|
|
||||||
// not added for default method.
|
|
||||||
if (IsDefaultActionMethod(methodInfo))
|
|
||||||
{
|
|
||||||
var existingHttpMethods = new HashSet<string>();
|
|
||||||
foreach (var declaredMethodInfo in controllerTypeInfo.DeclaredMethods)
|
|
||||||
{
|
|
||||||
if (!IsValidActionMethod(declaredMethodInfo) || HasCustomAttributes(declaredMethodInfo))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
httpMethods = GetSupportedHttpMethods(declaredMethodInfo);
|
|
||||||
if (httpMethods != null)
|
|
||||||
{
|
|
||||||
existingHttpMethods.UnionWith(httpMethods);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var undefinedHttpMethods = _supportedHttpMethodsForDefaultMethod.Except(
|
|
||||||
existingHttpMethods,
|
|
||||||
StringComparer.Ordinal)
|
|
||||||
.ToArray();
|
|
||||||
if (undefinedHttpMethods.Any())
|
|
||||||
{
|
|
||||||
actionInfos.Add(new ActionInfo()
|
|
||||||
{
|
|
||||||
HttpMethods = undefinedHttpMethods,
|
|
||||||
ActionName = methodInfo.Name,
|
|
||||||
RequireActionNameMatch = false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actionInfos.Add(
|
|
||||||
new ActionInfo()
|
|
||||||
{
|
|
||||||
ActionName = methodInfo.Name,
|
|
||||||
RequireActionNameMatch = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionInfos;
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ActionAttributes
|
private class ActionAttributes
|
||||||
{
|
{
|
||||||
public ActionNameAttribute ActionNameAttribute { get; set; }
|
public ActionNameAttribute ActionNameAttribute { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNet.Mvc.Core;
|
using Microsoft.AspNet.Mvc.Core;
|
||||||
using Microsoft.AspNet.Mvc.Logging;
|
using Microsoft.AspNet.Mvc.Logging;
|
||||||
using Microsoft.AspNet.Mvc.ModelBinding;
|
|
||||||
using Microsoft.AspNet.Mvc.Routing;
|
using Microsoft.AspNet.Mvc.Routing;
|
||||||
using Microsoft.AspNet.Routing;
|
using Microsoft.AspNet.Routing;
|
||||||
using Microsoft.Framework.Logging;
|
using Microsoft.Framework.Logging;
|
||||||
|
|
@ -18,22 +17,19 @@ namespace Microsoft.AspNet.Mvc
|
||||||
{
|
{
|
||||||
private readonly IActionDescriptorsCollectionProvider _actionDescriptorsCollectionProvider;
|
private readonly IActionDescriptorsCollectionProvider _actionDescriptorsCollectionProvider;
|
||||||
private readonly IActionSelectorDecisionTreeProvider _decisionTreeProvider;
|
private readonly IActionSelectorDecisionTreeProvider _decisionTreeProvider;
|
||||||
private readonly IActionBindingContextProvider _bindingProvider;
|
|
||||||
private ILogger _logger;
|
private ILogger _logger;
|
||||||
|
|
||||||
public DefaultActionSelector(
|
public DefaultActionSelector(
|
||||||
[NotNull] IActionDescriptorsCollectionProvider actionDescriptorsCollectionProvider,
|
[NotNull] IActionDescriptorsCollectionProvider actionDescriptorsCollectionProvider,
|
||||||
[NotNull] IActionSelectorDecisionTreeProvider decisionTreeProvider,
|
[NotNull] IActionSelectorDecisionTreeProvider decisionTreeProvider,
|
||||||
[NotNull] IActionBindingContextProvider bindingProvider,
|
|
||||||
[NotNull] ILoggerFactory loggerFactory)
|
[NotNull] ILoggerFactory loggerFactory)
|
||||||
{
|
{
|
||||||
_actionDescriptorsCollectionProvider = actionDescriptorsCollectionProvider;
|
_actionDescriptorsCollectionProvider = actionDescriptorsCollectionProvider;
|
||||||
_decisionTreeProvider = decisionTreeProvider;
|
_decisionTreeProvider = decisionTreeProvider;
|
||||||
_bindingProvider = bindingProvider;
|
|
||||||
_logger = loggerFactory.Create<DefaultActionSelector>();
|
_logger = loggerFactory.Create<DefaultActionSelector>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ActionDescriptor> SelectAsync([NotNull] RouteContext context)
|
public Task<ActionDescriptor> SelectAsync([NotNull] RouteContext context)
|
||||||
{
|
{
|
||||||
using (_logger.BeginScope("DefaultActionSelector.SelectAsync"))
|
using (_logger.BeginScope("DefaultActionSelector.SelectAsync"))
|
||||||
{
|
{
|
||||||
|
|
@ -67,7 +63,9 @@ namespace Microsoft.AspNet.Mvc
|
||||||
matching = matchesWithConstraints;
|
matching = matchesWithConstraints;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matching.Count == 0)
|
var finalMatches = SelectBestActions(matching);
|
||||||
|
|
||||||
|
if (finalMatches.Count == 0)
|
||||||
{
|
{
|
||||||
if (_logger.IsEnabled(TraceType.Information))
|
if (_logger.IsEnabled(TraceType.Information))
|
||||||
{
|
{
|
||||||
|
|
@ -75,17 +73,17 @@ namespace Microsoft.AspNet.Mvc
|
||||||
{
|
{
|
||||||
ActionsMatchingRouteConstraints = matchingRouteConstraints,
|
ActionsMatchingRouteConstraints = matchingRouteConstraints,
|
||||||
ActionsMatchingRouteAndMethodConstraints = matchingRouteAndMethodConstraints,
|
ActionsMatchingRouteAndMethodConstraints = matchingRouteAndMethodConstraints,
|
||||||
ActionsMatchingRouteAndMethodAndDynamicConstraints =
|
ActionsMatchingRouteAndMethodAndDynamicConstraints =
|
||||||
matchingRouteAndMethodAndDynamicConstraints,
|
matchingRouteAndMethodAndDynamicConstraints,
|
||||||
ActionsMatchingWithConstraints = matchesWithConstraints
|
FinalMatches = finalMatches,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return Task.FromResult<ActionDescriptor>(null);
|
||||||
}
|
}
|
||||||
else
|
else if (finalMatches.Count == 1)
|
||||||
{
|
{
|
||||||
var selectedAction = await SelectBestCandidate(context, matching);
|
var selectedAction = finalMatches[0];
|
||||||
|
|
||||||
if (_logger.IsEnabled(TraceType.Information))
|
if (_logger.IsEnabled(TraceType.Information))
|
||||||
{
|
{
|
||||||
|
|
@ -95,16 +93,50 @@ namespace Microsoft.AspNet.Mvc
|
||||||
ActionsMatchingRouteAndMethodConstraints = matchingRouteAndMethodConstraints,
|
ActionsMatchingRouteAndMethodConstraints = matchingRouteAndMethodConstraints,
|
||||||
ActionsMatchingRouteAndMethodAndDynamicConstraints =
|
ActionsMatchingRouteAndMethodAndDynamicConstraints =
|
||||||
matchingRouteAndMethodAndDynamicConstraints,
|
matchingRouteAndMethodAndDynamicConstraints,
|
||||||
ActionsMatchingWithConstraints = matchesWithConstraints,
|
FinalMatches = finalMatches,
|
||||||
SelectedAction = selectedAction
|
SelectedAction = selectedAction
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return selectedAction;
|
return Task.FromResult(selectedAction);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (_logger.IsEnabled(TraceType.Information))
|
||||||
|
{
|
||||||
|
_logger.WriteValues(new DefaultActionSelectorSelectAsyncValues()
|
||||||
|
{
|
||||||
|
ActionsMatchingRouteConstraints = matchingRouteConstraints,
|
||||||
|
ActionsMatchingRouteAndMethodConstraints = matchingRouteAndMethodConstraints,
|
||||||
|
ActionsMatchingRouteAndMethodAndDynamicConstraints =
|
||||||
|
matchingRouteAndMethodAndDynamicConstraints,
|
||||||
|
FinalMatches = finalMatches,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionNames = string.Join(
|
||||||
|
Environment.NewLine,
|
||||||
|
finalMatches.Select(a => a.DisplayName));
|
||||||
|
|
||||||
|
var message = Resources.FormatDefaultActionSelector_AmbiguousActions(
|
||||||
|
Environment.NewLine,
|
||||||
|
actionNames);
|
||||||
|
|
||||||
|
throw new AmbiguousActionException(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the set of best matching actions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actions">The set of actions that satisfy all constraints.</param>
|
||||||
|
/// <returns>A list of the best matching actions.</returns>
|
||||||
|
protected virtual IReadOnlyList<ActionDescriptor> SelectBestActions(IReadOnlyList<ActionDescriptor> actions)
|
||||||
|
{
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
private bool MatchMethodConstraints(ActionDescriptor descriptor, RouteContext context)
|
private bool MatchMethodConstraints(ActionDescriptor descriptor, RouteContext context)
|
||||||
{
|
{
|
||||||
return descriptor.MethodConstraints == null ||
|
return descriptor.MethodConstraints == null ||
|
||||||
|
|
@ -117,76 +149,6 @@ namespace Microsoft.AspNet.Mvc
|
||||||
descriptor.DynamicConstraints.All(c => c.Accept(context));
|
descriptor.DynamicConstraints.All(c => c.Accept(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected virtual async Task<ActionDescriptor> SelectBestCandidate(
|
|
||||||
RouteContext context,
|
|
||||||
List<ActionDescriptor> candidates)
|
|
||||||
{
|
|
||||||
var applicableCandiates = new List<ActionDescriptorCandidate>();
|
|
||||||
foreach (var action in candidates)
|
|
||||||
{
|
|
||||||
var isApplicable = true;
|
|
||||||
var candidate = new ActionDescriptorCandidate()
|
|
||||||
{
|
|
||||||
Action = action,
|
|
||||||
};
|
|
||||||
|
|
||||||
var actionContext = new ActionContext(context, action);
|
|
||||||
var actionBindingContext = await _bindingProvider.GetActionBindingContextAsync(actionContext);
|
|
||||||
|
|
||||||
foreach (var parameter in action.Parameters.Where(p => p.ParameterBindingInfo != null))
|
|
||||||
{
|
|
||||||
if (!ValueProviderResult.CanConvertFromString(parameter.ParameterBindingInfo.ParameterType))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await actionBindingContext.ValueProvider.ContainsPrefixAsync(
|
|
||||||
parameter.ParameterBindingInfo.Prefix))
|
|
||||||
{
|
|
||||||
candidate.FoundParameters++;
|
|
||||||
if (parameter.IsOptional)
|
|
||||||
{
|
|
||||||
candidate.FoundOptionalParameters++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!parameter.IsOptional)
|
|
||||||
{
|
|
||||||
isApplicable = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isApplicable)
|
|
||||||
{
|
|
||||||
applicableCandiates.Add(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (applicableCandiates.Count == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var mostParametersSatisfied =
|
|
||||||
applicableCandiates
|
|
||||||
.GroupBy(c => c.FoundParameters)
|
|
||||||
.OrderByDescending(g => g.Key)
|
|
||||||
.First();
|
|
||||||
|
|
||||||
var fewestOptionalParameters =
|
|
||||||
mostParametersSatisfied
|
|
||||||
.GroupBy(c => c.FoundOptionalParameters)
|
|
||||||
.OrderBy(g => g.Key).First()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
if (fewestOptionalParameters.Length > 1)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("The actions are ambiguious.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return fewestOptionalParameters[0].Action;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This method attempts to ensure that the route that's about to generate a link will generate a link
|
// This method attempts to ensure that the route that's about to generate a link will generate a link
|
||||||
// to an existing action. This method is called by a route (through MvcApplication) prior to generating
|
// to an existing action. This method is called by a route (through MvcApplication) prior to generating
|
||||||
// any link - this gives WebFX a chance to 'veto' the values provided by a route.
|
// any link - this gives WebFX a chance to 'veto' the values provided by a route.
|
||||||
|
|
@ -222,14 +184,5 @@ namespace Microsoft.AspNet.Mvc
|
||||||
|
|
||||||
return descriptors.Items;
|
return descriptors.Items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ActionDescriptorCandidate
|
|
||||||
{
|
|
||||||
public ActionDescriptor Action { get; set; }
|
|
||||||
|
|
||||||
public int FoundParameters { get; set; }
|
|
||||||
|
|
||||||
public int FoundOptionalParameters { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,12 @@ namespace Microsoft.AspNet.Mvc.Logging
|
||||||
public IReadOnlyList<ActionDescriptor> ActionsMatchingRouteAndMethodAndDynamicConstraints { get; set; }
|
public IReadOnlyList<ActionDescriptor> ActionsMatchingRouteAndMethodAndDynamicConstraints { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The actions that matched with at least one constraint.
|
/// The list of actions that are the best matches. These match all constraints.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<ActionDescriptor> ActionsMatchingWithConstraints { get; set; }
|
public IReadOnlyList<ActionDescriptor> FinalMatches { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The selected action.
|
/// The selected action. Will be null if no matches are found or more than one match is found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ActionDescriptor SelectedAction { get; set; }
|
public ActionDescriptor SelectedAction { get; set; }
|
||||||
|
|
||||||
|
|
@ -65,8 +65,8 @@ namespace Microsoft.AspNet.Mvc.Logging
|
||||||
builder.Append("\tActions matching route, method, and dynamic constraints: ");
|
builder.Append("\tActions matching route, method, and dynamic constraints: ");
|
||||||
StringBuilderHelpers.Append(builder, ActionsMatchingRouteAndMethodAndDynamicConstraints, Formatter);
|
StringBuilderHelpers.Append(builder, ActionsMatchingRouteAndMethodAndDynamicConstraints, Formatter);
|
||||||
builder.AppendLine();
|
builder.AppendLine();
|
||||||
builder.Append("\tActions matching with at least one constraint: ");
|
builder.Append("\tBest Matches: ");
|
||||||
StringBuilderHelpers.Append(builder, ActionsMatchingWithConstraints, Formatter);
|
StringBuilderHelpers.Append(builder, FinalMatches, Formatter);
|
||||||
builder.AppendLine();
|
builder.AppendLine();
|
||||||
builder.Append("\tSelected action: ");
|
builder.Append("\tSelected action: ");
|
||||||
builder.Append(Formatter(SelectedAction));
|
builder.Append(Formatter(SelectedAction));
|
||||||
|
|
|
||||||
|
|
@ -1482,6 +1482,22 @@ namespace Microsoft.AspNet.Mvc.Core
|
||||||
return GetString("AttributeRoute_NullTemplateRepresentation");
|
return GetString("AttributeRoute_NullTemplateRepresentation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multiple actions matched. The following actions matched route data and had all constraints satisfied:{0}{0}{1}
|
||||||
|
/// </summary>
|
||||||
|
internal static string DefaultActionSelector_AmbiguousActions
|
||||||
|
{
|
||||||
|
get { return GetString("DefaultActionSelector_AmbiguousActions"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Multiple actions matched. The following actions matched route data and had all constraints satisfied:{0}{0}{1}
|
||||||
|
/// </summary>
|
||||||
|
internal static string FormatDefaultActionSelector_AmbiguousActions(object p0, object p1)
|
||||||
|
{
|
||||||
|
return string.Format(CultureInfo.CurrentCulture, GetString("DefaultActionSelector_AmbiguousActions"), p0, p1);
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetString(string name, params string[] formatterNames)
|
private static string GetString(string name, params string[] formatterNames)
|
||||||
{
|
{
|
||||||
var value = _resourceManager.GetString(name);
|
var value = _resourceManager.GetString(name);
|
||||||
|
|
|
||||||
|
|
@ -402,4 +402,8 @@
|
||||||
<data name="AttributeRoute_NullTemplateRepresentation" xml:space="preserve">
|
<data name="AttributeRoute_NullTemplateRepresentation" xml:space="preserve">
|
||||||
<value>(none)</value>
|
<value>(none)</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="DefaultActionSelector_AmbiguousActions" xml:space="preserve">
|
||||||
|
<value>Multiple actions matched. The following actions matched route data and had all constraints satisfied:{0}{0}{1}</value>
|
||||||
|
<comment>0 is the newline - 1 is a newline separate list of action display names</comment>
|
||||||
|
</data>
|
||||||
</root>
|
</root>
|
||||||
|
|
@ -87,26 +87,6 @@ namespace Microsoft.AspNet.Mvc
|
||||||
Assert.Equal(null, result);
|
Assert.Equal(null, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("GET")]
|
|
||||||
[InlineData("POST")]
|
|
||||||
public async Task HttpMethodAttribute_DefaultMethod_IgnoresMethodsWithCustomAttributesAndInvalidMethods(string verb)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
// Note no action name is passed, hence should return a null action descriptor.
|
|
||||||
var routeContext = new RouteContext(GetHttpContext(verb));
|
|
||||||
routeContext.RouteData.Values = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "controller", "HttpMethodAttributeTests_DefaultMethodValidation" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await InvokeActionSelector(routeContext);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal("Index", result.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("Put")]
|
[InlineData("Put")]
|
||||||
[InlineData("RPCMethod")]
|
[InlineData("RPCMethod")]
|
||||||
|
|
@ -198,12 +178,9 @@ namespace Microsoft.AspNet.Mvc
|
||||||
var actionCollectionDescriptorProvider = new DefaultActionDescriptorsCollectionProvider(serviceContainer);
|
var actionCollectionDescriptorProvider = new DefaultActionDescriptorsCollectionProvider(serviceContainer);
|
||||||
var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionCollectionDescriptorProvider);
|
var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionCollectionDescriptorProvider);
|
||||||
|
|
||||||
var bindingProvider = new Mock<IActionBindingContextProvider>();
|
|
||||||
|
|
||||||
var defaultActionSelector = new DefaultActionSelector(
|
var defaultActionSelector = new DefaultActionSelector(
|
||||||
actionCollectionDescriptorProvider,
|
actionCollectionDescriptorProvider,
|
||||||
decisionTreeProvider,
|
decisionTreeProvider,
|
||||||
bindingProvider.Object,
|
|
||||||
NullLoggerFactory.Instance);
|
NullLoggerFactory.Instance);
|
||||||
|
|
||||||
return await defaultActionSelector.SelectAsync(context);
|
return await defaultActionSelector.SelectAsync(context);
|
||||||
|
|
@ -243,14 +220,15 @@ namespace Microsoft.AspNet.Mvc
|
||||||
|
|
||||||
private class CustomActionConvention : DefaultActionDiscoveryConventions
|
private class CustomActionConvention : DefaultActionDiscoveryConventions
|
||||||
{
|
{
|
||||||
public override IEnumerable<string> GetSupportedHttpMethods(MethodInfo methodInfo)
|
public override IEnumerable<ActionInfo> GetActions([NotNull]MethodInfo methodInfo, [NotNull]TypeInfo controllerTypeInfo)
|
||||||
{
|
{
|
||||||
if (methodInfo.Name.Equals("PostSomething", StringComparison.OrdinalIgnoreCase))
|
var actions = new List<ActionInfo>(base.GetActions(methodInfo, controllerTypeInfo));
|
||||||
|
if (methodInfo.Name == "PostSomething")
|
||||||
{
|
{
|
||||||
return new[] { "POST" };
|
actions[0].HttpMethods = new string[] { "POST" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return actions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,15 @@ namespace Microsoft.AspNet.Mvc
|
||||||
{
|
{
|
||||||
public class DefaultActionDiscoveryConventionsActionSelectionTests
|
public class DefaultActionDiscoveryConventionsActionSelectionTests
|
||||||
{
|
{
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData("GET")]
|
public async Task ActionSelection_ActionSelectedByName()
|
||||||
[InlineData("POST")]
|
|
||||||
public async Task ActionSelection_IndexSelectedByDefaultInAbsenceOfVerbOnlyMethod(string verb)
|
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var routeContext = new RouteContext(GetHttpContext(verb));
|
var routeContext = new RouteContext(GetHttpContext("GET"));
|
||||||
routeContext.RouteData.Values = new Dictionary<string, object>
|
routeContext.RouteData.Values = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "controller", "RpcOnly" }
|
{ "controller", "RpcOnly" },
|
||||||
|
{ "action", "Index" }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|
@ -40,101 +39,7 @@ namespace Microsoft.AspNet.Mvc
|
||||||
Assert.Equal("Index", result.Name);
|
Assert.Equal("Index", result.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
// Uses custom conventions to map a web-api-style action
|
||||||
[InlineData("GET")]
|
|
||||||
[InlineData("POST")]
|
|
||||||
public async Task ActionSelection_PrefersVerbOnlyMethodOverIndex(string verb)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var routeContext = new RouteContext(GetHttpContext(verb));
|
|
||||||
routeContext.RouteData.Values = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "controller", "MixedRpcAndRest" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await InvokeActionSelector(routeContext);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(verb, result.Name, StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("PUT")]
|
|
||||||
[InlineData("DELETE")]
|
|
||||||
[InlineData("PATCH")]
|
|
||||||
public async Task ActionSelection_IndexNotSelectedByDefaultExceptGetAndPostVerbs(string verb)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var routeContext = new RouteContext(GetHttpContext(verb));
|
|
||||||
routeContext.RouteData.Values = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "controller", "RpcOnly" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await InvokeActionSelector(routeContext);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(null, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("HEAD")]
|
|
||||||
[InlineData("OPTIONS")]
|
|
||||||
public async Task ActionSelection_NoConventionBasedRoutingForHeadAndOptions(string verb)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var routeContext = new RouteContext(GetHttpContext(verb));
|
|
||||||
routeContext.RouteData.Values = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "controller", "MixedRpcAndRest" },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await InvokeActionSelector(routeContext);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(null, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("HEAD")]
|
|
||||||
[InlineData("OPTIONS")]
|
|
||||||
public async Task ActionSelection_ActionNameBasedRoutingForHeadAndOptions(string verb)
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var routeContext = new RouteContext(GetHttpContext(verb));
|
|
||||||
routeContext.RouteData.Values = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "controller", "MixedRpcAndRest" },
|
|
||||||
{ "action", verb },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await InvokeActionSelector(routeContext);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(verb, result.Name, StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ActionSelection_ChangeDefaultConventionPicksCustomMethodForPost_DefaultMethodIsSelectedForGet()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var routeContext = new RouteContext(GetHttpContext("GET"));
|
|
||||||
routeContext.RouteData.Values = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "controller", "RpcOnly" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await InvokeActionSelector(routeContext, new CustomActionConvention());
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal("INDEX", result.Name, StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ActionSelection_ChangeDefaultConventionPicksCustomMethodForPost_CutomMethodIsSelected()
|
public async Task ActionSelection_ChangeDefaultConventionPicksCustomMethodForPost_CutomMethodIsSelected()
|
||||||
{
|
{
|
||||||
|
|
@ -177,12 +82,9 @@ namespace Microsoft.AspNet.Mvc
|
||||||
var actionCollectionDescriptorProvider = new DefaultActionDescriptorsCollectionProvider(serviceContainer);
|
var actionCollectionDescriptorProvider = new DefaultActionDescriptorsCollectionProvider(serviceContainer);
|
||||||
var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionCollectionDescriptorProvider);
|
var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionCollectionDescriptorProvider);
|
||||||
|
|
||||||
var bindingProvider = new Mock<IActionBindingContextProvider>();
|
|
||||||
|
|
||||||
var defaultActionSelector = new DefaultActionSelector(
|
var defaultActionSelector = new DefaultActionSelector(
|
||||||
actionCollectionDescriptorProvider,
|
actionCollectionDescriptorProvider,
|
||||||
decisionTreeProvider,
|
decisionTreeProvider,
|
||||||
bindingProvider.Object,
|
|
||||||
NullLoggerFactory.Instance);
|
NullLoggerFactory.Instance);
|
||||||
|
|
||||||
return await defaultActionSelector.SelectAsync(context);
|
return await defaultActionSelector.SelectAsync(context);
|
||||||
|
|
@ -222,64 +124,19 @@ namespace Microsoft.AspNet.Mvc
|
||||||
.Contains(typeInfo);
|
.Contains(typeInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override IEnumerable<string> GetSupportedHttpMethods(MethodInfo methodInfo)
|
public override IEnumerable<ActionInfo> GetActions([NotNull]MethodInfo methodInfo, [NotNull]TypeInfo controllerTypeInfo)
|
||||||
{
|
{
|
||||||
if (methodInfo.Name.Equals("PostSomething", StringComparison.OrdinalIgnoreCase))
|
var actions = new List<ActionInfo>(
|
||||||
|
base.GetActions(methodInfo, controllerTypeInfo) ??
|
||||||
|
new List<ActionInfo>());
|
||||||
|
|
||||||
|
if (methodInfo.Name == "PostSomething")
|
||||||
{
|
{
|
||||||
return new[] { "POST" };
|
actions[0].HttpMethods = new string[] { "POST" };
|
||||||
|
actions[0].RequireActionNameMatch = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return actions;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class MixedRpcAndRestController
|
|
||||||
{
|
|
||||||
public void Index()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Get()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Post()
|
|
||||||
{ }
|
|
||||||
|
|
||||||
public void GetSomething()
|
|
||||||
{ }
|
|
||||||
|
|
||||||
// This will be treated as an RPC method.
|
|
||||||
public void Head()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
// This will be treated as an RPC method.
|
|
||||||
public void Options()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class RestOnlyController
|
|
||||||
{
|
|
||||||
public void Get()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Put()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Post()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Delete()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Patch()
|
|
||||||
{
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ namespace Microsoft.AspNet.Mvc
|
||||||
Assert.Empty(values.ActionsMatchingRouteConstraints);
|
Assert.Empty(values.ActionsMatchingRouteConstraints);
|
||||||
Assert.Empty(values.ActionsMatchingRouteAndMethodConstraints);
|
Assert.Empty(values.ActionsMatchingRouteAndMethodConstraints);
|
||||||
Assert.Empty(values.ActionsMatchingRouteAndMethodAndDynamicConstraints);
|
Assert.Empty(values.ActionsMatchingRouteAndMethodAndDynamicConstraints);
|
||||||
Assert.Empty(values.ActionsMatchingWithConstraints);
|
Assert.Empty(values.FinalMatches);
|
||||||
Assert.Null(values.SelectedAction);
|
Assert.Null(values.SelectedAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,12 +106,60 @@ namespace Microsoft.AspNet.Mvc
|
||||||
Assert.Equal("DefaultActionSelector.SelectAsync", write.Scope);
|
Assert.Equal("DefaultActionSelector.SelectAsync", write.Scope);
|
||||||
var values = Assert.IsType<DefaultActionSelectorSelectAsyncValues>(write.State);
|
var values = Assert.IsType<DefaultActionSelectorSelectAsyncValues>(write.State);
|
||||||
Assert.Equal("DefaultActionSelector.SelectAsync", values.Name);
|
Assert.Equal("DefaultActionSelector.SelectAsync", values.Name);
|
||||||
Assert.NotEmpty(values.ActionsMatchingRouteConstraints);
|
Assert.Equal<ActionDescriptor>(actions, values.ActionsMatchingRouteConstraints);
|
||||||
Assert.NotEmpty(values.ActionsMatchingRouteAndMethodConstraints);
|
Assert.Equal<ActionDescriptor>(actions, values.ActionsMatchingRouteAndMethodConstraints);
|
||||||
Assert.NotEmpty(values.ActionsMatchingWithConstraints);
|
Assert.Equal(matched, Assert.Single(values.FinalMatches));
|
||||||
Assert.Equal(matched, values.SelectedAction);
|
Assert.Equal(matched, values.SelectedAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async void SelectAsync_AmbiguousActions_LogIsCorrect()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var sink = new TestSink();
|
||||||
|
var loggerFactory = new TestLoggerFactory(sink);
|
||||||
|
|
||||||
|
var actions = new ActionDescriptor[]
|
||||||
|
{
|
||||||
|
new ActionDescriptor() { DisplayName = "A1" },
|
||||||
|
new ActionDescriptor() { DisplayName = "A2" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var selector = CreateSelector(actions, loggerFactory);
|
||||||
|
|
||||||
|
var routeContext = CreateRouteContext("POST");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await Assert.ThrowsAsync<AmbiguousActionException>(async () =>
|
||||||
|
{
|
||||||
|
await selector.SelectAsync(routeContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(1, sink.Scopes.Count);
|
||||||
|
var scope = sink.Scopes[0];
|
||||||
|
Assert.Equal(typeof(DefaultActionSelector).FullName, scope.LoggerName);
|
||||||
|
Assert.Equal("DefaultActionSelector.SelectAsync", scope.Scope);
|
||||||
|
|
||||||
|
// There is a record for IsEnabled and one for WriteCore.
|
||||||
|
Assert.Equal(2, sink.Writes.Count);
|
||||||
|
|
||||||
|
var enabled = sink.Writes[0];
|
||||||
|
Assert.Equal(typeof(DefaultActionSelector).FullName, enabled.LoggerName);
|
||||||
|
Assert.Equal("DefaultActionSelector.SelectAsync", enabled.Scope);
|
||||||
|
Assert.Null(enabled.State);
|
||||||
|
|
||||||
|
var write = sink.Writes[1];
|
||||||
|
Assert.Equal(typeof(DefaultActionSelector).FullName, write.LoggerName);
|
||||||
|
Assert.Equal("DefaultActionSelector.SelectAsync", write.Scope);
|
||||||
|
var values = Assert.IsType<DefaultActionSelectorSelectAsyncValues>(write.State);
|
||||||
|
Assert.Equal("DefaultActionSelector.SelectAsync", values.Name);
|
||||||
|
Assert.Equal<ActionDescriptor>(actions, values.ActionsMatchingRouteConstraints);
|
||||||
|
Assert.Equal<ActionDescriptor>(actions, values.ActionsMatchingRouteAndMethodConstraints);
|
||||||
|
Assert.Equal<ActionDescriptor>(actions, values.FinalMatches);
|
||||||
|
Assert.Null(values.SelectedAction);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HasValidAction_Match()
|
public void HasValidAction_Match()
|
||||||
{
|
{
|
||||||
|
|
@ -262,6 +310,43 @@ namespace Microsoft.AspNet.Mvc
|
||||||
Assert.Null(action);
|
Assert.Null(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SelectAsync_Ambiguous()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var expectedMessage =
|
||||||
|
"Multiple actions matched. " +
|
||||||
|
"The following actions matched route data and had all constraints satisfied:" + Environment.NewLine +
|
||||||
|
Environment.NewLine +
|
||||||
|
"Ambiguous1" + Environment.NewLine +
|
||||||
|
"Ambiguous2";
|
||||||
|
|
||||||
|
var actions = new ActionDescriptor[]
|
||||||
|
{
|
||||||
|
CreateAction(area: null, controller: "Store", action: "Buy"),
|
||||||
|
CreateAction(area: null, controller: "Store", action: "Buy"),
|
||||||
|
CreateAction(area: null, controller: "Store", action: "Cart"),
|
||||||
|
};
|
||||||
|
|
||||||
|
actions[0].DisplayName = "Ambiguous1";
|
||||||
|
actions[1].DisplayName = "Ambiguous2";
|
||||||
|
|
||||||
|
var selector = CreateSelector(actions);
|
||||||
|
var context = CreateRouteContext("GET");
|
||||||
|
|
||||||
|
context.RouteData.Values.Add("controller", "Store");
|
||||||
|
context.RouteData.Values.Add("action", "Buy");
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var ex = await Assert.ThrowsAsync<AmbiguousActionException>(async () =>
|
||||||
|
{
|
||||||
|
await selector.SelectAsync(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(expectedMessage, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
private static ActionDescriptor[] GetActions()
|
private static ActionDescriptor[] GetActions()
|
||||||
{
|
{
|
||||||
return new ActionDescriptor[]
|
return new ActionDescriptor[]
|
||||||
|
|
@ -304,12 +389,7 @@ namespace Microsoft.AspNet.Mvc
|
||||||
|
|
||||||
var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionProvider.Object);
|
var decisionTreeProvider = new ActionSelectorDecisionTreeProvider(actionProvider.Object);
|
||||||
|
|
||||||
var bindingProvider = new Mock<IActionBindingContextProvider>(MockBehavior.Strict);
|
return new DefaultActionSelector(actionProvider.Object, decisionTreeProvider, loggerFactory);
|
||||||
bindingProvider
|
|
||||||
.Setup(bp => bp.GetActionBindingContextAsync(It.IsAny<ActionContext>()))
|
|
||||||
.Returns(Task.FromResult<ActionBindingContext>(null));
|
|
||||||
|
|
||||||
return new DefaultActionSelector(actionProvider.Object, decisionTreeProvider, bindingProvider.Object, loggerFactory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static VirtualPathContext CreateContext(object routeValues)
|
private static VirtualPathContext CreateContext(object routeValues)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue