Introduce ApiConventionMethodAttribute

Fixes #8147
This commit is contained in:
Pranav K 2018-07-26 12:09:59 -07:00
parent 29f3e94fd1
commit 0102d4efab
No known key found for this signature in database
GPG Key ID: 1963DA6D96C3057A
17 changed files with 503 additions and 20 deletions

View File

@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers
public ApiControllerSymbolCache(Compilation compilation)
{
ActionResultOfT = compilation.GetTypeByMetadataName(SymbolNames.ActionResultOfT);
ApiConventionMethodAttribute = compilation.GetTypeByMetadataName(SymbolNames.ApiConventionMethodAttribute);
ApiConventionNameMatchAttribute = compilation.GetTypeByMetadataName(SymbolNames.ApiConventionNameMatchAttribute);
ApiConventionTypeAttribute = compilation.GetTypeByMetadataName(SymbolNames.ApiConventionTypeAttribute);
ApiConventionTypeMatchAttribute = compilation.GetTypeByMetadataName(SymbolNames.ApiConventionTypeMatchAttribute);
@ -30,6 +31,8 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers
public INamedTypeSymbol ActionResultOfT { get; }
public INamedTypeSymbol ApiConventionMethodAttribute { get; }
public INamedTypeSymbol ApiConventionNameMatchAttribute { get; }
public INamedTypeSymbol ApiConventionTypeAttribute { get; }

View File

@ -36,6 +36,58 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers
ApiControllerSymbolCache symbolCache,
IMethodSymbol method,
IReadOnlyList<AttributeData> attributes)
{
var conventionMethod = GetMethodFromConventionMethodAttribute(symbolCache, method);
if (conventionMethod == null)
{
conventionMethod = MatchConventionMethod(symbolCache, method, attributes);
}
if (conventionMethod != null)
{
return GetResponseMetadataFromMethodAttributes(symbolCache, conventionMethod);
}
return Array.Empty<DeclaredApiResponseMetadata>();
}
private static IMethodSymbol GetMethodFromConventionMethodAttribute(ApiControllerSymbolCache symbolCache, IMethodSymbol method)
{
var attribute = method.GetAttributes(symbolCache.ApiConventionMethodAttribute, inherit: true)
.FirstOrDefault();
if (attribute == null)
{
return null;
}
if (attribute.ConstructorArguments.Length != 2)
{
return null;
}
if (attribute.ConstructorArguments[0].Kind != TypedConstantKind.Type ||
!(attribute.ConstructorArguments[0].Value is ITypeSymbol conventionType))
{
return null;
}
if (attribute.ConstructorArguments[1].Kind != TypedConstantKind.Primitive ||
!(attribute.ConstructorArguments[1].Value is string conventionMethodName))
{
return null;
}
var conventionMethod = conventionType.GetMembers(conventionMethodName)
.FirstOrDefault(m => m.Kind == SymbolKind.Method && m.IsStatic && m.DeclaredAccessibility == Accessibility.Public);
return (IMethodSymbol)conventionMethod;
}
private static IMethodSymbol MatchConventionMethod(
ApiControllerSymbolCache symbolCache,
IMethodSymbol method,
IReadOnlyList<AttributeData> attributes)
{
foreach (var attribute in attributes)
{
@ -53,16 +105,14 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers
continue;
}
if (!SymbolApiConventionMatcher.IsMatch(symbolCache, method, conventionMethod))
if (SymbolApiConventionMatcher.IsMatch(symbolCache, method, conventionMethod))
{
continue;
return conventionMethod;
}
return GetResponseMetadataFromMethodAttributes(symbolCache, conventionMethod);
}
}
return Array.Empty<DeclaredApiResponseMetadata>();
return null;
}
private static IList<DeclaredApiResponseMetadata> GetResponseMetadataFromMethodAttributes(ApiControllerSymbolCache symbolCache, IMethodSymbol methodSymbol)

View File

@ -7,6 +7,8 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers
{
public const string AllowAnonymousAttribute = "Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute";
public const string ApiConventionMethodAttribute = "Microsoft.AspNetCore.Mvc.ApiConventionMethodAttribute";
public const string ApiConventionNameMatchAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorer.ApiConventionNameMatchAttribute";
public const string ApiConventionTypeMatchAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorer.ApiConventionTypeMatchAttribute";

View File

@ -0,0 +1,76 @@
// 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;
namespace Microsoft.AspNetCore.Mvc
{
/// <summary>
/// API conventions to be applied to a controller action.
/// <para>
/// API conventions are used to influence the output of ApiExplorer.
/// <see cref="ApiConventionMethodAttribute"/> can be used to specify an exact convention method that applies
/// to an action. <seealso cref="ApiConventionTypeAttribute"/> for details about applying conventions at
/// the assembly or controller level.
/// </para>
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class ApiConventionMethodAttribute : Attribute
{
/// <summary>
/// Initializes an <see cref="ApiConventionMethodAttribute"/> instance using <paramref name="conventionType"/> and
/// the specified <paramref name="methodName"/>.
/// </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>
/// <param name="methodName">The method name.</param>
public ApiConventionMethodAttribute(Type conventionType, string methodName)
{
ConventionType = conventionType ?? throw new ArgumentNullException(nameof(conventionType));
ApiConventionTypeAttribute.EnsureValid(conventionType);
if (string.IsNullOrEmpty(methodName))
{
throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(methodName));
}
Method = GetConventionMethod(conventionType, methodName);
}
private static MethodInfo GetConventionMethod(Type conventionType, string methodName)
{
var methods = conventionType.GetMethods(BindingFlags.Public | BindingFlags.Static)
.Where(method => method.Name == methodName)
.ToArray();
if (methods.Length == 0)
{
throw new ArgumentException(Resources.FormatApiConventionMethod_NoMethodFound(methodName, conventionType), nameof(methodName));
}
else if (methods.Length > 1)
{
throw new ArgumentException(Resources.FormatApiConventionMethod_AmbigiousMethodName(methodName, conventionType), nameof(methodName));
}
return methods[0];
}
/// <summary>
/// Gets the convention type.
/// </summary>
public Type ConventionType { get; }
internal MethodInfo Method { get; }
}
}

View File

@ -51,7 +51,7 @@ namespace Microsoft.AspNetCore.Mvc
/// </summary>
public Type ConventionType { get; }
private static void EnsureValid(Type conventionType)
internal static void EnsureValid(Type conventionType)
{
if (!conventionType.IsSealed || !conventionType.IsAbstract)
{

View File

@ -26,31 +26,38 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
ApiConventionTypeAttribute[] apiConventionAttributes,
out ApiConventionResult result)
{
foreach (var attribute in apiConventionAttributes)
var apiConventionMethodAttribute = method.GetCustomAttribute<ApiConventionMethodAttribute>(inherit: true);
var conventionMethod = apiConventionMethodAttribute?.Method;
if (conventionMethod == null)
{
var conventionMethod = GetConventionMethod(method, attribute.ConventionType);
if (conventionMethod != null)
{
var metadataProviders = conventionMethod.GetCustomAttributes(inherit: false)
.OfType<IApiResponseMetadataProvider>()
.ToArray();
conventionMethod = GetConventionMethod(method, apiConventionAttributes);
}
result = new ApiConventionResult(metadataProviders);
return true;
}
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)
private static MethodInfo GetConventionMethod(MethodInfo method, ApiConventionTypeAttribute[] apiConventionAttributes)
{
foreach (var conventionMethod in conventionType.GetMethods(BindingFlags.Public | BindingFlags.Static))
foreach (var attribute in apiConventionAttributes)
{
if (ApiConventionMatcher.IsMatch(method, conventionMethod))
var conventionMethods = attribute.ConventionType.GetMethods(BindingFlags.Public | BindingFlags.Static);
foreach (var conventionMethod in conventionMethods)
{
return conventionMethod;
if (ApiConventionMatcher.IsMatch(method, conventionMethod))
{
return conventionMethod;
}
}
}

View File

@ -1508,6 +1508,34 @@ namespace Microsoft.AspNetCore.Mvc.Core
internal static string FormatApiConvention_UnsupportedAttributesOnConvention(object p0, object p1, object p2)
=> string.Format(CultureInfo.CurrentCulture, GetString("ApiConvention_UnsupportedAttributesOnConvention"), p0, p1, p2);
/// <summary>
/// Method name '{0}' is ambigous for convention type '{1}'. More than one method found with the name '{0}'.
/// </summary>
internal static string ApiConventionMethod_AmbigiousMethodName
{
get => GetString("ApiConventionMethod_AmbigiousMethodName");
}
/// <summary>
/// Method name '{0}' is ambigous for convention type '{1}'. More than one method found with the name '{0}'.
/// </summary>
internal static string FormatApiConventionMethod_AmbigiousMethodName(object p0, object p1)
=> string.Format(CultureInfo.CurrentCulture, GetString("ApiConventionMethod_AmbigiousMethodName"), p0, p1);
/// <summary>
/// A method named '{0}' was not found on convention type '{1}'.
/// </summary>
internal static string ApiConventionMethod_NoMethodFound
{
get => GetString("ApiConventionMethod_NoMethodFound");
}
/// <summary>
/// A method named '{0}' was not found on convention type '{1}'.
/// </summary>
internal static string FormatApiConventionMethod_NoMethodFound(object p0, object p1)
=> string.Format(CultureInfo.CurrentCulture, GetString("ApiConventionMethod_NoMethodFound"), p0, p1);
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);

View File

@ -451,4 +451,10 @@
<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>
<data name="ApiConventionMethod_AmbigiousMethodName" xml:space="preserve">
<value>Method name '{0}' is ambigous for convention type '{1}'. More than one method found with the name '{0}'.</value>
</data>
<data name="ApiConventionMethod_NoMethodFound" xml:space="preserve">
<value>A method named '{0}' was not found on convention type '{1}'.</value>
</data>
</root>

View File

@ -261,6 +261,54 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
});
}
public class GetApiResponseTypes_WithApiConventionMethodAndProducesResponseType : ControllerBase
{
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))]
[ProducesResponseType(201)]
[ProducesResponseType(404)]
public Task<ActionResult<BaseModel>> Put(int id, BaseModel model) => null;
}
[Fact]
public void GetApiResponseTypes_ReturnsValuesFromProducesResponseType_IfApiConventionMethodAndAttributesAreSpecified()
{
// Arrange
var actionDescriptor = GetControllerActionDescriptor(
typeof(GetApiResponseTypes_WithApiConventionMethodAndProducesResponseType),
nameof(GetApiResponseTypes_WithApiConventionMethodAndProducesResponseType.Put));
actionDescriptor.Properties[typeof(ApiConventionResult)] = new ApiConventionResult(new IApiResponseMetadataProvider[]
{
new ProducesResponseTypeAttribute(200),
new ProducesResponseTypeAttribute(404),
new ProducesDefaultResponseTypeAttribute(),
});
var provider = GetProvider();
// Act
var result = provider.GetApiResponseTypes(actionDescriptor);
// Assert
Assert.Collection(
result.OrderBy(r => r.StatusCode),
responseType =>
{
Assert.Equal(201, responseType.StatusCode);
Assert.Equal(typeof(BaseModel), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Collection(
responseType.ApiResponseFormats,
format => Assert.Equal("application/json", format.MediaType));
},
responseType =>
{
Assert.Equal(404, responseType.StatusCode);
Assert.Equal(typeof(void), responseType.Type);
Assert.False(responseType.IsDefaultResponse);
Assert.Empty(responseType.ApiResponseFormats);
});
}
private static ApiResponseTypeProvider GetProvider()
{
var mvcOptions = new MvcOptions

View File

@ -0,0 +1,122 @@
// 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 Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Testing;
using Xunit;
namespace Microsoft.AspNetCore.Mvc
{
public class ApiConventionMethodAttributeTest
{
[Fact]
public void Constructor_ThrowsIfConventionMethodIsAnnotatedWithProducesAttribute()
{
// Arrange
var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get);
var attribute = typeof(ProducesAttribute);
var expected = GetErrorMessage(methodName, attribute);
// Act & Assert
ExceptionAssert.ThrowsArgument(
() => new ApiConventionMethodAttribute(typeof(ConventionWithProducesAttribute), nameof(ConventionWithProducesAttribute.Get)),
"conventionType",
expected);
}
public static class ConventionWithProducesAttribute
{
[Produces(typeof(void))]
public static void Get() { }
}
[Fact]
public void Constructor_ThrowsIfTypeIsNotStatic()
{
// Arrange
var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get);
var attribute = typeof(ProducesAttribute);
var expected = $"API convention type '{typeof(object)}' must be a static type.";
// Act & Assert
ExceptionAssert.ThrowsArgument(
() => new ApiConventionMethodAttribute(typeof(object), nameof(object.ToString)),
"conventionType",
expected);
}
[Fact]
public void Constructor_ThrowsIfMethodCannotBeFound()
{
// Arrange
var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get);
var attribute = typeof(ProducesAttribute);
var type = typeof(TestConventions);
var expected = $"A method named 'DoesNotExist' was not found on convention type '{type}'.";
// Act & Assert
ExceptionAssert.ThrowsArgument(
() => new ApiConventionMethodAttribute(typeof(TestConventions), "DoesNotExist"),
"methodName",
expected);
}
[Fact]
public void Constructor_ThrowsIfMethodIsNotPublic()
{
// Arrange
var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get);
var attribute = typeof(ProducesAttribute);
var type = typeof(TestConventions);
var expected = $"A method named 'NotPublic' was not found on convention type '{type}'.";
// Act & Assert
ExceptionAssert.ThrowsArgument(
() => new ApiConventionMethodAttribute(typeof(TestConventions), "NotPublic"),
"methodName",
expected);
}
[Fact]
public void Constructor_ThrowsIfMethodIsAmbigous()
{
// Arrange
var methodName = typeof(ConventionWithProducesAttribute).FullName + '.' + nameof(ConventionWithProducesAttribute.Get);
var attribute = typeof(ProducesAttribute);
var type = typeof(TestConventions);
var expected = $"Method name 'Method' is ambigous for convention type '{type}'. More than one method found with the name 'Method'.";
// Act & Assert
ExceptionAssert.ThrowsArgument(
() => new ApiConventionMethodAttribute(typeof(TestConventions), nameof(TestConventions.Method)),
"methodName",
expected);
}
private static class TestConventions
{
internal static void NotPublic() { }
public static void Method(int value) { }
public static void Method(string value) { }
}
private static string GetErrorMessage(string methodName, params Type[] attributes)
{
return $"Method {methodName} is decorated with the following attributes that are not allowed on an API convention method:" +
Environment.NewLine +
string.Join(Environment.NewLine, attributes.Select(a => a.FullName)) +
Environment.NewLine +
$"The following attributes are allowed on API convention methods: {nameof(ProducesResponseTypeAttribute)}, {nameof(ProducesDefaultResponseTypeAttribute)}, {nameof(ApiConventionNameMatchAttribute)}";
}
}
}

View File

@ -182,6 +182,29 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
r => Assert.Equal(404, r.StatusCode));
}
[Fact]
public void GetApiConvention_UsesApiConventionMethod()
{
// Arrange
var method = typeof(DefaultConventionController)
.GetMethod(nameof(DefaultConventionController.EditUser));
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.IsAssignableFrom<IApiDefaultResponseMetadataProvider>(r),
r => Assert.Equal(201, r.StatusCode),
r => Assert.Equal(400, r.StatusCode));
}
public class DefaultConventionController
{
public IActionResult GetUser(Guid id) => null;
@ -191,6 +214,9 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
public IActionResult PutUser(Guid userId, User user) => null;
public IActionResult Delete(Guid userId) => null;
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Post))]
public IActionResult EditUser(int id, User user) => null;
}
public class User { }

View File

@ -1349,6 +1349,34 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
});
}
[Fact]
public async Task ApiConvention_ForActionWtihApiConventionMethod()
{
// Act
var response = await Client.PostAsync(
"ApiExplorerResponseTypeWithApiConventionController/PostItem",
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(302, responseType.StatusCode);
Assert.Empty(responseType.ResponseFormats);
},
responseType =>
{
Assert.Equal(typeof(void).FullName, responseType.ResponseType);
Assert.Equal(409, responseType.StatusCode);
Assert.Empty(responseType.ResponseFormats);
});
}
private IEnumerable<string> GetSortedMediaTypes(ApiExplorerResponseType apiResponseType)
{
return apiResponseType.ResponseFormats

View File

@ -63,6 +63,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers
public Task DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode()
=> RunTest(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, 400);
[Fact]
public Task DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode()
=> RunTest(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, 202);
[Fact]
public Task DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation()
=> RunTest(DiagnosticDescriptors.MVC1005_ActionReturnsUndocumentedSuccessResult);

View File

@ -182,6 +182,55 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers
});
}
[Fact]
public async Task GetResponseMetadata_ReturnsValuesFromApiConventionMethodAttribute()
{
// Arrange
var compilation = await GetResponseMetadataCompilation();
var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}");
var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.GetResponseMetadata_ReturnsValuesFromApiConventionMethodAttribute)).First();
var symbolCache = new ApiControllerSymbolCache(compilation);
// Act
var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method, Array.Empty<AttributeData>());
// Assert
Assert.Collection(
result,
metadata =>
{
Assert.Equal(200, metadata.StatusCode);
Assert.NotNull(metadata.Attribute);
},
metadata =>
{
Assert.Equal(404, metadata.StatusCode);
Assert.NotNull(metadata.Attribute);
});
}
[Fact]
public async Task GetResponseMetadata_WIthProducesResponseTypeAndApiConventionMethod()
{
// Arrange
var compilation = await GetResponseMetadataCompilation();
var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}");
var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.GetResponseMetadata_WIthProducesResponseTypeAndApiConventionMethod)).First();
var symbolCache = new ApiControllerSymbolCache(compilation);
// Act
var result = SymbolApiResponseMetadataProvider.GetDeclaredResponseMetadata(symbolCache, method, Array.Empty<AttributeData>());
// Assert
Assert.Collection(
result,
metadata =>
{
Assert.Equal(204, metadata.StatusCode);
Assert.NotNull(metadata.Attribute);
});
}
[Fact]
public async Task GetResponseMetadata_IgnoresCustomResponseTypeMetadataProvider()
{

View File

@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc;
[assembly: ApiConventionType(typeof(DefaultApiConventions))]
namespace Microsoft.AspNetCore.Mvc.Analyzers
{
[ApiController]
public class DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode : ControllerBase
{
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Post))]
public IActionResult Get(int id)
{
/*MM*/return Accepted();
}
}
}

View File

@ -42,6 +42,13 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers
[CustomInvalidProducesResponseType(Type = typeof(Person), StatusCode = "204")]
public IActionResult ActionWithProducesResponseTypeWithIncorrectStatusCodeType() => null;
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Find))]
public IActionResult GetResponseMetadata_ReturnsValuesFromApiConventionMethodAttribute() => null;
[ProducesResponseType(204)]
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Find))]
public IActionResult GetResponseMetadata_WIthProducesResponseTypeAndApiConventionMethod() => null;
}
public class Person { }

View File

@ -35,5 +35,16 @@ namespace ApiExplorerWebSite
[HttpDelete]
public Task<IActionResult> DeleteProductAsync(object id) => null;
[HttpPost]
[ApiConventionMethod(typeof(CustomConventions), nameof(CustomConventions.CustomConventionMethod))]
public Task<IActionResult> PostItem(Product p) => null;
}
public static class CustomConventions
{
[ProducesResponseType(302)]
[ProducesResponseType(409)]
public static void CustomConventionMethod() { }
}
}