diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/ApiController.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/ApiController.cs
index c9f8d3fe4c..45b8c0a1f1 100644
--- a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/ApiController.cs
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/ApiController.cs
@@ -5,9 +5,12 @@ using System.Security.Principal;
using Microsoft.AspNet.Http;
using Microsoft.AspNet.Mvc;
using Microsoft.AspNet.Mvc.ModelBinding;
+using Microsoft.AspNet.Mvc.WebApiCompatShim;
namespace System.Web.Http
{
+ [UseWebApiActionConventions]
+ [UseWebApiOverloading]
public abstract class ApiController : IDisposable
{
/// Gets the action context.
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/IUseWebApiActionConventions.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/IUseWebApiActionConventions.cs
new file mode 100644
index 0000000000..52ec587231
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/IUseWebApiActionConventions.cs
@@ -0,0 +1,9 @@
+// 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.
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ public interface IUseWebApiActionConventions
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/IUseWebApiOverloading.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/IUseWebApiOverloading.cs
new file mode 100644
index 0000000000..04e0ef4e13
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/IUseWebApiOverloading.cs
@@ -0,0 +1,9 @@
+// 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.
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ public interface IUseWebApiOverloading
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/UseWebApiActionConventionsAttribute.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/UseWebApiActionConventionsAttribute.cs
new file mode 100644
index 0000000000..6a055894f3
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/UseWebApiActionConventionsAttribute.cs
@@ -0,0 +1,12 @@
+// 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;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
+ public class UseWebApiActionConventionsAttribute : Attribute, IUseWebApiActionConventions
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/UseWebApiOverloadingAttribute.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/UseWebApiOverloadingAttribute.cs
new file mode 100644
index 0000000000..84565cce3d
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/UseWebApiOverloadingAttribute.cs
@@ -0,0 +1,12 @@
+// 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;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
+ public class UseWebApiOverloadingAttribute : Attribute, IUseWebApiOverloading
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/WebApiActionConventionsGlobalModelConvention.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/WebApiActionConventionsGlobalModelConvention.cs
new file mode 100644
index 0000000000..c52d92b6b7
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/WebApiActionConventionsGlobalModelConvention.cs
@@ -0,0 +1,95 @@
+// 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.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNet.Mvc.ApplicationModel;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ public class WebApiActionConventionsGlobalModelConvention : IGlobalModelConvention
+ {
+ private static readonly string[] SupportedHttpMethodConventions = new string[]
+ {
+ "GET",
+ "PUT",
+ "POST",
+ "DELETE",
+ "PATCH",
+ "HEAD",
+ "OPTIONS",
+ };
+
+ public void Apply(GlobalModel model)
+ {
+ foreach (var controller in model.Controllers)
+ {
+ if (IsConventionApplicable(controller))
+ {
+ Apply(controller);
+ }
+ }
+ }
+
+ private bool IsConventionApplicable(ControllerModel controller)
+ {
+ return controller.Attributes.OfType().Any();
+ }
+
+ private void Apply(ControllerModel model)
+ {
+ var newActions = new List();
+
+ foreach (var action in model.Actions)
+ {
+ SetHttpMethodFromConvention(action);
+
+ // Action Name doesn't really come into play with attribute routed actions. However for a
+ // non-attribute-routed action we need to create a 'named' version and an 'unnamed' version.
+ if (!IsActionAttributeRouted(action))
+ {
+ var namedAction = action;
+
+ var unnamedAction = new ActionModel(namedAction);
+ unnamedAction.IsActionNameMatchRequired = false;
+ newActions.Add(unnamedAction);
+ }
+ }
+
+ model.Actions.AddRange(newActions);
+ }
+
+ private bool IsActionAttributeRouted(ActionModel action)
+ {
+ if (action.Controller.AttributeRoutes.Count > 0)
+ {
+ return true;
+ }
+
+ return action.AttributeRouteModel?.Template != null;
+ }
+
+ private void SetHttpMethodFromConvention(ActionModel action)
+ {
+ if (action.HttpMethods.Count > 0)
+ {
+ // If the HttpMethods are set from attributes, don't override it with the convention
+ return;
+ }
+
+ // The Method name is used to infer verb constraints. Changing the action name has not impact.
+ foreach (var verb in SupportedHttpMethodConventions)
+ {
+ if (action.ActionMethod.Name.StartsWith(verb, StringComparison.OrdinalIgnoreCase))
+ {
+ action.HttpMethods.Add(verb);
+ return;
+ }
+ }
+
+ // If no convention matches, then assume POST
+ action.HttpMethods.Add("POST");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/WebApiOverloadingGlobalModelConvention.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/WebApiOverloadingGlobalModelConvention.cs
new file mode 100644
index 0000000000..8158788989
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Conventions/WebApiOverloadingGlobalModelConvention.cs
@@ -0,0 +1,35 @@
+// 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.Linq;
+using Microsoft.AspNet.Mvc.ApplicationModel;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ public class WebApiOverloadingGlobalModelConvention : IGlobalModelConvention
+ {
+ public void Apply(GlobalModel model)
+ {
+ foreach (var controller in model.Controllers)
+ {
+ if (IsConventionApplicable(controller))
+ {
+ Apply(controller);
+ }
+ }
+ }
+
+ private bool IsConventionApplicable(ControllerModel controller)
+ {
+ return controller.Attributes.OfType().Any();
+ }
+
+ private void Apply(ControllerModel model)
+ {
+ foreach (var action in model.Actions)
+ {
+ action.ActionConstraints.Add(new OverloadActionConstraint());
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/FormDataCollectionExtensions.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/FormDataCollectionExtensions.cs
new file mode 100644
index 0000000000..5a5616da3e
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/FormDataCollectionExtensions.cs
@@ -0,0 +1,109 @@
+// 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.Collections.Generic;
+using System.Net.Http.Formatting;
+using System.Text;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ public static class FormDataCollectionExtensions
+ {
+ // This is a helper method to use Model Binding over a JQuery syntax.
+ // Normalize from JQuery to MVC keys. The model binding infrastructure uses MVC keys
+ // x[] --> x
+ // [] --> ""
+ // x[field] --> x.field, where field is not a number
+ public static string NormalizeJQueryToMvc(string key)
+ {
+ if (key == null)
+ {
+ return string.Empty;
+ }
+
+ StringBuilder sb = null;
+ var i = 0;
+ while (true)
+ {
+ int indexOpen = key.IndexOf('[', i);
+ if (indexOpen < 0)
+ {
+ // Fast path, no normalization needed.
+ // This skips the string conversion and allocating the string builder.
+ if (i == 0)
+ {
+ return key;
+ }
+ sb = sb ?? new StringBuilder();
+ sb.Append(key, i, key.Length - i);
+ break; // no more brackets
+ }
+
+ sb = sb ?? new StringBuilder();
+ sb.Append(key, i, indexOpen - i); // everything up to "["
+
+ // Find closing bracket.
+ var indexClose = key.IndexOf(']', indexOpen);
+ if (indexClose == -1)
+ {
+ throw new ArgumentException(Resources.JQuerySyntaxMissingClosingBracket, "key");
+ }
+
+ if (indexClose == indexOpen + 1)
+ {
+ // Empty bracket. Signifies array. Just remove.
+ }
+ else
+ {
+ if (char.IsDigit(key[indexOpen + 1]))
+ {
+ // array index. Leave unchanged.
+ sb.Append(key, indexOpen, indexClose - indexOpen + 1);
+ }
+ else
+ {
+ // Field name. Convert to dot notation.
+ sb.Append('.');
+ sb.Append(key, indexOpen + 1, indexClose - indexOpen - 1);
+ }
+ }
+
+ i = indexClose + 1;
+ if (i >= key.Length)
+ {
+ break; // end of string
+ }
+ }
+ return sb.ToString();
+ }
+
+ public static IEnumerable> GetJQueryNameValuePairs(
+ [NotNull] this FormDataCollection formData)
+ {
+ var count = 0;
+
+ foreach (var kv in formData)
+ {
+ ThrowIfMaxHttpCollectionKeysExceeded(count);
+
+ var key = NormalizeJQueryToMvc(kv.Key);
+ var value = kv.Value ?? string.Empty;
+ yield return new KeyValuePair(key, value);
+
+ count++;
+ }
+ }
+
+ private static void ThrowIfMaxHttpCollectionKeysExceeded(int count)
+ {
+ if (count >= MediaTypeFormatter.MaxHttpCollectionKeys)
+ {
+ var message = Resources.FormatMaxHttpCollectionKeyLimitReached(
+ MediaTypeFormatter.MaxHttpCollectionKeys,
+ typeof(MediaTypeFormatter));
+ throw new InvalidOperationException(message);
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/FromUriAttribute.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/FromUriAttribute.cs
new file mode 100644
index 0000000000..df8c80c42e
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/FromUriAttribute.cs
@@ -0,0 +1,22 @@
+// 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 Microsoft.AspNet.Mvc.ApplicationModel;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
+ public class FromUriAttribute : Attribute, IParameterModelConvention
+ {
+ public string Name { get; set; }
+
+ public void Apply(ParameterModel model)
+ {
+ if (Name != null)
+ {
+ model.ParameterName = Name;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/OverloadActionConstraint.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/OverloadActionConstraint.cs
new file mode 100644
index 0000000000..6abed0240d
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/OverloadActionConstraint.cs
@@ -0,0 +1,125 @@
+// 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.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNet.Mvc.ModelBinding;
+using Microsoft.AspNet.Routing;
+using System.Net.Http.Formatting;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ public class OverloadActionConstraint : IActionConstraint
+ {
+ public int Order { get; } = Int32.MaxValue;
+
+ public bool Accept(ActionConstraintContext context)
+ {
+ var candidates = context.Candidates.Select(c => new
+ {
+ Action = c,
+ Parameters = GetOverloadableParameters(c),
+ });
+
+ // Combined route value keys and query string keys. These are the values available for overload selection.
+ var requestKeys = GetCombinedKeys(context.RouteContext);
+
+ // Group candidates by the highest number of keys, and then process them until we find an action
+ // with all parameters satisfied.
+ foreach (var group in candidates.GroupBy(c => c.Parameters?.Count ?? 0).OrderByDescending(g => g.Key))
+ {
+ var foundMatch = false;
+ foreach (var candidate in group)
+ {
+ var allFound = true;
+ if (candidate.Parameters != null)
+ {
+ foreach (var parameter in candidate.Parameters)
+ {
+ if (!requestKeys.Contains(parameter.ParameterBindingInfo.Prefix))
+ {
+ if (candidate.Action.Action == context.CurrentCandidate.Action)
+ {
+ return false;
+ }
+
+ allFound = false;
+ break;
+ }
+ }
+ }
+
+ if (allFound)
+ {
+ foundMatch = true;
+ }
+ }
+
+ if (foundMatch)
+ {
+ return group.Any(c => c.Action.Action == context.CurrentCandidate.Action);
+ }
+ }
+
+ return false;
+ }
+
+ private List GetOverloadableParameters(ActionSelectorCandidate candidate)
+ {
+ if (candidate.Action.Parameters == null)
+ {
+ return null;
+ }
+
+ var isOverloaded = false;
+ foreach (var constraint in candidate.Constraints)
+ {
+ if (constraint is OverloadActionConstraint)
+ {
+ isOverloaded = true;
+ }
+ }
+
+ if (!isOverloaded)
+ {
+ return null;
+ }
+
+ // We only consider parameters that are bound from the URL.
+ return candidate.Action.Parameters.Where(
+ p =>
+ p.ParameterBindingInfo != null &&
+ !p.IsOptional &&
+ ValueProviderResult.CanConvertFromString(p.ParameterBindingInfo.ParameterType))
+ .ToList();
+ }
+
+ private static ISet GetCombinedKeys(RouteContext routeContext)
+ {
+ var keys = new HashSet(routeContext.RouteData.Values.Keys, StringComparer.OrdinalIgnoreCase);
+ keys.Remove("controller");
+ keys.Remove("action");
+
+ var queryString = routeContext.HttpContext.Request.QueryString.ToUriComponent();
+
+ if (queryString.Length > 0)
+ {
+ // We need to chop off the leading '?'
+ var queryData = new FormDataCollection(queryString.Substring(1));
+
+ var queryNameValuePairs = queryData.GetJQueryNameValuePairs();
+
+ if (queryNameValuePairs != null)
+ {
+ foreach (var queryNameValuePair in queryNameValuePairs)
+ {
+ keys.Add(queryNameValuePair.Key);
+ }
+ }
+ }
+
+ return keys;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Properties/Resources.Designer.cs
new file mode 100644
index 0000000000..ee2edffaaf
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Properties/Resources.Designer.cs
@@ -0,0 +1,62 @@
+//
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ using System.Globalization;
+ using System.Reflection;
+ using System.Resources;
+
+ internal static class Resources
+ {
+ private static readonly ResourceManager _resourceManager
+ = new ResourceManager("Microsoft.AspNet.Mvc.WebApiCompatShim.Resources", typeof(Resources).GetTypeInfo().Assembly);
+
+ ///
+ /// The key is invalid JQuery syntax because it is missing a closing bracket.
+ ///
+ internal static string JQuerySyntaxMissingClosingBracket
+ {
+ get { return GetString("JQuerySyntaxMissingClosingBracket"); }
+ }
+
+ ///
+ /// The key is invalid JQuery syntax because it is missing a closing bracket.
+ ///
+ internal static string FormatJQuerySyntaxMissingClosingBracket()
+ {
+ return GetString("JQuerySyntaxMissingClosingBracket");
+ }
+
+ ///
+ /// The number of keys in a NameValueCollection has exceeded the limit of '{0}'. You can adjust it by modifying the MaxHttpCollectionKeys property on the '{1}' class.
+ ///
+ internal static string MaxHttpCollectionKeyLimitReached
+ {
+ get { return GetString("MaxHttpCollectionKeyLimitReached"); }
+ }
+
+ ///
+ /// The number of keys in a NameValueCollection has exceeded the limit of '{0}'. You can adjust it by modifying the MaxHttpCollectionKeys property on the '{1}' class.
+ ///
+ internal static string FormatMaxHttpCollectionKeyLimitReached(object p0, object p1)
+ {
+ return string.Format(CultureInfo.CurrentCulture, GetString("MaxHttpCollectionKeyLimitReached"), p0, p1);
+ }
+
+ private static string GetString(string name, params string[] formatterNames)
+ {
+ var value = _resourceManager.GetString(name);
+
+ System.Diagnostics.Debug.Assert(value != null);
+
+ if (formatterNames != null)
+ {
+ for (var i = 0; i < formatterNames.Length; i++)
+ {
+ value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}");
+ }
+ }
+
+ return value;
+ }
+ }
+}
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Resources.resx b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Resources.resx
new file mode 100644
index 0000000000..00afe5899e
--- /dev/null
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/Resources.resx
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ The key is invalid JQuery syntax because it is missing a closing bracket.
+
+
+ The number of keys in a NameValueCollection has exceeded the limit of '{0}'. You can adjust it by modifying the MaxHttpCollectionKeys property on the '{1}' class.
+
+
\ No newline at end of file
diff --git a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/WebApiCompatShimOptionsSetup.cs b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/WebApiCompatShimOptionsSetup.cs
index e2f837828b..3a1e0adfdc 100644
--- a/src/Microsoft.AspNet.Mvc.WebApiCompatShim/WebApiCompatShimOptionsSetup.cs
+++ b/src/Microsoft.AspNet.Mvc.WebApiCompatShim/WebApiCompatShimOptionsSetup.cs
@@ -18,7 +18,9 @@ namespace Microsoft.AspNet.Mvc.WebApiCompatShim
public void Invoke(MvcOptions options)
{
- // Placeholder
+ // Add webapi behaviors to controllers with the appropriate attributes
+ options.ApplicationModelConventions.Add(new WebApiActionConventionsGlobalModelConvention());
+ options.ApplicationModelConventions.Add(new WebApiOverloadingGlobalModelConvention());
}
public void Invoke(WebApiCompatShimOptions options)
diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/WebApiCompatShimActionSelectionTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/WebApiCompatShimActionSelectionTest.cs
new file mode 100644
index 0000000000..93072ae413
--- /dev/null
+++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/WebApiCompatShimActionSelectionTest.cs
@@ -0,0 +1,627 @@
+// 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.
+
+#if ASPNET50
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.AspNet.Builder;
+using Microsoft.AspNet.TestHost;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace Microsoft.AspNet.Mvc.FunctionalTests
+{
+ public class WebApiCompatShimActionSelectionTest
+ {
+ private readonly IServiceProvider _services = TestHelper.CreateServices(nameof(WebApiCompatShimWebSite));
+ private readonly Action _app = new WebApiCompatShimWebSite.Startup().Configure;
+
+ [Theory]
+ [InlineData("GET", "GetItems")]
+ [InlineData("PUT", "PutItems")]
+ [InlineData("POST", "PostItems")]
+ [InlineData("DELETE", "DeleteItems")]
+ [InlineData("PATCH", "PatchItems")]
+ [InlineData("HEAD", "HeadItems")]
+ [InlineData("OPTIONS", "OptionsItems")]
+ public async Task WebAPIConvention_TakesHttpMethodFromPrefix_UnnamedAction(string httpMethod, string actionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod(httpMethod),
+ "http://localhost/api/Admin/WebAPIActionConventions");
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(actionName, result.ActionName);
+ }
+
+ [Theory]
+ [InlineData("GET", "GetItems")]
+ [InlineData("PUT", "PutItems")]
+ [InlineData("POST", "PostItems")]
+ [InlineData("DELETE", "DeleteItems")]
+ [InlineData("PATCH", "PatchItems")]
+ [InlineData("HEAD", "HeadItems")]
+ [InlineData("OPTIONS", "OptionsItems")]
+ public async Task WebAPIConvention_TakesHttpMethodFromPrefix_NamedAction(string httpMethod, string actionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod(httpMethod),
+ "http://localhost/api/Blog/WebAPIActionConventions/" + actionName);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(actionName, result.ActionName);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromPrefix_NamedAction_MismatchedVerb()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("POST"),
+ "http://localhost/api/Blog/WebAPIActionConventions/GetItems");
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromPrefix_UnnamedAction_DefaultVerbIsPost_Success()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("POST"),
+ "http://localhost/api/Admin/WebApiActionConventionsDefaultPost");
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("DefaultVerbIsPost", result.ActionName);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromPrefix_NamedAction_DefaultVerbIsPost_Success()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("POST"),
+ "http://localhost/api/Blog/WebAPIActionConventionsDefaultPost/DefaultVerbIsPost");
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("DefaultVerbIsPost", result.ActionName);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromPrefix_UnnamedAction_DefaultVerbIsPost_VerbMismatch()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("GET"),
+ "http://localhost/api/Admin/WebApiActionConventionsDefaultPost");
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromPrefix_NamedAction_DefaultVerbIsPost_VerbMismatch()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("PUT"),
+ "http://localhost/api/Blog/WebApiActionConventionsDefaultPost/DefaultVerbIsPost");
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromMethodName_NotActionName_UnnamedAction_Success()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("POST"),
+ "http://localhost/api/Admin/WebAPIActionConventionsActionName");
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("GetItems", result.ActionName);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromMethodName_NotActionName_NamedAction_Success()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("POST"),
+ "http://localhost/api/Blog/WebAPIActionConventionsActionName/GetItems");
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("GetItems", result.ActionName);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromMethodName_NotActionName_UnnamedAction_VerbMismatch()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("Get"),
+ "http://localhost/api/Admin/WebAPIActionConventionsActionName");
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_TakesHttpMethodFromMethodName_NotActionName_NamedAction_VerbMismatch()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("GET"),
+ "http://localhost/api/Blog/WebAPIActionConventionsActionName/GetItems");
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_HttpMethodOverride_UnnamedAction_Success()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("GET"),
+ "http://localhost/api/Admin/WebAPIActionConventionsVerbOverride");
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("PostItems", result.ActionName);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_HttpMethodOverride_NamedAction_Success()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("GET"),
+ "http://localhost/api/Blog/WebAPIActionConventionsVerbOverride/PostItems");
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("PostItems", result.ActionName);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_HttpMethodOverride_UnnamedAction_VerbMismatch()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("POST"),
+ "http://localhost/api/Admin/WebAPIActionConventionsVerbOverride");
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task WebAPIConvention_HttpMethodOverride_NamedAction_VerbMismatch()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(
+ new HttpMethod("POST"),
+ "http://localhost/api/Blog/WebAPIActionConventionsVerbOverride/PostItems");
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ // This was ported from the WebAPI 5.2 codebase. Kept the same intentionally for compatability.
+ [Theory]
+ [InlineData("GET", "api/Admin/Test", "GetUsers")]
+ [InlineData("GET", "api/Admin/Test/2", "GetUser")]
+ [InlineData("GET", "api/Admin/Test/3?name=mario", "GetUserByNameAndId")]
+ [InlineData("GET", "api/Admin/Test/3?name=mario&ssn=123456", "GetUserByNameIdAndSsn")]
+ [InlineData("GET", "api/Admin/Test?name=mario&ssn=123456", "GetUserByNameAndSsn")]
+ [InlineData("GET", "api/Admin/Test?name=mario&ssn=123456&age=3", "GetUserByNameAgeAndSsn")]
+ [InlineData("GET", "api/Admin/Test/5?random=9", "GetUser")]
+ [InlineData("POST", "api/Admin/Test", "PostUser")]
+ [InlineData("POST", "api/Admin/Test?name=mario&age=10", "PostUserByNameAndAge")]
+
+ // Note: Normally the following would not match DeleteUserByIdAndOptName because it has 'id' and 'age' as parameters while the DeleteUserByIdAndOptName action has 'id' and 'name'.
+ // However, because the default value is provided on action parameter 'name', having the 'id' in the request was enough to match the action.
+ [InlineData("DELETE", "api/Admin/Test/6?age=10", "DeleteUserByIdAndOptName")]
+ [InlineData("DELETE", "api/Admin/Test", "DeleteUserByOptName")]
+ [InlineData("DELETE", "api/Admin/Test?name=user", "DeleteUserByOptName")]
+ [InlineData("DELETE", "api/Admin/Test/6?email=user@test.com", "DeleteUserById_Email_OptName_OptPhone")]
+ [InlineData("DELETE", "api/Admin/Test/6?email=user@test.com&name=user", "DeleteUserById_Email_OptName_OptPhone")]
+ [InlineData("DELETE", "api/Admin/Test/6?email=user@test.com&name=user&phone=123456789", "DeleteUserById_Email_OptName_OptPhone")]
+ [InlineData("DELETE", "api/Admin/Test/6?email=user@test.com&height=1.8", "DeleteUserById_Email_Height_OptName_OptPhone")]
+ [InlineData("DELETE", "api/Admin/Test/6?email=user@test.com&height=1.8&name=user", "DeleteUserById_Email_Height_OptName_OptPhone")]
+ [InlineData("DELETE", "api/Admin/Test/6?email=user@test.com&height=1.8&name=user&phone=12345678", "DeleteUserById_Email_Height_OptName_OptPhone")]
+ [InlineData("HEAD", "api/Admin/Test/6", "Head_Id_OptSize_OptIndex")]
+ [InlineData("HEAD", "api/Admin/Test/6?size=2", "Head_Id_OptSize_OptIndex")]
+ [InlineData("HEAD", "api/Admin/Test/6?index=2", "Head_Id_OptSize_OptIndex")]
+ [InlineData("HEAD", "api/Admin/Test/6?index=2&size=10", "Head_Id_OptSize_OptIndex")]
+ [InlineData("HEAD", "api/Admin/Test/6?index=2&otherParameter=10", "Head_Id_OptSize_OptIndex")]
+ [InlineData("HEAD", "api/Admin/Test/6?otherQueryParameter=1234", "Head_Id_OptSize_OptIndex")]
+ [InlineData("HEAD", "api/Admin/Test", "Head")]
+ [InlineData("HEAD", "api/Admin/Test?otherParam=2", "Head")]
+ [InlineData("HEAD", "api/Admin/Test?index=2&size=10", "Head")]
+ public async Task LegacyActionSelection_OverloadedAction_WithUnnamedAction(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+ [Theory]
+ [InlineData("GET", "api/Store/Test", "GetUsers")]
+ [InlineData("GET", "api/Store/Test/2", "GetUsersByName")]
+ [InlineData("GET", "api/Store/Test/luigi?ssn=123456", "GetUserByNameAndSsn")]
+ [InlineData("GET", "api/Store/Test/luigi?ssn=123456&id=2&ssn=12345", "GetUserByNameIdAndSsn")]
+ [InlineData("GET", "api/Store/Test?age=10&ssn=123456", "GetUsers")]
+ [InlineData("GET", "api/Store/Test?id=3&ssn=123456&name=luigi", "GetUserByNameIdAndSsn")]
+ [InlineData("POST", "api/Store/Test/luigi?age=20", "PostUserByNameAndAge")]
+ public async Task LegacyActionSelection_OverloadedAction_NonIdRouteParameter(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+ [Theory]
+ [InlineData("GET", "api/Admin/Test/3?NAME=mario", "GetUserByNameAndId")]
+ [InlineData("GET", "api/Admin/Test/3?name=mario&SSN=123456", "GetUserByNameIdAndSsn")]
+ [InlineData("GET", "api/Admin/Test?nAmE=mario&ssn=123456&AgE=3", "GetUserByNameAgeAndSsn")]
+ [InlineData("DELETE", "api/Admin/Test/6?AGe=10", "DeleteUserByIdAndOptName")]
+ public async Task LegacyActionSelection_OverloadedAction_Parameter_Casing(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+ [Theory]
+ [InlineData("GET", "api/Blog/Test/GetUsers", "GetUsers")]
+ [InlineData("GET", "api/Blog/Test/GetUser/7", "GetUser")]
+ [InlineData("GET", "api/Blog/Test/GetUser?id=3", "GetUser")]
+ [InlineData("GET", "api/Blog/Test/GetUser/4?id=3", "GetUser")]
+ [InlineData("GET", "api/Blog/Test/GetUserByNameAgeAndSsn?name=user&age=90&ssn=123456789", "GetUserByNameAgeAndSsn")]
+ [InlineData("GET", "api/Blog/Test/GetUserByNameAndSsn?name=user&ssn=123456789", "GetUserByNameAndSsn")]
+ [InlineData("POST", "api/Blog/Test/PostUserByNameAndAddress?name=user", "PostUserByNameAndAddress")]
+ public async Task LegacyActionSelection_RouteWithActionName(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+ [Theory]
+ [InlineData("GET", "api/Blog/Test/getusers", "GetUsers")]
+ [InlineData("GET", "api/Blog/Test/getuseR/1", "GetUser")]
+ [InlineData("GET", "api/Blog/Test/Getuser?iD=3", "GetUser")]
+ [InlineData("GET", "api/Blog/Test/GetUser/4?Id=3", "GetUser")]
+ [InlineData("GET", "api/Blog/Test/GetUserByNameAgeandSsn?name=user&age=90&ssn=123456789", "GetUserByNameAgeAndSsn")]
+ [InlineData("GET", "api/Blog/Test/getUserByNameAndSsn?name=user&ssn=123456789", "GetUserByNameAndSsn")]
+ [InlineData("POST", "api/Blog/Test/PostUserByNameAndAddress?name=user", "PostUserByNameAndAddress")]
+ public async Task LegacyActionSelection_RouteWithActionName_Casing(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+ [Theory]
+ [InlineData("GET", "api/Admin/Test", "GetUsers")]
+ [InlineData("GET", "api/Admin/Test/?name=peach", "GetUsersByName")]
+ [InlineData("GET", "api/Admin/Test?name=peach", "GetUsersByName")]
+ [InlineData("GET", "api/Admin/Test?name=peach&ssn=123456", "GetUserByNameAndSsn")]
+ [InlineData("GET", "api/Admin/Test?name=peach&ssn=123456&age=3", "GetUserByNameAgeAndSsn")]
+ public async Task LegacyActionSelection_RouteWithoutActionName(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+
+ [Theory]
+ [InlineData("GET", "api/Admin/ParameterAttribute/2", "GetUser")]
+ [InlineData("GET", "api/Admin/ParameterAttribute?id=2", "GetUser")]
+ [InlineData("GET", "api/Admin/ParameterAttribute?myId=2", "GetUserByMyId")]
+ [InlineData("POST", "api/Admin/ParameterAttribute/3?name=user", "PostUserNameFromUri")]
+ [InlineData("POST", "api/Admin/ParameterAttribute/3", "PostUserNameFromBody")]
+ [InlineData("DELETE", "api/Admin/ParameterAttribute/3?name=user", "DeleteUserWithNullableIdAndName")]
+ [InlineData("DELETE", "api/Admin/ParameterAttribute?address=userStreet", "DeleteUser")]
+ public async Task LegacyActionSelection_ModelBindingParameterAttribute_AreAppliedWhenSelectingActions(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+ [Theory]
+ [InlineData("GET", "api/Support/notActionParameterValue1/Test", "GetUsers")]
+ [InlineData("GET", "api/Support/notActionParameterValue2/Test/2", "GetUser")]
+ [InlineData("GET", "api/Support/notActionParameterValue1/Test?randomQueryVariable=val1", "GetUsers")]
+ [InlineData("GET", "api/Support/notActionParameterValue2/Test/2?randomQueryVariable=val2", "GetUser")]
+ public async Task LegacyActionSelection_ActionsThatHaveSubsetOfRouteParameters_AreConsideredForSelection(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+ // This would result in ambiguous match because complex parameter is not considered for matching.
+ // Therefore, PostUserByNameAndAddress(string name, Address address) would conflicts with PostUserByName(string name)
+ [Fact]
+ public async Task LegacyActionSelection_RequestToAmbiguousAction_OnDefaultRoute()
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod("POST"), "http://localhost/api/Admin/Test?name=mario");
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () => await client.SendAsync(request));
+ }
+
+ [Theory]
+ [InlineData("GET", "api/Admin/EnumParameterOverloads", "Get")]
+ [InlineData("GET", "api/Admin/EnumParameterOverloads?scope=global", "GetWithEnumParameter")]
+ [InlineData("GET", "api/Admin/EnumParameterOverloads?level=off&kind=trace", "GetWithTwoEnumParameters")]
+ [InlineData("GET", "api/Admin/EnumParameterOverloads?level=", "GetWithNullableEnumParameter")]
+ public async Task LegacyActionSelection_SelectAction_ReturnsActionDescriptor_ForEnumParameterOverloads(string httpMethod, string requestUrl, string expectedActionName)
+ {
+ // Arrange
+ var server = TestServer.Create(_services, _app);
+ var client = server.CreateClient();
+
+ var request = new HttpRequestMessage(new HttpMethod(httpMethod), "http://localhost/" + requestUrl);
+
+ // Act
+ var response = await client.SendAsync(request);
+ var body = await response.Content.ReadAsStringAsync();
+
+ var result = JsonConvert.DeserializeObject(body);
+
+ //Assert
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(expectedActionName, result.ActionName);
+ }
+
+ // Verify response has all the methods in its Allow header. values are unsorted.
+ private void AssertAllowedHeaders(HttpResponseMessage response, params HttpMethod[] allowedMethods)
+ {
+ foreach (var method in allowedMethods)
+ {
+ Assert.Contains(method.ToString(), response.Content.Headers.Allow);
+ }
+ Assert.Equal(allowedMethods.Length, response.Content.Headers.Allow.Count);
+ }
+
+ private class ActionSelectionResult
+ {
+ public string ActionName { get; set; }
+
+ public string ControllerName { get; set; }
+ }
+ }
+}
+ #endif
\ No newline at end of file
diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/WebApiCompatShimBasicTest.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/WebApiCompatShimBasicTest.cs
index 9069278dd4..c15ebb13f7 100644
--- a/test/Microsoft.AspNet.Mvc.FunctionalTests/WebApiCompatShimBasicTest.cs
+++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/WebApiCompatShimBasicTest.cs
@@ -26,7 +26,7 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
var client = server.CreateClient();
// Act
- var response = await client.GetAsync("http://localhost/BasicApi/WriteToHttpContext");
+ var response = await client.GetAsync("http://localhost/api/Blog/BasicApi/WriteToHttpContext");
var content = await response.Content.ReadAsStringAsync();
// Assert
@@ -44,13 +44,13 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
var client = server.CreateClient();
// Act
- var response = await client.GetAsync("http://localhost/BasicApi/GenerateUrl");
+ var response = await client.GetAsync("http://localhost/api/Blog/BasicApi/GenerateUrl");
var content = await response.Content.ReadAsStringAsync();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(
- "Visited: /BasicApi/GenerateUrl",
+ "Visited: /api/Blog/BasicApi/GenerateUrl",
content);
}
@@ -69,7 +69,7 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
};
// Act
- var response = await client.GetAsync("http://localhost/BasicApi/GetFormatters");
+ var response = await client.GetAsync("http://localhost/api/Blog/BasicApi/GetFormatters");
var content = await response.Content.ReadAsStringAsync();
var formatters = JsonConvert.DeserializeObject(content);
diff --git a/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/ApiControllerActionDiscoveryTest.cs b/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/ApiControllerActionDiscoveryTest.cs
index f6759bba9c..73a7f9b17c 100644
--- a/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/ApiControllerActionDiscoveryTest.cs
+++ b/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/ApiControllerActionDiscoveryTest.cs
@@ -6,6 +6,7 @@ using System.Linq;
using System.Reflection;
using Microsoft.AspNet.Mvc;
using Microsoft.AspNet.Mvc.Filters;
+using Microsoft.AspNet.Mvc.WebApiCompatShim;
using Microsoft.Framework.DependencyInjection;
using Microsoft.Framework.DependencyInjection.NestedProviders;
using Microsoft.Framework.OptionsModel;
@@ -16,8 +17,6 @@ namespace System.Web.Http
{
public class ApiControllerActionDiscoveryTest
{
- // For now we just want to verify that an ApiController is-a controller and produces
- // actions. When we implement the conventions for action discovery, this test will be revised.
[Fact]
public void GetActions_ApiControllerWithControllerSuffix_IsController()
{
@@ -32,9 +31,9 @@ namespace System.Web.Http
// Assert
var controllerType = typeof(TestControllers.ProductsController).GetTypeInfo();
- var filtered = results.Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType).ToArray();
+ var actions = results.Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType).ToArray();
- Assert.Equal(3, filtered.Length);
+ Assert.NotEmpty(actions);
}
[Fact]
@@ -51,9 +50,179 @@ namespace System.Web.Http
// Assert
var controllerType = typeof(TestControllers.Blog).GetTypeInfo();
- var filtered = results.Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType).ToArray();
+ var actions = results.Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType).ToArray();
- Assert.Empty(filtered);
+ Assert.Empty(actions);
+ }
+
+ [Fact]
+ public void GetActions_CreatesNamedAndUnnamedAction()
+ {
+ // Arrange
+ var provider = CreateProvider();
+
+ // Act
+ var context = new ActionDescriptorProviderContext();
+ provider.Invoke(context);
+
+ var results = context.Results.Cast();
+
+ // Assert
+ var controllerType = typeof(TestControllers.StoreController).GetTypeInfo();
+ var actions = results
+ .Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType)
+ .Where(ad => ad.MethodInfo.Name == "GetAll")
+ .ToArray();
+
+ Assert.Equal(2, actions.Length);
+
+ var action = Assert.Single(
+ actions,
+ a => a.RouteConstraints.Any(rc => rc.RouteKey == "action" && rc.RouteValue == "GetAll"));
+ Assert.Equal(
+ new string[] { "GET" },
+ Assert.Single(action.ActionConstraints.OfType()).HttpMethods);
+
+ action = Assert.Single(
+ actions,
+ a => a.RouteConstraints.Any(rc => rc.RouteKey == "action" && rc.RouteValue == ""));
+ Assert.Equal(
+ new string[] { "GET" },
+ Assert.Single(action.ActionConstraints.OfType()).HttpMethods);
+ }
+
+ [Fact]
+ public void GetActions_CreatesNamedAndUnnamedAction_DefaultVerbIsPost()
+ {
+ // Arrange
+ var provider = CreateProvider();
+
+ // Act
+ var context = new ActionDescriptorProviderContext();
+ provider.Invoke(context);
+
+ var results = context.Results.Cast();
+
+ // Assert
+ var controllerType = typeof(TestControllers.StoreController).GetTypeInfo();
+ var actions = results
+ .Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType)
+ .Where(ad => ad.MethodInfo.Name == "Edit")
+ .ToArray();
+
+ Assert.Equal(2, actions.Length);
+
+ var action = Assert.Single(
+ actions,
+ a => a.RouteConstraints.Any(rc => rc.RouteKey == "action" && rc.RouteValue == "Edit"));
+ Assert.Equal(
+ new string[] { "POST" },
+ Assert.Single(action.ActionConstraints.OfType()).HttpMethods);
+
+ action = Assert.Single(
+ actions,
+ a => a.RouteConstraints.Any(rc => rc.RouteKey == "action" && rc.RouteValue == ""));
+ Assert.Equal(
+ new string[] { "POST" },
+ Assert.Single(action.ActionConstraints.OfType()).HttpMethods);
+ }
+
+ [Fact]
+ public void GetActions_CreatesNamedAndUnnamedAction_RespectsVerbAttribute()
+ {
+ // Arrange
+ var provider = CreateProvider();
+
+ // Act
+ var context = new ActionDescriptorProviderContext();
+ provider.Invoke(context);
+
+ var results = context.Results.Cast();
+
+ // Assert
+ var controllerType = typeof(TestControllers.StoreController).GetTypeInfo();
+ var actions = results
+ .Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType)
+ .Where(ad => ad.MethodInfo.Name == "Delete")
+ .ToArray();
+
+ Assert.Equal(2, actions.Length);
+
+ var action = Assert.Single(
+ actions,
+ a => a.RouteConstraints.Any(rc => rc.RouteKey == "action" && rc.RouteValue == "Delete"));
+ Assert.Equal(
+ new string[] { "PUT" },
+ Assert.Single(action.ActionConstraints.OfType()).HttpMethods);
+
+ action = Assert.Single(
+ actions,
+ a => a.RouteConstraints.Any(rc => rc.RouteKey == "action" && rc.RouteValue == ""));
+ Assert.Equal(
+ new string[] { "PUT" },
+ Assert.Single(action.ActionConstraints.OfType()).HttpMethods);
+ }
+
+ // The method name is used to infer a verb, not the action name
+ [Fact]
+ public void GetActions_CreatesNamedAndUnnamedAction_VerbBasedOnMethodName()
+ {
+ // Arrange
+ var provider = CreateProvider();
+
+ // Act
+ var context = new ActionDescriptorProviderContext();
+ provider.Invoke(context);
+
+ var results = context.Results.Cast();
+
+ // Assert
+ var controllerType = typeof(TestControllers.StoreController).GetTypeInfo();
+ var actions = results
+ .Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType)
+ .Where(ad => ad.MethodInfo.Name == "Options")
+ .ToArray();
+
+ Assert.Equal(2, actions.Length);
+
+ var action = Assert.Single(
+ actions,
+ a => a.RouteConstraints.Any(rc => rc.RouteKey == "action" && rc.RouteValue == "GetOptions"));
+ Assert.Equal(
+ new string[] { "OPTIONS" },
+ Assert.Single(action.ActionConstraints.OfType()).HttpMethods);
+
+ action = Assert.Single(
+ actions,
+ a => a.RouteConstraints.Any(rc => rc.RouteKey == "action" && rc.RouteValue == ""));
+ Assert.Equal(
+ new string[] { "OPTIONS" },
+ Assert.Single(action.ActionConstraints.OfType()).HttpMethods);
+ }
+
+ [Fact]
+ public void GetActions_AllWebApiActionsAreOverloaded()
+ {
+ // Arrange
+ var provider = CreateProvider();
+
+ // Act
+ var context = new ActionDescriptorProviderContext();
+ provider.Invoke(context);
+
+ var results = context.Results.Cast();
+
+ // Assert
+ var controllerType = typeof(TestControllers.StoreController).GetTypeInfo();
+ var actions = results
+ .Where(ad => ad.ControllerDescriptor.ControllerTypeInfo == controllerType)
+ .ToArray();
+
+ Assert.NotEmpty(actions);
+ foreach (var action in actions)
+ {
+ Assert.Single(action.ActionConstraints, c => c is OverloadActionConstraint);
+ }
}
private INestedProviderManager CreateProvider()
@@ -70,13 +239,17 @@ namespace System.Web.Http
var conventions = new NamespaceLimitedActionDiscoveryConventions();
+ var options = new MvcOptions();
+ options.ApplicationModelConventions.Add(new WebApiActionConventionsGlobalModelConvention());
+ options.ApplicationModelConventions.Add(new WebApiOverloadingGlobalModelConvention());
+
var optionsAccessor = new Mock>();
optionsAccessor
.SetupGet(o => o.Options)
- .Returns(new MvcOptions());
+ .Returns(options);
var provider = new ControllerActionDescriptorProvider(
- assemblyProvider.Object,
+ assemblyProvider.Object,
conventions,
filterProvider.Object,
optionsAccessor.Object);
@@ -92,7 +265,7 @@ namespace System.Web.Http
{
public override bool IsController(TypeInfo typeInfo)
{
- return
+ return
typeInfo.Namespace == "System.Web.Http.TestControllers" &&
base.IsController(typeInfo);
}
@@ -110,16 +283,6 @@ namespace System.Web.Http.TestControllers
{
return null;
}
-
- public IActionResult Get(int id)
- {
- return null;
- }
-
- public IActionResult Edit(int id)
- {
- return null;
- }
}
// Not a controller, because there's no controller suffix
@@ -130,4 +293,29 @@ namespace System.Web.Http.TestControllers
return null;
}
}
+
+ public class StoreController : ApiController
+ {
+ public IActionResult GetAll()
+ {
+ return null;
+ }
+
+ public IActionResult Edit(int id)
+ {
+ return null;
+ }
+
+ [HttpPut]
+ public IActionResult Delete(int id)
+ {
+ return null;
+ }
+
+ [ActionName("GetOptions")]
+ public IActionResult Options()
+ {
+ return null;
+ }
+ }
}
\ No newline at end of file
diff --git a/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/FormDataCollectionExtensionsTest.cs b/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/FormDataCollectionExtensionsTest.cs
new file mode 100644
index 0000000000..5daf365ac8
--- /dev/null
+++ b/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/FormDataCollectionExtensionsTest.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information.
+
+using System.Linq;
+using System.Net.Http.Formatting;
+using Xunit;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ public class FormDataCollectionExtensionsTest
+ {
+ [Theory]
+ [InlineData("", null)]
+ [InlineData("", "")] // empty
+ [InlineData("x", "x")] // normal key
+ [InlineData("", "[]")] // trim []
+ [InlineData("x", "x[]")] // trim []
+ [InlineData("x[234]", "x[234]")] // array index
+ [InlineData("x.y", "x[y]")] // field lookup
+ [InlineData("x.y.z", "x[y][z]")] // nested field lookup
+ [InlineData("x.y[234].x", "x[y][234][x]")] // compound
+ public void TestNormalize(string expectedMvc, string jqueryString)
+ {
+ Assert.Equal(expectedMvc, FormDataCollectionExtensions.NormalizeJQueryToMvc(jqueryString));
+ }
+
+ [Fact]
+ public void TestGetJQueryNameValuePairs()
+ {
+ // Arrange
+ var formData = new FormDataCollection("x.y=30&x[y]=70&x[z][20]=cool");
+
+ // Act
+ var actual = FormDataCollectionExtensions.GetJQueryNameValuePairs(formData).ToArray();
+
+ // Assert
+ var arraySetter = Assert.Single(actual, kvp => kvp.Key == "x.z[20]");
+ Assert.Equal("cool", arraySetter.Value);
+
+ Assert.Single(actual, kvp => kvp.Key == "x.y" && kvp.Value == "30");
+ Assert.Single(actual, kvp => kvp.Key == "x.y" && kvp.Value == "70");
+ }
+ }
+}
diff --git a/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/OverloadActionConstraintTest.cs b/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/OverloadActionConstraintTest.cs
new file mode 100644
index 0000000000..8069650d9a
--- /dev/null
+++ b/test/Microsoft.AspNet.Mvc.WebApiCompatShimTest/OverloadActionConstraintTest.cs
@@ -0,0 +1,444 @@
+// 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.Collections.Generic;
+using Microsoft.AspNet.Http;
+using Microsoft.AspNet.PipelineCore;
+using Microsoft.AspNet.Routing;
+using Xunit;
+
+namespace Microsoft.AspNet.Mvc.WebApiCompatShim
+{
+ public class OverloadActionConstraintTest
+ {
+ [Fact]
+ public void Accept_RejectsActionMatchWithMissingParameter()
+ {
+ // Arrange
+ var action = new ActionDescriptor();
+ action.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext();
+
+ // Act & Assert
+ Assert.False(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_AcceptsActionWithSatisfiedParameters()
+ {
+ // Arrange
+ var action = new ActionDescriptor();
+ action.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?quantity=5", new { id = 17});
+
+ // Act & Assert
+ Assert.True(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_AcceptsActionWithSatisfiedParameters_QueryStringOnly()
+ {
+ // Arrange
+ var action = new ActionDescriptor();
+ action.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?quantity=5&id=7", new { });
+
+ // Act & Assert
+ Assert.True(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_AcceptsActionWithSatisfiedParameters_RouteDataOnly()
+ {
+ // Arrange
+ var action = new ActionDescriptor();
+ action.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?", new { quantity = 9, id = 17 });
+
+ // Act & Assert
+ Assert.True(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_AcceptsActionWithUnsatisfiedOptionalParameter()
+ {
+ // Arrange
+ var action = new ActionDescriptor();
+ action.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ IsOptional = true,
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?store=5", new { id = 17 });
+
+ // Act & Assert
+ Assert.True(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_AcceptsOneAndRejectsAnother()
+ {
+ // Arrange
+ var action1 = new ActionDescriptor();
+ action1.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var action2 = new ActionDescriptor();
+ action2.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity_ordered",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity_ordered", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action1, new [] { constraint }),
+ new ActionSelectorCandidate(action2, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?quantity=5", new { id = 17 });
+
+ // Act & Assert
+ Assert.True(constraint.Accept(context));
+
+ context.CurrentCandidate = context.Candidates[1];
+ Assert.False(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_RejectsWorseMatch()
+ {
+ // Arrange
+ var action1 = new ActionDescriptor();
+ action1.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ };
+
+ var action2 = new ActionDescriptor();
+ action2.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action1, new [] { constraint }),
+ new ActionSelectorCandidate(action2, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?quantity=5", new { id = 17 });
+
+ // Act & Assert
+ Assert.False(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_RejectsWorseMatch_OptionalParameter()
+ {
+ // Arrange
+ var action1 = new ActionDescriptor();
+ action1.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ IsOptional = true,
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var action2 = new ActionDescriptor();
+ action2.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action1, new [] { constraint }),
+ new ActionSelectorCandidate(action2, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?quantity=5", new { id = 17 });
+
+ // Act & Assert
+ Assert.False(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_AcceptsActionsOnSameTier()
+ {
+ // Arrange
+ var action1 = new ActionDescriptor();
+ action1.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var action2 = new ActionDescriptor();
+ action2.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "price",
+ ParameterBindingInfo = new ParameterBindingInfo("price", typeof(decimal)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action1, new [] { constraint }),
+ new ActionSelectorCandidate(action2, new [] { constraint }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?quantity=5&price=5.99", new { id = 17 });
+
+ // Act & Assert
+ Assert.True(constraint.Accept(context));
+
+ context.CurrentCandidate = context.Candidates[1];
+ Assert.True(constraint.Accept(context));
+ }
+
+ [Fact]
+ public void Accept_AcceptsAction_WithFewerParameters_WhenOtherIsNotOverloaded()
+ {
+ // Arrange
+ var action1 = new ActionDescriptor();
+ action1.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ };
+
+ var action2 = new ActionDescriptor();
+ action2.Parameters = new List()
+ {
+ new ParameterDescriptor()
+ {
+ Name = "id",
+ ParameterBindingInfo = new ParameterBindingInfo("id", typeof(int)),
+ },
+ new ParameterDescriptor()
+ {
+ Name = "quantity",
+ ParameterBindingInfo = new ParameterBindingInfo("quantity", typeof(int)),
+ },
+ };
+
+ var constraint = new OverloadActionConstraint();
+
+ var context = new ActionConstraintContext();
+ context.Candidates = new List()
+ {
+ new ActionSelectorCandidate(action1, new [] { constraint }),
+ new ActionSelectorCandidate(action2, new IActionConstraint[] { }),
+ };
+
+ context.CurrentCandidate = context.Candidates[0];
+ context.RouteContext = CreateRouteContext("?quantity=5", new { id = 17 });
+
+ // Act & Assert
+ Assert.True(constraint.Accept(context));
+ }
+
+ private static RouteContext CreateRouteContext(string queryString = null, object routeValues = null)
+ {
+ var httpContext = new DefaultHttpContext();
+ if (queryString != null)
+ {
+ httpContext.Request.QueryString = new QueryString(queryString);
+ }
+
+ var routeContext = new RouteContext(httpContext);
+ routeContext.RouteData = new RouteData()
+ {
+ Values = new RouteValueDictionary(routeValues),
+ };
+
+ return routeContext;
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/ActionSelectionFilter.cs b/test/WebSites/WebApiCompatShimWebSite/ActionSelectionFilter.cs
new file mode 100644
index 0000000000..875794108b
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/ActionSelectionFilter.cs
@@ -0,0 +1,20 @@
+// 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 Microsoft.AspNet.Mvc;
+
+namespace WebApiCompatShimWebSite
+{
+ public class ActionSelectionFilterAttribute : ActionFilterAttribute
+ {
+ public override void OnActionExecuted(ActionExecutedContext context)
+ {
+ var action = (ControllerActionDescriptor)context.ActionDescriptor;
+ context.Result = new JsonResult(new
+ {
+ ActionName = action.Name,
+ ControllerName = action.ControllerName
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/EnumParameterOverloadsController.cs b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/EnumParameterOverloadsController.cs
new file mode 100644
index 0000000000..8ffcc07054
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/EnumParameterOverloadsController.cs
@@ -0,0 +1,35 @@
+// 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.Collections.Generic;
+using System.Diagnostics;
+using System.Web.Http;
+using Microsoft.AspNet.Mvc.WebApiCompatShim;
+
+namespace WebApiCompatShimWebSite
+{
+ // This was ported from the WebAPI 5.2 codebase. Kept the same intentionally for compatability.
+ [ActionSelectionFilter]
+ public class EnumParameterOverloadsController : ApiController
+ {
+ public IEnumerable Get()
+ {
+ return new string[] { "get" };
+ }
+
+ public string GetWithEnumParameter(UserKind scope)
+ {
+ return scope.ToString();
+ }
+
+ public string GetWithTwoEnumParameters([FromUri]UserKind level, UserKind kind)
+ {
+ return level.ToString() + kind.ToString();
+ }
+
+ public string GetWithNullableEnumParameter(TraceLevel? level)
+ {
+ return level.ToString();
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/ParameterAttributeController.cs b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/ParameterAttributeController.cs
new file mode 100644
index 0000000000..22b6834a82
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/ParameterAttributeController.cs
@@ -0,0 +1,22 @@
+// 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.Collections.Generic;
+using System.Web.Http;
+using Microsoft.AspNet.Mvc;
+using Microsoft.AspNet.Mvc.WebApiCompatShim;
+
+namespace WebApiCompatShimWebSite
+{
+ // This was ported from the WebAPI 5.2 codebase. Kept the same intentionally for compatability.
+ [ActionSelectionFilter]
+ public class ParameterAttributeController : ApiController
+ {
+ public User GetUserByMyId(int myId) { return null; }
+ public User GetUser([FromUri(Name = "id")] int myId) { return null; }
+ public List PostUserNameFromUri(int id, [FromUri]string name) { return null; }
+ public List PostUserNameFromBody(int id, [FromBody] string name) { return null; }
+ public void DeleteUserWithNullableIdAndName(int? id, string name) { }
+ public void DeleteUser(string address) { }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/TestController.cs b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/TestController.cs
new file mode 100644
index 0000000000..bfd4de8d5e
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/Legacy/TestController.cs
@@ -0,0 +1,39 @@
+// 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.Collections.Generic;
+using System.Web.Http;
+using Microsoft.AspNet.Mvc;
+
+namespace WebApiCompatShimWebSite
+{
+ // This was ported from the WebAPI 5.2 codebase. Kept the same intentionally for compatability.
+ [ActionSelectionFilter]
+ public class TestController : ApiController
+ {
+ public User GetUser(int id) { return null; }
+ public List GetUsers() { return null; }
+
+ public List GetUsersByName(string name) { return null; }
+
+ [AcceptVerbs("PATCH")]
+ public void PutUser(User user) { }
+
+ public User GetUserByNameAndId(string name, int id) { return null; }
+ public User GetUserByNameAndAge(string name, int age) { return null; }
+ public User GetUserByNameAgeAndSsn(string name, int age, int ssn) { return null; }
+ public User GetUserByNameIdAndSsn(string name, int id, int ssn) { return null; }
+ public User GetUserByNameAndSsn(string name, int ssn) { return null; }
+ public User PostUser(User user) { return null; }
+ public User PostUserByNameAndAge(string name, int age) { return null; }
+ public User PostUserByName(string name) { return null; }
+ public User PostUserByNameAndAddress(string name, UserAddress address) { return null; }
+ public User DeleteUserByOptName(string name = null) { return null; }
+ public User DeleteUserByIdAndOptName(int id, string name = "DefaultName") { return null; }
+ public User DeleteUserByIdNameAndAge(int id, string name, int age) { return null; }
+ public User DeleteUserById_Email_OptName_OptPhone(int id, string email, string name = null, int phone = 0) { return null; }
+ public User DeleteUserById_Email_Height_OptName_OptPhone(int id, string email, double height, string name = "DefaultName", int? phone = null) { return null; }
+ public void Head_Id_OptSize_OptIndex(int id, int size = 10, int index = 0) { }
+ public void Head() { }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsActionNameController.cs b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsActionNameController.cs
new file mode 100644
index 0000000000..78c5eed691
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsActionNameController.cs
@@ -0,0 +1,18 @@
+// 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.Web.Http;
+using Microsoft.AspNet.Mvc;
+
+namespace WebApiCompatShimWebSite
+{
+ // The verb is still inferred by the METHOD NAME not the action name.
+ [ActionSelectionFilter]
+ public class WebAPIActionConventionsActionNameController : ApiController
+ {
+ [ActionName("GetItems")]
+ public void PostItems()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsController.cs b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsController.cs
new file mode 100644
index 0000000000..b45a24e36b
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsController.cs
@@ -0,0 +1,41 @@
+// 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.Web.Http;
+
+namespace WebApiCompatShimWebSite
+{
+ // Each of these is mapped to an unnamed action with the corresponding http verb, and also
+ // a named action with the corresponding http verb.
+ [ActionSelectionFilter]
+ public class WebAPIActionConventionsController : ApiController
+ {
+ public void GetItems()
+ {
+ }
+
+ public void PostItems()
+ {
+ }
+
+ public void PutItems()
+ {
+ }
+
+ public void DeleteItems()
+ {
+ }
+
+ public void PatchItems()
+ {
+ }
+
+ public void HeadItems()
+ {
+ }
+
+ public void OptionsItems()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsDefaultPostController.cs b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsDefaultPostController.cs
new file mode 100644
index 0000000000..9f98c1c077
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsDefaultPostController.cs
@@ -0,0 +1,16 @@
+// 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.Web.Http;
+
+namespace WebApiCompatShimWebSite
+{
+ // This action only accepts POST by default
+ [ActionSelectionFilter]
+ public class WebAPIActionConventionsDefaultPostController : ApiController
+ {
+ public void DefaultVerbIsPost()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsVerbOverrideController.cs b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsVerbOverrideController.cs
new file mode 100644
index 0000000000..563595e433
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Controllers/ActionSelection/WebAPIActionConventionsVerbOverrideController.cs
@@ -0,0 +1,18 @@
+// 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.Web.Http;
+using Microsoft.AspNet.Mvc;
+
+namespace WebApiCompatShimWebSite
+{
+ // The verb is overridden by the attribute
+ [ActionSelectionFilter]
+ public class WebAPIActionConventionsVerbOverrideController : ApiController
+ {
+ [HttpGet]
+ public void PostItems()
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Models/User.cs b/test/WebSites/WebApiCompatShimWebSite/Models/User.cs
new file mode 100644
index 0000000000..50df5ddc90
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Models/User.cs
@@ -0,0 +1,11 @@
+// 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;
+
+namespace WebApiCompatShimWebSite
+{
+ public class User
+ {
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Models/UserAddress.cs b/test/WebSites/WebApiCompatShimWebSite/Models/UserAddress.cs
new file mode 100644
index 0000000000..6c79945ee2
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Models/UserAddress.cs
@@ -0,0 +1,11 @@
+// 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;
+
+namespace WebApiCompatShimWebSite
+{
+ public class UserAddress
+ {
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Models/UserKind.cs b/test/WebSites/WebApiCompatShimWebSite/Models/UserKind.cs
new file mode 100644
index 0000000000..2b96be4a19
--- /dev/null
+++ b/test/WebSites/WebApiCompatShimWebSite/Models/UserKind.cs
@@ -0,0 +1,12 @@
+// 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.
+
+namespace WebApiCompatShimWebSite
+{
+ public enum UserKind
+ {
+ Normal,
+ Admin,
+ SuperAdmin,
+ }
+}
\ No newline at end of file
diff --git a/test/WebSites/WebApiCompatShimWebSite/Startup.cs b/test/WebSites/WebApiCompatShimWebSite/Startup.cs
index daa7f3fd7f..c4dcf3d447 100644
--- a/test/WebSites/WebApiCompatShimWebSite/Startup.cs
+++ b/test/WebSites/WebApiCompatShimWebSite/Startup.cs
@@ -2,6 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNet.Builder;
+using Microsoft.AspNet.Mvc;
+using Microsoft.AspNet.Routing;
using Microsoft.Framework.DependencyInjection;
namespace WebApiCompatShimWebSite
@@ -19,7 +21,15 @@ namespace WebApiCompatShimWebSite
services.AddWebApiConventions();
});
- app.UseMvc();
+ app.UseMvc(routes =>
+ {
+ // Tests include different styles of WebAPI conventional routing and action selection - the prefix keeps
+ // them from matching too eagerly.
+ routes.MapRoute("named-action", "api/Blog/{controller}/{action}/{id?}");
+ routes.MapRoute("unnamed-action", "api/Admin/{controller}/{id?}");
+ routes.MapRoute("name-as-parameter", "api/Store/{controller}/{name?}");
+ routes.MapRoute("extra-parameter", "api/Support/{extra}/{controller}/{id?}");
+ });
}
}
}