Change DefaultApiConventions (#7939)

* Change DefaultApiConventions

* Introduce attributes for matching by name and type.

* Move discovery of ApiConventionAttribute to ApiBehaviorApplicationModelProvider. This is required
for us to detect during startup if the convention is incorrectly authored.
This commit is contained in:
Pranav K 2018-06-25 08:24:30 -07:00 committed by GitHub
parent 53857d052f
commit 94a7c83998
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1678 additions and 635 deletions

View File

@ -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<IApiResponseMetadataProvider>();
}
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<IApiResponseMetadataProvider>()
.ToArray();
}
}
return Array.Empty<IApiResponseMetadataProvider>();
}
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<IApiResponseMetadataProvider> GetResponseMetadataAttributes(ControllerActionDescriptor action)
{
if (action.FilterDescriptors == null)
{
@ -107,7 +68,7 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
}
private IList<ApiResponseType> GetApiResponseTypes(
IApiResponseMetadataProvider[] responseMetadataAttributes,
IReadOnlyList<IApiResponseMetadataProvider> responseMetadataAttributes,
Type type)
{
var results = new List<ApiResponseType>();
@ -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);
}
}
}

View File

@ -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; }
}
}

View File

@ -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
{
/// <summary>
/// API conventions to be applied to an assembly containing MVC controllers or a single controller.
/// <para>
/// 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 <see cref="ApiConventionNameMatchAttribute" />
/// that may be applied to a method name or it's parameters and <see cref="ApiConventionTypeMatchAttribute"/>
/// that are applied to parameters.
/// </para>
/// <para>
/// When no attributes are found specifying the behavior, MVC matches method names and parameter names are matched
/// using <see cref="ApiConventionNameMatchBehavior.Exact"/> and parameter types are matched
/// using <see cref="ApiConventionTypeMatchBehavior.AssignableFrom"/>.
/// </para>
/// </summary>
[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class ApiConventionTypeAttribute : Attribute
{
/// <summary>
/// Initializes an <see cref="ApiConventionTypeAttribute"/> instance using <paramref name="conventionType"/>.
/// </summary>
/// <param name="conventionType">
/// The <see cref="Type"/> of the convention.
/// <para>
/// Conventions must be static types. Methods in a convention are
/// matched to an action method using rules specified by <see cref="ApiConventionNameMatchAttribute" />
/// that may be applied to a method name or it's parameters and <see cref="ApiConventionTypeMatchAttribute"/>
/// that are applied to parameters.
/// </para>
/// </param>
public ApiConventionTypeAttribute(Type conventionType)
{
ConventionType = conventionType ?? throw new ArgumentNullException(nameof(conventionType));
EnsureValid(conventionType);
}
/// <summary>
/// Gets the convention type.
/// </summary>
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;
}
}
}

View File

@ -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
{
/// <summary>
/// Determines the matching behavior an API convention method or parameter by name.
/// <see cref="ApiConventionNameMatchBehavior"/> for supported options.
/// <seealso cref="ApiConventionTypeAttribute"/>.
/// </summary>
/// <remarks>
/// <see cref="ApiConventionNameMatchBehavior.Exact"/> is used if no value for this
/// attribute is specified on a convention method or parameter.
/// </remarks>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class ApiConventionNameMatchAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of <see cref="ApiConventionNameMatchAttribute"/>.
/// </summary>
/// <param name="matchBehavior">The <see cref="ApiConventionNameMatchBehavior"/>.</param>
public ApiConventionNameMatchAttribute(ApiConventionNameMatchBehavior matchBehavior)
{
MatchBehavior = matchBehavior;
}
/// <summary>
/// Gets the <see cref="ApiConventionNameMatchBehavior"/>.
/// </summary>
public ApiConventionNameMatchBehavior MatchBehavior { get; }
}
}

View File

@ -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
{
/// <summary>
/// The behavior for matching the name of a convention parameter or method.
/// </summary>
public enum ApiConventionNameMatchBehavior
{
/// <summary>
/// Matches any name. Use this if the parameter or method name does not need to be matched.
/// </summary>
Any,
/// <summary>
/// The parameter or method name must exactly match the convention.
/// </summary>
Exact,
/// <summary>
/// The parameter or method name in the convention is a proper prefix.
/// <para>
/// 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".
/// </para>
/// </summary>
Prefix,
/// <summary>
/// The parameter or method name in the convention is a proper suffix.
/// <para>
/// 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".
/// </para>
/// </summary>
Suffix,
}
}

View File

@ -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
{
/// <summary>
/// Metadata associated with an action method via API convention.
/// </summary>
public sealed class ApiConventionResult
{
public ApiConventionResult(IReadOnlyList<IApiResponseMetadataProvider> responseMetadataProviders)
{
ResponseMetadataProviders = responseMetadataProviders ??
throw new ArgumentNullException(nameof(responseMetadataProviders));
}
public IReadOnlyList<IApiResponseMetadataProvider> 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<IApiResponseMetadataProvider>()
.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<ApiConventionNameMatchAttribute>(attributeProvider);
return attribute?.MatchBehavior ?? ApiConventionNameMatchBehavior.Exact;
}
internal static ApiConventionTypeMatchBehavior GetTypeMatchBehavior(ICustomAttributeProvider attributeProvider)
{
var attribute = GetCustomAttribute<ApiConventionTypeMatchAttribute>(attributeProvider);
return attribute?.MatchBehavior ?? ApiConventionTypeMatchBehavior.AssignableFrom;
}
private static TAttribute GetCustomAttribute<TAttribute>(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;
}
}
}
}

View File

@ -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
{
/// <summary>
/// Determines the matching behavior an API convention parameter by type.
/// <see cref="ApiConventionTypeMatchBehavior"/> for supported options.
/// <seealso cref="ApiConventionTypeAttribute"/>.
/// </summary>
/// <remarks>
/// <see cref="ApiConventionTypeMatchBehavior.AssignableFrom"/> is used if no value for this
/// attribute is specified on a convention parameter.
/// </remarks>
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
public sealed class ApiConventionTypeMatchAttribute : Attribute
{
public ApiConventionTypeMatchAttribute(ApiConventionTypeMatchBehavior matchBehavior)
{
MatchBehavior = matchBehavior;
}
public ApiConventionTypeMatchBehavior MatchBehavior { get; }
}
}

View File

@ -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
{
/// <summary>
/// The behavior for matching the name of a convention parameter.
/// </summary>
public enum ApiConventionTypeMatchBehavior
{
/// <summary>
/// Matches any type. Use this if the parameter does not need to be matched.
/// </summary>
Any,
/// <summary>
/// The parameter in the convention is the exact type or a subclass of the type
/// specified in the convention.
/// </summary>
AssignableFrom,
}
}

View File

@ -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<TModel>(object id) { }
[ProducesResponseType(StatusCodes.Status200OK)]
public static void Get<TModel>() { }
[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>(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<TModel>(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<TModel>(object id) { }
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
public static void Delete(
[ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)]
[ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)]
object id) { }
}
}

View File

@ -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<ApiConventionTypeAttribute>().ToArray();
if (conventions.Length == 0)
{
var controllerAssembly = controllerModel.ControllerType.Assembly;
conventions = controllerAssembly.GetCustomAttributes<ApiConventionTypeAttribute>().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<ApiConventionAttribute>().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<ApiConventionAttribute>())
{
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<IApiResponseMetadataProvider>().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))

View File

@ -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);
/// <summary>
/// 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}.
/// </summary>
internal static string ApiConvention_UnsupportedAttributesOnConvention
{
get => GetString("ApiConvention_UnsupportedAttributesOnConvention");
}
/// <summary>
/// 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}.
/// </summary>
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);

View File

@ -448,4 +448,7 @@
<data name="InvalidTypeTForActionResultOfT" xml:space="preserve">
<value>Invalid type parameter '{0}' specified for '{1}'.</value>
</data>
<data name="ApiConvention_UnsupportedAttributesOnConvention" xml:space="preserve">
<value>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}.</value>
</data>
</root>

View File

@ -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<ActionResult<BaseModel>> 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<ActionResult<BaseModel>> 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<ActionResult<BaseModel>> Get(int id) => null;
public Task<ActionResult<BaseModel>> 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<ActionResult<BaseModel>> PostModel(int id, BaseModel model) => null;

View File

@ -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() { }
}
}
}

View File

@ -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<ICustomAttributeProvider>(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<ICustomAttributeProvider>(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<ICustomAttributeProvider>(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<ICustomAttributeProvider>(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<ICustomAttributeProvider>(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<ICustomAttributeProvider>(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) { }
}
}
}

View File

@ -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<object>());
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<object>());
var actionModel = new ActionModel(
typeof(TestApiConventionController).GetMethod(nameof(TestApiConventionController.Delete)),
Array.Empty<object>());
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<object>());
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<object>());
var attributes = new[] { new ApiConventionTypeAttribute(typeof(DefaultApiConventions)) };
// Act
ApiBehaviorApplicationModelProvider.DiscoverApiConvention(actionModel, attributes);
// Assert
Assert.Collection(
model.Filters,
filter => Assert.IsType<ApiConventionAttribute>(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<object>());
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; }

View File

@ -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<List<ApiExplorerData>>(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<List<ApiExplorerData>>(response);
// Assert
var description = Assert.Single(result);
Assert.Collection(
description.SupportedResponseTypes.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.Equal(typeof(IEnumerable<ApiExplorerWebSite.Product>).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<List<ApiExplorerData>>(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<List<ApiExplorerData>>(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<List<ApiExplorerData>>(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<List<ApiExplorerData>>(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<string> GetSortedMediaTypes(ApiExplorerResponseType apiResponseType)
{
return apiResponseType.ResponseFormats

View File

@ -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<ActionResult<Product>> GetTaskOfActionResultOfProduct(int id) => null;
[HttpGet]
public IEnumerable<Product> GetProducts() => null;
[HttpPost]
[Produces("application/json")]
[ProducesResponseType(202)]
[ProducesResponseType(403)]
public IActionResult PostWithConventions() => null;
[HttpPost]
public Task<IActionResult> PostTaskOfProduct(Product p) => null;
[HttpPut]
public Task<IActionResult> Put(string id, Product product) => null;
[HttpDelete]
public Task<IActionResult> DeleteProductAsync(object id) => null;
}
}