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?}"); + }); } } }