diff --git a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs index 6414542f39..e0953ca6e2 100644 --- a/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.ApiExplorer/ApiResponseTypeProvider.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Controllers; @@ -39,57 +38,19 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer var runtimeReturnType = GetRuntimeReturnType(declaredReturnType); var responseMetadataAttributes = GetResponseMetadataAttributes(action); - if (responseMetadataAttributes.Length == 0) + if (responseMetadataAttributes.Count == 0 && + action.Properties.TryGetValue(typeof(ApiConventionResult), out var result)) { - // Action does not have any conventions. Look for conventions on the type. - responseMetadataAttributes = GetResponseMetadataAttributesFromConventions(action); + // Action does not have any conventions. Use conventions on it if present. + var apiConventionResult = (ApiConventionResult)result; + responseMetadataAttributes = apiConventionResult.ResponseMetadataProviders; } var apiResponseTypes = GetApiResponseTypes(responseMetadataAttributes, runtimeReturnType); return apiResponseTypes; } - private IApiResponseMetadataProvider[] GetResponseMetadataAttributesFromConventions(ControllerActionDescriptor action) - { - if (action.FilterDescriptors == null) - { - return Array.Empty(); - } - - foreach (var filterDescriptor in action.FilterDescriptors) - { - if (!(filterDescriptor.Filter is ApiConventionAttribute apiConventionAttribute)) - { - continue; - } - - var method = GetConventionMethod(action.MethodInfo, apiConventionAttribute.ConventionType); - if (method != null) - { - return method.GetCustomAttributes(inherit: false) - .OfType() - .ToArray(); - } - } - - return Array.Empty(); - } - - private MethodInfo GetConventionMethod(MethodInfo methodInfo, Type conventions) - { - var conventionMethods = conventions.GetMethods(BindingFlags.Public | BindingFlags.Static); - for (var i = 0; i < conventionMethods.Length; i++) - { - if (IsMatch(methodInfo, conventionMethods[i])) - { - return conventionMethods[i]; - } - } - - return null; - } - - private IApiResponseMetadataProvider[] GetResponseMetadataAttributes(ControllerActionDescriptor action) + private IReadOnlyList GetResponseMetadataAttributes(ControllerActionDescriptor action) { if (action.FilterDescriptors == null) { @@ -107,7 +68,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer } private IList GetApiResponseTypes( - IApiResponseMetadataProvider[] responseMetadataAttributes, + IReadOnlyList responseMetadataAttributes, Type type) { var results = new List(); @@ -240,95 +201,5 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer return declaredReturnType; } - - internal static bool IsMatch(MethodInfo methodInfo, MethodInfo conventionMethod) - { - if (!IsMethodNameMatch(methodInfo.Name, conventionMethod.Name)) - { - return false; - } - - var methodParameters = methodInfo.GetParameters(); - var conventionMethodParameters = conventionMethod.GetParameters(); - if (conventionMethodParameters.Length != methodParameters.Length) - { - return false; - } - - for (var i = 0; i < conventionMethodParameters.Length; i++) - { - if (conventionMethodParameters[i].ParameterType.IsGenericParameter) - { - // Use TModel as wildcard - continue; - } - else if (!IsParameterNameMatch(methodParameters[i].Name, conventionMethodParameters[i].Name) || - !IsParameterTypeMatch(methodParameters[i].ParameterType, conventionMethodParameters[i].ParameterType)) - { - return false; - } - } - - return true; - } - - internal static bool IsMethodNameMatch(string name, string conventionName) - { - if (!name.StartsWith(conventionName, StringComparison.Ordinal)) - { - return false; - } - - if (name.Length == conventionName.Length) - { - return true; - } - - return char.IsUpper(name[conventionName.Length]); - } - - internal static bool IsParameterNameMatch(string name, string conventionName) - { - // Leading underscores could be used to allow multiple parameter names with the same suffix e.g. GetPersonAddress(int personId, int addressId) - // A common convention that allows targeting these category of methods would look like Get(int id, int _id) - conventionName = conventionName.Trim('_'); - - // name = id, conventionName = id - if (string.Equals(name, conventionName, StringComparison.Ordinal)) - { - return true; - } - - if (name.Length <= conventionName.Length) - { - return false; - } - - // name = personId, conventionName = id - var index = name.Length - conventionName.Length - 1; - if (!char.IsLower(name[index])) - { - return false; - } - - index++; - if (name[index] != char.ToUpper(conventionName[0])) - { - return false; - } - - index++; - return string.Compare(name, index, conventionName, 1, conventionName.Length - 1, StringComparison.Ordinal) == 0; - } - - internal static bool IsParameterTypeMatch(Type parameterType, Type conventionParameterType) - { - if (conventionParameterType == typeof(object)) - { - return true; - } - - return conventionParameterType.IsAssignableFrom(parameterType); - } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionAttribute.cs deleted file mode 100644 index 0cd749ca8c..0000000000 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionAttribute.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) .NET Foundation. 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.AspNetCore.Mvc.Core; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace Microsoft.AspNetCore.Mvc -{ - [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class, AllowMultiple = true, Inherited = true)] - public sealed class ApiConventionAttribute : Attribute, IFilterMetadata - { - public ApiConventionAttribute(Type conventionType) - { - ConventionType = conventionType ?? throw new ArgumentNullException(nameof(conventionType)); - - if (!ConventionType.IsSealed || !ConventionType.IsAbstract) - { - // Conventions must be static viz abstract + sealed. - throw new ArgumentException(Resources.FormatApiConventionMustBeStatic(conventionType), nameof(conventionType)); - } - } - - public Type ConventionType { get; } - } -} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs new file mode 100644 index 0000000000..9c44452961 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs @@ -0,0 +1,89 @@ +// Copyright (c) .NET Foundation. 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.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.Extensions.Internal; + +namespace Microsoft.AspNetCore.Mvc +{ + /// + /// API conventions to be applied to an assembly containing MVC controllers or a single controller. + /// + /// API conventions are used to influence the output of ApiExplorer. + /// Conventions must be static types. Methods in a convention are + /// matched to an action method using rules specified by + /// that may be applied to a method name or it's parameters and + /// that are applied to parameters. + /// + /// + /// When no attributes are found specifying the behavior, MVC matches method names and parameter names are matched + /// using and parameter types are matched + /// using . + /// + /// + [AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + public sealed class ApiConventionTypeAttribute : Attribute + { + /// + /// Initializes an instance using . + /// + /// + /// The of the convention. + /// + /// Conventions must be static types. Methods in a convention are + /// matched to an action method using rules specified by + /// that may be applied to a method name or it's parameters and + /// that are applied to parameters. + /// + /// + public ApiConventionTypeAttribute(Type conventionType) + { + ConventionType = conventionType ?? throw new ArgumentNullException(nameof(conventionType)); + EnsureValid(conventionType); + } + + /// + /// Gets the convention type. + /// + public Type ConventionType { get; } + + private static void EnsureValid(Type conventionType) + { + if (!conventionType.IsSealed || !conventionType.IsAbstract) + { + // Conventions must be static viz abstract + sealed. + throw new ArgumentException(Resources.FormatApiConventionMustBeStatic(conventionType), nameof(conventionType)); + } + + foreach (var method in conventionType.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + var unsupportedAttributes = method.GetCustomAttributes(inherit: true) + .Where(attribute => !IsAllowedAttribute(attribute)) + .ToArray(); + + if (unsupportedAttributes.Length == 0) + { + continue; + } + + var methodDisplayName = TypeNameHelper.GetTypeDisplayName(method.DeclaringType) + "." + method.Name; + var errorMessage = Resources.FormatApiConvention_UnsupportedAttributesOnConvention( + methodDisplayName, + Environment.NewLine + string.Join(Environment.NewLine, unsupportedAttributes) + Environment.NewLine, + $"{nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"); + + throw new ArgumentException(errorMessage, nameof(conventionType)); + } + } + + private static bool IsAllowedAttribute(object attribute) + { + return attribute is ProducesResponseTypeAttribute || + attribute is ApiConventionNameMatchAttribute; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchAttribute.cs new file mode 100644 index 0000000000..2da8950dec --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchAttribute.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. 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.AspNetCore.Mvc.ApiExplorer +{ + /// + /// Determines the matching behavior an API convention method or parameter by name. + /// for supported options. + /// . + /// + /// + /// is used if no value for this + /// attribute is specified on a convention method or parameter. + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + public sealed class ApiConventionNameMatchAttribute : Attribute + { + /// + /// Initializes a new instance of . + /// + /// The . + public ApiConventionNameMatchAttribute(ApiConventionNameMatchBehavior matchBehavior) + { + MatchBehavior = matchBehavior; + } + + /// + /// Gets the . + /// + public ApiConventionNameMatchBehavior MatchBehavior { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchBehavior.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchBehavior.cs new file mode 100644 index 0000000000..b4775fbf05 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionNameMatchBehavior.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + /// + /// The behavior for matching the name of a convention parameter or method. + /// + public enum ApiConventionNameMatchBehavior + { + /// + /// Matches any name. Use this if the parameter or method name does not need to be matched. + /// + Any, + + /// + /// The parameter or method name must exactly match the convention. + /// + Exact, + + /// + /// The parameter or method name in the convention is a proper prefix. + /// + /// Casing is used to delineate words in a given name. For instance, with this behavior + /// the convention name "Get" will match "Get", "GetPerson" or "GetById", but not "getById", "Getaway". + /// + /// + Prefix, + + /// + /// The parameter or method name in the convention is a proper suffix. + /// + /// Casing is used to delineate words in a given name. For instance, with this behavior + /// the convention name "id" will match "id", or "personId" but not "grid" or "personid". + /// + /// + Suffix, + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs new file mode 100644 index 0000000000..ca4d85d177 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs @@ -0,0 +1,229 @@ +// Copyright (c) .NET Foundation. 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 System.Reflection; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + /// + /// Metadata associated with an action method via API convention. + /// + public sealed class ApiConventionResult + { + public ApiConventionResult(IReadOnlyList responseMetadataProviders) + { + ResponseMetadataProviders = responseMetadataProviders ?? + throw new ArgumentNullException(nameof(responseMetadataProviders)); + } + + public IReadOnlyList ResponseMetadataProviders { get; } + + internal static bool TryGetApiConvention( + MethodInfo method, + ApiConventionTypeAttribute[] apiConventionAttributes, + out ApiConventionResult result) + { + foreach (var attribute in apiConventionAttributes) + { + var conventionMethod = GetConventionMethod(method, attribute.ConventionType); + if (conventionMethod != null) + { + var metadataProviders = conventionMethod.GetCustomAttributes(inherit: false) + .OfType() + .ToArray(); + + result = new ApiConventionResult(metadataProviders); + return true; + } + } + + result = null; + return false; + } + + private static MethodInfo GetConventionMethod(MethodInfo method, Type conventionType) + { + foreach (var conventionMethod in conventionType.GetMethods()) + { + if (IsMatch(method, conventionMethod)) + { + return conventionMethod; + } + } + + return null; + } + + internal static bool IsMatch(MethodInfo methodInfo, MethodInfo conventionMethod) + { + return MethodMatches() && ParametersMatch(); + + bool MethodMatches() + { + var methodNameMatchBehavior = GetNameMatchBehavior(conventionMethod); + if (!IsNameMatch(methodInfo.Name, conventionMethod.Name, methodNameMatchBehavior)) + { + return false; + } + + return true; + } + + bool ParametersMatch() + { + var methodParameters = methodInfo.GetParameters(); + var conventionMethodParameters = conventionMethod.GetParameters(); + + for (var i = 0; i < conventionMethodParameters.Length; i++) + { + var conventionParameter = conventionMethodParameters[i]; + if (conventionParameter.IsDefined(typeof(ParamArrayAttribute))) + { + return true; + } + + if (methodParameters.Length <= i) + { + return false; + } + + var nameMatchBehavior = GetNameMatchBehavior(conventionParameter); + var typeMatchBehavior = GetTypeMatchBehavior(conventionParameter); + + if (!IsTypeMatch(methodParameters[i].ParameterType, conventionParameter.ParameterType, typeMatchBehavior) || + !IsNameMatch(methodParameters[i].Name, conventionParameter.Name, nameMatchBehavior)) + { + return false; + } + } + + // Ensure convention has at least as many parameters as the method. params convention argument are handled + // inside the for loop. + return methodParameters.Length == conventionMethodParameters.Length; + } + } + + internal static ApiConventionNameMatchBehavior GetNameMatchBehavior(ICustomAttributeProvider attributeProvider) + { + var attribute = GetCustomAttribute(attributeProvider); + return attribute?.MatchBehavior ?? ApiConventionNameMatchBehavior.Exact; + } + + internal static ApiConventionTypeMatchBehavior GetTypeMatchBehavior(ICustomAttributeProvider attributeProvider) + { + var attribute = GetCustomAttribute(attributeProvider); + return attribute?.MatchBehavior ?? ApiConventionTypeMatchBehavior.AssignableFrom; + } + + private static TAttribute GetCustomAttribute(ICustomAttributeProvider attributeProvider) + { + var attributes = attributeProvider.GetCustomAttributes(inherit: false); + for (var i = 0; i < attributes.Length; i++) + { + if (attributes[i] is TAttribute attribute) + { + return attribute; + } + } + + return default; + } + + internal static bool IsNameMatch(string name, string conventionName, ApiConventionNameMatchBehavior nameMatchBehavior) + { + switch (nameMatchBehavior) + { + case ApiConventionNameMatchBehavior.Any: + return true; + + case ApiConventionNameMatchBehavior.Exact: + return string.Equals(name, conventionName, StringComparison.Ordinal); + + case ApiConventionNameMatchBehavior.Prefix: + return IsNameMatchPrefix(); + + case ApiConventionNameMatchBehavior.Suffix: + return IsNameMatchSuffix(); + + default: + return false; + } + + bool IsNameMatchPrefix() + { + if (name.Length < conventionName.Length) + { + return false; + } + + if (name.Length == conventionName.Length) + { + // name = "Post", conventionName = "Post" + return string.Equals(name, conventionName, StringComparison.Ordinal); + } + + if (!name.StartsWith(conventionName, StringComparison.Ordinal)) + { + // name = "GetPerson", conventionName = "Post" + return false; + } + + // Check for name = "PostPerson", conventionName = "Post" + // Verify the first letter after the convention name is upper case. In this case 'P' from "Person" + return char.IsUpper(name[conventionName.Length]); + } + + bool IsNameMatchSuffix() + { + if (name.Length < conventionName.Length) + { + // name = "person", conventionName = "personName" + return false; + } + + if (name.Length == conventionName.Length) + { + // name = id, conventionName = id + return string.Equals(name, conventionName, StringComparison.Ordinal); + } + + // Check for name = personName, conventionName = name + var index = name.Length - conventionName.Length - 1; + if (!char.IsLower(name[index])) + { + // Verify letter before "name" is lowercase. In this case the letter 'n' at the end of "person" + return false; + } + + index++; + if (name[index] != char.ToUpper(conventionName[0])) + { + // Verify the first letter from convention is upper case. In this case 'n' from "name" + return false; + } + + // Match the remaining letters with exact case. i.e. match "ame" from "personName", "name" + index++; + return string.Compare(name, index, conventionName, 1, conventionName.Length - 1, StringComparison.Ordinal) == 0; + } + } + + internal static bool IsTypeMatch(Type type, Type conventionType, ApiConventionTypeMatchBehavior typeMatchBehavior) + { + switch (typeMatchBehavior) + { + case ApiConventionTypeMatchBehavior.Any: + return true; + + case ApiConventionTypeMatchBehavior.AssignableFrom: + return conventionType.IsAssignableFrom(type); + + default: + return false; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchAttribute.cs new file mode 100644 index 0000000000..657da6ef3d --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchAttribute.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. 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.AspNetCore.Mvc.ApiExplorer +{ + /// + /// Determines the matching behavior an API convention parameter by type. + /// for supported options. + /// . + /// + /// + /// is used if no value for this + /// attribute is specified on a convention parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] + public sealed class ApiConventionTypeMatchAttribute : Attribute + { + public ApiConventionTypeMatchAttribute(ApiConventionTypeMatchBehavior matchBehavior) + { + MatchBehavior = matchBehavior; + } + + public ApiConventionTypeMatchBehavior MatchBehavior { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchBehavior.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchBehavior.cs new file mode 100644 index 0000000000..300c7255f9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionTypeMatchBehavior.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + /// + /// The behavior for matching the name of a convention parameter. + /// + public enum ApiConventionTypeMatchBehavior + { + /// + /// Matches any type. Use this if the parameter does not need to be matched. + /// + Any, + + /// + /// The parameter in the convention is the exact type or a subclass of the type + /// specified in the convention. + /// + AssignableFrom, + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs b/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs index a48bd43bb7..46b3ec6e80 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/DefaultApiConventions.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ApiExplorer; namespace Microsoft.AspNetCore.Mvc { @@ -9,23 +10,40 @@ namespace Microsoft.AspNetCore.Mvc { [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public static void Get(object id) { } - - [ProducesResponseType(StatusCodes.Status200OK)] - public static void Get() { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Get( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id) { } [ProducesResponseType(StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public static void Post(TModel model) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Post( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) { } [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public static void Put(object id, TModel model) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Put( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id, + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object model) { } [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public static void Delete(object id) { } + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Delete( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] + [ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] + object id) { } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs index b5181b6706..7105b9a4df 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Internal/ApiBehaviorApplicationModelProvider.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Linq; using System.Reflection; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.Core; using Microsoft.AspNetCore.Mvc.Infrastructure; @@ -68,11 +69,15 @@ namespace Microsoft.AspNetCore.Mvc.Internal if (isApiController) { InferBoundPropertyModelPrefixes(controllerModel); - - AddGloballyConfiguredApiConventions(controllerModel); } var controllerHasSelectorModel = controllerModel.Selectors.Any(s => s.AttributeRouteModel != null); + var conventions = controllerModel.Attributes.OfType().ToArray(); + if (conventions.Length == 0) + { + var controllerAssembly = controllerModel.ControllerType.Assembly; + conventions = controllerAssembly.GetCustomAttributes().ToArray(); + } foreach (var actionModel in controllerModel.Actions) { @@ -90,25 +95,12 @@ namespace Microsoft.AspNetCore.Mvc.Internal InferParameterModelPrefixes(actionModel); AddMultipartFormDataConsumesAttribute(actionModel); + + DiscoverApiConvention(actionModel, conventions); } } } - internal static void AddGloballyConfiguredApiConventions(ControllerModel controllerModel) - { - if (controllerModel.Filters.OfType().Any()) - { - // ApiControllerAttribute is already associated with controller. Do not look for conventions configured at assembly. - return; - } - - var assembly = controllerModel.ControllerType.Assembly; - foreach (var attribute in assembly.GetCustomAttributes()) - { - controllerModel.Filters.Add(attribute); - } - } - // Internal for unit testing internal void AddMultipartFormDataConsumesAttribute(ActionModel actionModel) { @@ -250,6 +242,20 @@ namespace Microsoft.AspNetCore.Mvc.Internal return bindingSource; } + internal static void DiscoverApiConvention(ActionModel actionModel, ApiConventionTypeAttribute[] apiConventionAttributes) + { + if (actionModel.Filters.OfType().Any()) + { + // If an action already has providers, don't discover any from conventions. + return; + } + + if (ApiConventionResult.TryGetApiConvention(actionModel.ActionMethod, apiConventionAttributes, out var result)) + { + actionModel.Properties[typeof(ApiConventionResult)] = result; + } + } + private bool ParameterExistsInAnyRoute(ActionModel actionModel, string parameterName) { foreach (var (route, _, _) in ActionAttributeRouteModel.GetAttributeRoutes(actionModel)) diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs index 1221fccdef..7612620733 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs @@ -1494,6 +1494,20 @@ namespace Microsoft.AspNetCore.Mvc.Core internal static string FormatInvalidTypeTForActionResultOfT(object p0, object p1) => string.Format(CultureInfo.CurrentCulture, GetString("InvalidTypeTForActionResultOfT"), p0, p1); + /// + /// Method {0} is decorated with the following attributes that are not allowed on an API convention method:{1}The following attributes are allowed on API convention methods: {2}. + /// + internal static string ApiConvention_UnsupportedAttributesOnConvention + { + get => GetString("ApiConvention_UnsupportedAttributesOnConvention"); + } + + /// + /// Method {0} is decorated with the following attributes that are not allowed on an API convention method:{1}The following attributes are allowed on API convention methods: {2}. + /// + internal static string FormatApiConvention_UnsupportedAttributesOnConvention(object p0, object p1, object p2) + => string.Format(CultureInfo.CurrentCulture, GetString("ApiConvention_UnsupportedAttributesOnConvention"), p0, p1, p2); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx index 98aa14bda6..652f0df157 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx @@ -448,4 +448,7 @@ Invalid type parameter '{0}' specified for '{1}'. + + Method {0} is decorated with the following attributes that are not allowed on an API convention method:{1}The following attributes are allowed on API convention methods: {2}. + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs index dd72f88b2e..99e862dcf4 100644 --- a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs @@ -17,304 +17,6 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer { public class ApiResponseTypeProviderTest { - [Theory] - [InlineData("id", "model")] - [InlineData("id", "person")] - [InlineData("id", "i")] - public void IsParameterNameMatch_ReturnsFalse_IfConventionNameIsNotSuffix(string parameterName, string conventionName) - { - // Act - var result = ApiResponseTypeProvider.IsParameterNameMatch(parameterName, conventionName); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsParameterNameMatch_ReturnsFalse_IfConventionNameIsNotExactCaseSensitiveMatch() - { - // Arrange - var parameterName = "Id"; - var conventionName = "id"; - - // Act - var result = ApiResponseTypeProvider.IsParameterNameMatch(parameterName, conventionName); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("rid", "id")] - [InlineData("candid", "id")] - [InlineData("colocation", "location")] - public void IsParamterNameMatch_ReturnsFalse_IfConventionNameIsNotProperSuffix(string parameterName, string conventionName) - { - // Act - var result = ApiResponseTypeProvider.IsParameterNameMatch(parameterName, conventionName); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("id", "id")] - [InlineData("model", "model")] - public void IsParamterNameMatch_ReturnsTrue_IfConventionNameIsExactMatch(string parameterName, string conventionName) - { - // Act - var result = ApiResponseTypeProvider.IsParameterNameMatch(parameterName, conventionName); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData("id", "_id")] - [InlineData("model", "_model")] - public void IsParamterNameMatch_ReturnsTrue_IfConventionNameIsExactMatchIgnoringLeadingUnderscores(string parameterName, string conventionName) - { - // Act - var result = ApiResponseTypeProvider.IsParameterNameMatch(parameterName, conventionName); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData("personId", "id")] - [InlineData("userModel", "model")] - [InlineData("beaconLocation", "Location")] - public void IsParamterNameMatch_ReturnsTrue_IfConventionNameIsProperSuffix(string parameterName, string conventionName) - { - // Act - var result = ApiResponseTypeProvider.IsParameterNameMatch(parameterName, conventionName); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData("personId", "_id")] - [InlineData("userModel", "_model")] - [InlineData("userModel", "__model")] - public void IsParamterNameMatch_ReturnsTrue_IfConventionNameIsProperSuffixIgnoringLeadingUnderscores(string parameterName, string conventionName) - { - // Act - var result = ApiResponseTypeProvider.IsParameterNameMatch(parameterName, conventionName); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsParameterTypeMatch_ReturnsFalse_ForUnrelatedTypes() - { - // Arrange - var type = typeof(string); - var conventionType = typeof(int); - - // Act - var result = ApiResponseTypeProvider.IsParameterTypeMatch(type, conventionType); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsParameterTypeMatch_ReturnsFalse_IfTypeIsBaseClassOfConvention() - { - // Arrange - var type = typeof(BaseModel); - var conventionType = typeof(DerivedModel); - - // Act - var result = ApiResponseTypeProvider.IsParameterTypeMatch(type, conventionType); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsParameterTypeMatch_ReturnsTrue_IfTypeIsExact() - { - // Arrange - var type = typeof(Uri); - var conventionType = typeof(Uri); - - // Act - var result = ApiResponseTypeProvider.IsParameterTypeMatch(type, conventionType); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsParameterTypeMatch_ReturnsTrue_IfTypeIsSubtypeOfConvention() - { - // Arrange - var type = typeof(DerivedModel); - var conventionType = typeof(BaseModel); - - // Act - var result = ApiResponseTypeProvider.IsParameterTypeMatch(type, conventionType); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData(typeof(int))] - [InlineData(typeof(DerivedModel))] - public void IsParameterTypeMatch_ReturnsTrue_IfConventionTypeIsObject(Type type) - { - // Arrange - var conventionType = typeof(object); - - // Act - var result = ApiResponseTypeProvider.IsParameterTypeMatch(type, conventionType); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData("Get", "Post")] - [InlineData("Post", "Get")] - [InlineData("PostPerson", "Put")] - public void IsMethodNameMatch_ReturnsFalse_IfMethodIsNotPrefix(string methodName, string conventionMethodName) - { - // Act - var result = ApiResponseTypeProvider.IsMethodNameMatch(methodName, conventionMethodName); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("PostalService", "Post")] - [InlineData("Listings", "List")] - [InlineData("Putt", "Put")] - public void IsMethodNameMatch_ReturnsFalse_IfMethodIsNotProperPrefix(string methodName, string conventionMethodName) - { - // Act - var result = ApiResponseTypeProvider.IsMethodNameMatch(methodName, conventionMethodName); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMethodNameMatch_ReturnsTrue_IfMethodNameIsExactMatch() - { - // Arrange - var methodName = "Post"; - var conventionMethodName = "Post"; - - // Act - var result = ApiResponseTypeProvider.IsMethodNameMatch(methodName, conventionMethodName); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsMethodNameMatch_ReturnsFalse_IfMethodNameIsExactMatchWithDifferentCasing() - { - // Arrange - var methodName = "post"; - var conventionMethodName = "Post"; - - // Act - var result = ApiResponseTypeProvider.IsMethodNameMatch(methodName, conventionMethodName); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData("PostPerson", "Post")] - [InlineData("GetById", "Get")] - [InlineData("SearchList", "Search")] - public void IsMethodNameMatch_ReturnsTrue_IfMethodNameIsProperSuffix(string methodName, string conventionMethodName) - { - // Act - var result = ApiResponseTypeProvider.IsMethodNameMatch(methodName, conventionMethodName); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsMethodNameMatch_ReturnsFalse_IfMethodNameIsProperSuffix_WithDifferentCasing() - { - // Arrange - var methodName = "getById"; - var conventionMethodName = "Get"; - - // Act - var result = ApiResponseTypeProvider.IsMethodNameMatch(methodName, conventionMethodName); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMatch_ReturnsFalse_IfMethodNamesAreNotMatches() - { - // Arrange - var conventionMethod = typeof(DefaultApiConventions).GetMethod(nameof(DefaultApiConventions.Post)); - var method = typeof(TestController).GetMethod(nameof(TestController.GetUser)); - - // Act - var result = ApiResponseTypeProvider.IsMatch(method, conventionMethod); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMatch_ReturnsFalse_IfParameterCountsDoNotMatch() - { - // Arrange - var conventionMethod = typeof(DefaultApiConventions).GetMethod(nameof(DefaultApiConventions.Get), new[] { typeof(object) }); - var method = typeof(TestController).GetMethod(nameof(TestController.GetUserLocation)); - - // Act - var result = ApiResponseTypeProvider.IsMatch(method, conventionMethod); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMatch_ReturnsTrue_ForMethodWithObjectParameter() - { - // Arrange - var conventionMethod = typeof(DefaultApiConventions).GetMethod(nameof(DefaultApiConventions.Get), new[] { typeof(object) }); - var method = typeof(TestController).GetMethod(nameof(TestController.GetUser)); - - // Act - var result = ApiResponseTypeProvider.IsMatch(method, conventionMethod); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsMatch_ReturnsTrue_ForConventionWithGenericParameter() - { - // Arrange - var conventionMethod = typeof(DefaultApiConventions).GetMethod(nameof(DefaultApiConventions.Put)); - var method = typeof(TestController).GetMethod(nameof(TestController.PutModel)); - - // Act - var result = ApiResponseTypeProvider.IsMatch(method, conventionMethod); - - // Assert - Assert.True(result); - } - [Fact] public void GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresent() { @@ -322,9 +24,11 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer var actionDescriptor = GetControllerActionDescriptor( typeof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController), nameof(GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController.Get)); - - var filter = new FilterDescriptor(new ApiConventionAttribute(typeof(DefaultApiConventions)), FilterScope.Controller); - actionDescriptor.FilterDescriptors.Add(filter); + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new[] + { + new ProducesResponseTypeAttribute(201), + new ProducesResponseTypeAttribute(404), + }); var provider = GetProvider(); @@ -359,7 +63,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer }); } - [ApiConvention(typeof(DefaultApiConventions))] + [ApiConventionType(typeof(DefaultApiConventions))] public class GetApiResponseTypes_ReturnsResponseTypesFromActionIfPresentController : ControllerBase { [Produces(typeof(BaseModel))] @@ -369,14 +73,19 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer } [Fact] - public void GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventions() + public void GetApiResponseTypes_ReturnsResponseTypesFromApiConventionItem() { // Arrange var actionDescriptor = GetControllerActionDescriptor( typeof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController), nameof(GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController.DeleteBase)); - var filter = new FilterDescriptor(new ApiConventionAttribute(typeof(DefaultApiConventions)), FilterScope.Controller); - actionDescriptor.FilterDescriptors.Add(filter); + + actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new[] + { + new ProducesResponseTypeAttribute(200), + new ProducesResponseTypeAttribute(400), + new ProducesResponseTypeAttribute(404), + }); var provider = GetProvider(); @@ -409,133 +118,12 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer }); } - [ApiConvention(typeof(DefaultApiConventions))] + [ApiConventionType(typeof(DefaultApiConventions))] public class GetApiResponseTypes_ReturnsResponseTypesFromDefaultConventionsController : ControllerBase { public Task> DeleteBase(int id) => null; } - [Fact] - public void GetApiResponseTypes_ReturnsResponseTypesFromCustomConventions() - { - // Arrange - var actionDescriptor = GetControllerActionDescriptor( - typeof(GetApiResponseTypes_ReturnsResponseTypesFromCustomConventionsController), - nameof(GetApiResponseTypes_ReturnsResponseTypesFromCustomConventionsController.SearchModel)); - var filter = new FilterDescriptor(new ApiConventionAttribute(typeof(SearchApiConventions)), FilterScope.Controller); - actionDescriptor.FilterDescriptors.Add(filter); - - var provider = GetProvider(); - - // Act - var result = provider.GetApiResponseTypes(actionDescriptor); - - // Assert - Assert.Collection( - result.OrderBy(r => r.StatusCode), - responseType => - { - Assert.Equal(206, responseType.StatusCode); - Assert.Equal(typeof(void), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Empty(responseType.ApiResponseFormats); - }, - responseType => - { - Assert.Equal(406, responseType.StatusCode); - Assert.Equal(typeof(void), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Empty(responseType.ApiResponseFormats); - }); - } - - [ApiConvention(typeof(SearchApiConventions))] - public class GetApiResponseTypes_ReturnsResponseTypesFromCustomConventionsController : ControllerBase - { - public Task> SearchModel(string searchTerm, int page) => null; - } - - [Fact] - public void GetApiResponseTypes_ReturnsResponseTypesFromFirstMatchingConvention_WhenMultipleConventionsArePresent() - { - // Arrange - var actionDescriptor = GetControllerActionDescriptor( - typeof(GetApiResponseTypes_ReturnsResponseTypesFromFirstMatchingConventionController), - nameof(GetApiResponseTypes_ReturnsResponseTypesFromFirstMatchingConventionController.SearchModel)); - var filter = new FilterDescriptor(new ApiConventionAttribute(typeof(DefaultApiConventions)), FilterScope.Controller); - actionDescriptor.FilterDescriptors.Add(filter); - filter = new FilterDescriptor(new ApiConventionAttribute(typeof(SearchApiConventions)), FilterScope.Controller); - actionDescriptor.FilterDescriptors.Add(filter); - - var provider = GetProvider(); - - // Act - var result = provider.GetApiResponseTypes(actionDescriptor); - - // Assert - Assert.Collection( - result.OrderBy(r => r.StatusCode), - responseType => - { - Assert.Equal(206, responseType.StatusCode); - Assert.Equal(typeof(void), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Empty(responseType.ApiResponseFormats); - }, - responseType => - { - Assert.Equal(406, responseType.StatusCode); - Assert.Equal(typeof(void), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Empty(responseType.ApiResponseFormats); - }); - } - - [ApiConvention(typeof(DefaultApiConventions))] - [ApiConvention(typeof(SearchApiConventions))] - public class GetApiResponseTypes_ReturnsResponseTypesFromFirstMatchingConventionController : ControllerBase - { - public Task> Get(int id) => null; - - public Task> SearchModel(string searchTerm, int page) => null; - } - - [Fact] - public void GetApiResponseTypes_ReturnsResponseTypesFromDefaultConvention_WhenMultipleConventionsArePresent() - { - // Arrange - var actionDescriptor = GetControllerActionDescriptor( - typeof(GetApiResponseTypes_ReturnsResponseTypesFromFirstMatchingConventionController), - nameof(GetApiResponseTypes_ReturnsResponseTypesFromFirstMatchingConventionController.Get)); - var filter = new FilterDescriptor(new ApiConventionAttribute(typeof(DefaultApiConventions)), FilterScope.Controller); - actionDescriptor.FilterDescriptors.Add(filter); - filter = new FilterDescriptor(new ApiConventionAttribute(typeof(SearchApiConventions)), FilterScope.Controller); - actionDescriptor.FilterDescriptors.Add(filter); - - var provider = GetProvider(); - - // Act - var result = provider.GetApiResponseTypes(actionDescriptor); - - // Assert - Assert.Collection( - result.OrderBy(r => r.StatusCode), - responseType => - { - Assert.Equal(200, responseType.StatusCode); - Assert.Equal(typeof(void), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Empty(responseType.ApiResponseFormats); - }, - responseType => - { - Assert.Equal(404, responseType.StatusCode); - Assert.Equal(typeof(void), responseType.Type); - Assert.False(responseType.IsDefaultResponse); - Assert.Empty(responseType.ApiResponseFormats); - }); - } - [Fact] public void GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatch() { @@ -543,8 +131,6 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer var actionDescriptor = GetControllerActionDescriptor( typeof(GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatchController), nameof(GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatchController.PostModel)); - var filter = new FilterDescriptor(new ApiConventionAttribute(typeof(DefaultApiConventions)), FilterScope.Controller); - actionDescriptor.FilterDescriptors.Add(filter); var provider = GetProvider(); @@ -565,7 +151,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer }); } - [ApiConvention(typeof(DefaultApiConventions))] + [ApiConventionType(typeof(DefaultApiConventions))] public class GetApiResponseTypes_ReturnsDefaultResultsIfNoConventionsMatchController : ControllerBase { public Task> PostModel(int id, BaseModel model) => null; diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs new file mode 100644 index 0000000000..edc7ce7747 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionTypeAttributeTest.cs @@ -0,0 +1,86 @@ +// Copyright (c) .NET Foundation. 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.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc +{ + public class ApiConventionTypeAttributeTest + { + [Fact] + public void Constructor_ThrowsIfConventionMethodIsAnnotatedWithProducesAttribute() + { + // Arrange + var expected = $"Method {typeof(ConventionWithProducesAttribute).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" + + Environment.NewLine + + typeof(ProducesAttribute).FullName + + Environment.NewLine + + $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionTypeAttribute(typeof(ConventionWithProducesAttribute)), + "conventionType", + expected); + } + + public static class ConventionWithProducesAttribute + { + [Produces(typeof(void))] + public static void Get() { } + } + + [Fact] + public void Constructor_ThrowsIfConventionMethodHasRouteAttribute() + { + // Arrange + var expected = $"Method {typeof(ConventionWithRouteAttribute).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" + + Environment.NewLine + + typeof(HttpGetAttribute).FullName + + Environment.NewLine + + $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionTypeAttribute(typeof(ConventionWithRouteAttribute)), + "conventionType", + expected); + } + + public static class ConventionWithRouteAttribute + { + [HttpGet("url")] + public static void Get() { } + } + + [Fact] + public void Constructor_ThrowsIfMultipleUnsupportedAttributesArePresentOnConvention() + { + // Arrange + var expected = $"Method {typeof(ConventionWitUnsupportedAttributes).FullName + ".Get"} is decorated with the following attributes that are not allowed on an API convention method:" + + Environment.NewLine + + string.Join(Environment.NewLine, typeof(ProducesAttribute).FullName, typeof(ServiceFilterAttribute).FullName, typeof(AuthorizeAttribute).FullName) + + Environment.NewLine + + $"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}"; + + // Act & Assert + ExceptionAssert.ThrowsArgument( + () => new ApiConventionTypeAttribute(typeof(ConventionWitUnsupportedAttributes)), + "conventionType", + expected); + } + + public static class ConventionWitUnsupportedAttributes + { + [ProducesResponseType(400)] + [Produces(typeof(void))] + [ServiceFilter(typeof(object))] + [Authorize] + public static void Get() { } + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs new file mode 100644 index 0000000000..69079bd8b0 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs @@ -0,0 +1,754 @@ +// Copyright (c) .NET Foundation. 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.Linq; +using System.Reflection; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + public class ApiConventionResultTest + { + [Fact] + public void GetApiConvention_ReturnsNull_IfNoConventionMatches() + { + // Arrange + var method = typeof(GetApiConvention_ReturnsNull_IfNoConventionMatchesController).GetMethod(nameof(GetApiConvention_ReturnsNull_IfNoConventionMatchesController.NoMatch)); + var attribute = new ApiConventionTypeAttribute(typeof(DefaultApiConventions)); + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, new[] { attribute }, out var conventionResult); + + // Assert + Assert.False(result); + Assert.Null(conventionResult); + } + + public class GetApiConvention_ReturnsNull_IfNoConventionMatchesController + { + public IActionResult NoMatch(int id) => null; + } + + [Fact] + public void GetApiConvention_ReturnsResultFromConvention() + { + // Arrange + var method = typeof(GetApiConvention_ReturnsResultFromConventionController) + .GetMethod(nameof(GetApiConvention_ReturnsResultFromConventionController.Match)); + var attribute = new ApiConventionTypeAttribute(typeof(GetApiConvention_ReturnsResultFromConventionType)); + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, new[] { attribute }, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.Equal(201, r.StatusCode), + r => Assert.Equal(403, r.StatusCode)); + } + + public class GetApiConvention_ReturnsResultFromConventionController + { + public IActionResult Match(int id) => null; + } + + public static class GetApiConvention_ReturnsResultFromConventionType + { + [ProducesResponseType(200)] + [ProducesResponseType(202)] + [ProducesResponseType(404)] + public static void Get(int id) { } + + [ProducesResponseType(201)] + [ProducesResponseType(403)] + public static void Match(int id) { } + } + + [Fact] + public void GetApiConvention_ReturnsResultFromFirstMatchingConvention() + { + // Arrange + var method = typeof(GetApiConvention_ReturnsResultFromFirstMatchingConventionController) + .GetMethod(nameof(GetApiConvention_ReturnsResultFromFirstMatchingConventionController.Get)); + var attributes = new[] + { + new ApiConventionTypeAttribute(typeof(GetApiConvention_ReturnsResultFromConventionType)), + new ApiConventionTypeAttribute(typeof(DefaultApiConventions)), + }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, attributes, result: out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.Equal(200, r.StatusCode), + r => Assert.Equal(202, r.StatusCode), + r => Assert.Equal(404, r.StatusCode)); + } + + public class GetApiConvention_ReturnsResultFromFirstMatchingConventionController + { + public IActionResult Get(int id) => null; + } + + [Fact] + public void GetApiConvention_GetAction_MatchesDefaultConvention() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.GetUser)); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, attributes, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.Equal(200, r.StatusCode), + r => Assert.Equal(404, r.StatusCode)); + } + + [Fact] + public void GetApiConvention_PostAction_MatchesDefaultConvention() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.PostUser)); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, attributes, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.Equal(201, r.StatusCode), + r => Assert.Equal(400, r.StatusCode)); + } + + [Fact] + public void GetApiConvention_PutAction_MatchesDefaultConvention() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.PutUser)); + var conventions = new[] + { + new ApiConventionTypeAttribute(typeof(DefaultApiConventions)), + }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, conventions, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.Equal(204, r.StatusCode), + r => Assert.Equal(400, r.StatusCode), + r => Assert.Equal(404, r.StatusCode)); + } + + [Fact] + public void GetApiConvention_DeleteAction_MatchesDefaultConvention() + { + // Arrange + var method = typeof(DefaultConventionController) + .GetMethod(nameof(DefaultConventionController.Delete)); + var conventions = new[] + { + new ApiConventionTypeAttribute(typeof(DefaultApiConventions)), + }; + + // Act + var result = ApiConventionResult.TryGetApiConvention(method, conventions, out var conventionResult); + + // Assert + Assert.True(result); + Assert.Collection( + conventionResult.ResponseMetadataProviders.OrderBy(o => o.StatusCode), + r => Assert.Equal(200, r.StatusCode), + r => Assert.Equal(400, r.StatusCode), + r => Assert.Equal(404, r.StatusCode)); + } + + public class DefaultConventionController + { + public IActionResult GetUser(Guid id) => null; + + public IActionResult PostUser(User user) => null; + + public IActionResult PutUser(Guid userId, User user) => null; + + public IActionResult Delete(Guid userId) => null; + } + + public class User { } + + [Theory] + [InlineData("Method", "method")] + [InlineData("Method", "ConventionMethod")] + [InlineData("p", "model")] + [InlineData("person", "model")] + public void IsNameMatch_WithAny_AlwaysReturnsTrue(string name, string conventionName) + { + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfNamesDifferInCase() + { + // Arrange + var name = "Name"; + var conventionName = "name"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "Name"; + var conventionName = "Different"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSubString() + { + // Arrange + var name = "RegularName"; + var conventionName = "Regular"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSuperString() + { + // Arrange + var name = "Regular"; + var conventionName = "RegularName"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsTrue_IfExactMatch() + { + // Arrange + var name = "parameterName"; + var conventionName = "parameterName"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsTrue_IfNamesAreExact() + { + // Arrange + var name = "PostPerson"; + var conventionName = "PostPerson"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsTrue_IfNameIsProperPrefix() + { + // Arrange + var name = "PostPerson"; + var conventionName = "Post"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "GetPerson"; + var conventionName = "Post"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesDifferInCase() + { + // Arrange + var name = "GetPerson"; + var conventionName = "post"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsNotProperPrfix() + { + // Arrange + var name = "Postman"; + var conventionName = "Post"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsSuffix() + { + // Arrange + var name = "GoPost"; + var conventionName = "Post"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "name"; + var conventionName = "diff"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnsFalse_IfNameIsNotSuffix() + { + // Arrange + var name = "personId"; + var conventionName = "idx"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsExact() + { + // Arrange + var name = "test"; + var conventionName = "test"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnFalse_IfNameDiffersInCase() + { + // Arrange + var name = "test"; + var conventionName = "Test"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsProperSuffix() + { + // Arrange + var name = "personId"; + var conventionName = "id"; + + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("candid", "id")] + [InlineData("canDid", "id")] + public void IsNameMatch_WithSuffix_ReturnFalse_IfNameIsNotProperSuffix(string name, string conventionName) + { + // Act + var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(typeof(object), typeof(object))] + [InlineData(typeof(int), typeof(void))] + [InlineData(typeof(string), typeof(DateTime))] + public void IsTypeMatch_WithAny_ReturnsTrue(Type type, Type conventionType) + { + // Act + var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssinableFrom_ReturnsTrueForExact() + { + // Arrange + var type = typeof(Base); + var conventionType = typeof(Base); + + // Act + var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssinableFrom_ReturnsTrueForDerived() + { + // Arrange + var type = typeof(Derived); + var conventionType = typeof(Base); + + // Act + var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssinableFrom_ReturnsFalseForBaseTypes() + { + // Arrange + var type = typeof(Base); + var conventionType = typeof(Derived); + + // Act + var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsTypeMatch_WithAssinableFrom_ReturnsFalseForUnrelated() + { + // Arrange + var type = typeof(string); + var conventionType = typeof(Derived); + + // Act + var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsFalse_IfMethodNamesDoNotMatch() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Post)); + + // Act + var result = ApiConventionResult.IsMatch(method, conventionMethod); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsFalse_IMethodHasMoreParametersThanConvention() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetNoArgs)); + + // Act + var result = ApiConventionResult.IsMatch(method, conventionMethod); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsFalse_IfMethodHasFewerParametersThanConvention() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetTwoArgs)); + + // Act + var result = ApiConventionResult.IsMatch(method, conventionMethod); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsFalse_IfParametersDoNotMatch() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetParameterNotMatching)); + + // Act + var result = ApiConventionResult.IsMatch(method, conventionMethod); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsMatch_ReturnsTrue_IfMethodNameAndParametersMatchs() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Get)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Get)); + + // Act + var result = ApiConventionResult.IsMatch(method, conventionMethod); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsMatch_ReturnsTrue_IfParamsArrayMatchesRemainingArguments() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.Search)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Search)); + + // Act + var result = ApiConventionResult.IsMatch(method, conventionMethod); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsMatch_WithEmpty_MatchesMethodWithNoParameters() + { + // Arrange + var method = typeof(TestController).GetMethod(nameof(TestController.SearchEmpty)); + var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.SearchWithParams)); + + // Act + var result = ApiConventionResult.IsMatch(method, conventionMethod); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsExact_WhenNoAttributesArePresent() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Exact; + var attributes = new object[0]; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionResult.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsExact_WhenNoNameMatchBehaviorAttributeIsSpecified() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Exact; + var attributes = new object[] { new CLSCompliantAttribute(false), new ProducesResponseTypeAttribute(200) }; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionResult.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Prefix; + var attributes = new object[] + { + new CLSCompliantAttribute(false), + new ApiConventionNameMatchAttribute(expected), + new ProducesResponseTypeAttribute(200) } + ; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionResult.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoAttributesArePresent() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.AssignableFrom; + var attributes = new object[0]; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionResult.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoMatchingAttributesArePresent() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.AssignableFrom; + var attributes = new object[] { new CLSCompliantAttribute(false), new ProducesResponseTypeAttribute(200) }; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionResult.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.Any; + var attributes = new object[] + { + new CLSCompliantAttribute(false), + new ApiConventionTypeMatchAttribute(expected), + new ProducesResponseTypeAttribute(200) } + ; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionResult.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + public class Base { } + + public class Derived : Base { } + + public class TestController + { + public IActionResult Get(int id) => null; + + public IActionResult Search(string searchTerm, bool sortDescending, int page) => null; + + public IActionResult SearchEmpty() => null; + } + + public static class TestConvention + { + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Get(int id) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void GetNoArgs() { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void GetTwoArgs(int id, string name) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Post(Derived model) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void GetParameterNotMatching([ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.AssignableFrom)] Derived model) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void Search( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Exact)] + string searchTerm, + params object[] others) + { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void SearchWithParams(params object[] others) { } + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs index c0c83f07ef..25486902c3 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/Internal/ApiBehaviorApplicationModelProviderTest.cs @@ -9,7 +9,9 @@ using System.Reflection.Emit; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Authorization; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging.Abstractions; @@ -875,47 +877,107 @@ Environment.NewLine + "int b"; } [Fact] - public void ApiConventionAttributeIsNotAdded_IfModelAlreadyHasAttribute() + public void DiscoverApiConvention_DoesNotAddConventionItem_IfActionHasProducesResponseTypeAttribute() { // Arrange - var attribute = new ApiConventionAttribute(typeof(DefaultApiConventions)); - var controllerType = CreateTestControllerType(); - - var model = new ControllerModel(controllerType.GetTypeInfo(), new[] { attribute }) - { - Filters = { attribute, }, - }; + var actionModel = new ActionModel( + typeof(TestApiConventionController).GetMethod(nameof(TestApiConventionController.Delete)), + Array.Empty()); + actionModel.Filters.Add(new ProducesResponseTypeAttribute(200)); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; // Act - ApiBehaviorApplicationModelProvider.AddGloballyConfiguredApiConventions(model); + ApiBehaviorApplicationModelProvider.DiscoverApiConvention(actionModel, attributes); // Assert - Assert.Collection( - model.Filters, - filter => Assert.Same(attribute, filter)); + Assert.Empty(actionModel.Properties); } [Fact] - public void ApiConventionAttributeIsAdded_IfAttributeExistsInAssembly() + public void DiscoverApiConvention_DoesNotAddConventionItem_IfActionHasProducesAttribute() { // Arrange - var controllerType = CreateTestControllerType(); - var model = new ControllerModel(controllerType.GetTypeInfo(), Array.Empty()); + var actionModel = new ActionModel( + typeof(TestApiConventionController).GetMethod(nameof(TestApiConventionController.Delete)), + Array.Empty()); + actionModel.Filters.Add(new ProducesAttribute(typeof(object))); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; // Act - ApiBehaviorApplicationModelProvider.AddGloballyConfiguredApiConventions(model); + ApiBehaviorApplicationModelProvider.DiscoverApiConvention(actionModel, attributes); + + // Assert + Assert.Empty(actionModel.Properties); + } + + [Fact] + public void DiscoverApiConvention_DoesNotAddConventionItem_IfNoConventionMatches() + { + // Arrange + var actionModel = new ActionModel( + typeof(TestApiConventionController).GetMethod(nameof(TestApiConventionController.NoMatch)), + Array.Empty()); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; + + // Act + ApiBehaviorApplicationModelProvider.DiscoverApiConvention(actionModel, attributes); + + // Assert + Assert.Empty(actionModel.Properties); + } + + [Fact] + public void DiscoverApiConvention_AddsConventionItem_IfConventionMatches() + { + // Arrange + var actionModel = new ActionModel( + typeof(TestApiConventionController).GetMethod(nameof(TestApiConventionController.Delete)), + Array.Empty()); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; + + // Act + ApiBehaviorApplicationModelProvider.DiscoverApiConvention(actionModel, attributes); // Assert Assert.Collection( - model.Filters, - filter => Assert.IsType(filter)); + actionModel.Properties, + kvp => + { + Assert.Equal(typeof(ApiConventionResult), kvp.Key); + Assert.NotNull(kvp.Value); + }); + } + + [Fact] + public void DiscoverApiConvention_AddsConventionItem_IfActionHasNonConventionBasedFilters() + { + // Arrange + var actionModel = new ActionModel( + typeof(TestApiConventionController).GetMethod(nameof(TestApiConventionController.Delete)), + Array.Empty()); + actionModel.Filters.Add(new AuthorizeFilter()); + actionModel.Filters.Add(new ServiceFilterAttribute(typeof(object))); + actionModel.Filters.Add(new ConsumesAttribute("application/xml")); + var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) }; + + // Act + ApiBehaviorApplicationModelProvider.DiscoverApiConvention(actionModel, attributes); + + // Assert + Assert.Collection( + actionModel.Properties, + kvp => + { + Assert.Equal(typeof(ApiConventionResult), kvp.Key); + Assert.NotNull(kvp.Value); + }); } // A dynamically generated type in an assembly that has an ApiConventionAttribute. private static TypeBuilder CreateTestControllerType() { var attributeBuilder = new CustomAttributeBuilder( - typeof(ApiConventionAttribute).GetConstructor(new[] { typeof(Type) }), + typeof(ApiConventionTypeAttribute).GetConstructor(new[] { typeof(Type) }), new[] { typeof(DefaultApiConventions) }); var assemblyName = new AssemblyName("TestAssembly"); @@ -1202,6 +1264,13 @@ Environment.NewLine + "int b"; public IActionResult Action([ModelBinder(typeof(object))] Car car) => null; } + private class TestApiConventionController + { + public IActionResult NoMatch() => null; + + public IActionResult Delete(int id) => null; + } + private class GpsCoordinates { public long Latitude { get; set; } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs index a6a69bfccd..7490fe2f7f 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -1148,6 +1148,189 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests Assert.Equal("multipart/form-data", requestFormat.MediaType); } + [Fact] + public Task ApiConvention_ForGetMethod_ReturningModel() => ApiConvention_ForGetMethod("GetProduct"); + + [Fact] + public Task ApiConvention_ForGetMethod_ReturningTaskOfActionResultOfModel() => ApiConvention_ForGetMethod("GetTaskOfActionResultOfProduct"); + + private async Task ApiConvention_ForGetMethod(string action) + { + // Act + var response = await Client.GetStringAsync( + $"ApiExplorerResponseTypeWithApiConventionController/{action}"); + var result = JsonConvert.DeserializeObject>(response); + + // Assert + var description = Assert.Single(result); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }); + } + + [Fact] + public async Task ApiConvention_ForGetMethodThatDoesNotMatchConvention() + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.GetStringAsync( + $"ApiExplorerResponseTypeWithApiConventionController/GetProducts"); + var result = JsonConvert.DeserializeObject>(response); + + // Assert + var description = Assert.Single(result); + + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(IEnumerable).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + var actualMediaTypes = responseType.ResponseFormats.Select(r => r.MediaType).OrderBy(r => r); + Assert.Equal(expectedMediaTypes, actualMediaTypes); + }); + } + + [Fact] + public async Task ApiConvention_ForMethodWithResponseTypeAttributes() + { + // Arrange + var expectedMediaTypes = new[] { "application/json", "application/xml", "text/json", "text/xml" }; + + // Act + var response = await Client.PostAsync( + $"ApiExplorerResponseTypeWithApiConventionController/PostWithConventions", + new StringContent(string.Empty)); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(202, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(403, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }); + } + + [Fact] + public async Task ApiConvention_ForPostMethodThatMatchesConvention() + { + // Act + var response = await Client.PostAsync( + $"ApiExplorerResponseTypeWithApiConventionController/PostTaskOfProduct", + new StringContent(string.Empty)); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(201, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(400, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }); + } + + [Fact] + public async Task ApiConvention_ForPutActionThatMatchesConvention() + { + // Act + var response = await Client.PutAsync( + $"ApiExplorerResponseTypeWithApiConventionController/Put", + new StringContent(string.Empty)); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(204, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(400, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }); + } + + [Fact] + public async Task ApiConvention_ForDeleteActionThatMatchesConvention() + { + // Act + var response = await Client.DeleteAsync( + $"ApiExplorerResponseTypeWithApiConventionController/DeleteProductAsync"); + var responseBody = await response.EnsureSuccessStatusCode().Content.ReadAsStringAsync(); + var result = JsonConvert.DeserializeObject>(responseBody); + + // Assert + var description = Assert.Single(result); + Assert.Collection( + description.SupportedResponseTypes.OrderBy(r => r.StatusCode), + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(200, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(400, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }, + responseType => + { + Assert.Equal(typeof(void).FullName, responseType.ResponseType); + Assert.Equal(404, responseType.StatusCode); + Assert.Empty(responseType.ResponseFormats); + }); + } + private IEnumerable GetSortedMediaTypes(ApiExplorerResponseType apiResponseType) { return apiResponseType.ResponseFormats diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs new file mode 100644 index 0000000000..4a63fc746a --- /dev/null +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. 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.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace ApiExplorerWebSite +{ + [ApiController] + [Route("ApiExplorerResponseTypeWithApiConventionController/[Action]")] + [ApiConventionType(typeof(DefaultApiConventions))] + public class ApiExplorerResponseTypeWithApiConventionController : Controller + { + [HttpGet] + public Product GetProduct(int id) => null; + + [HttpGet] + public Task> GetTaskOfActionResultOfProduct(int id) => null; + + [HttpGet] + public IEnumerable GetProducts() => null; + + [HttpPost] + [Produces("application/json")] + [ProducesResponseType(202)] + [ProducesResponseType(403)] + public IActionResult PostWithConventions() => null; + + [HttpPost] + public Task PostTaskOfProduct(Product p) => null; + + [HttpPut] + public Task Put(string id, Product product) => null; + + [HttpDelete] + public Task DeleteProductAsync(object id) => null; + } +} \ No newline at end of file