From 0102d4efab9480d443bea0dc896390a7516c5a88 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Thu, 26 Jul 2018 12:09:59 -0700 Subject: [PATCH] Introduce ApiConventionMethodAttribute Fixes #8147 --- .../ApiControllerSymbolCache.cs | 3 + .../SymbolApiResponseMetadataProvider.cs | 60 ++++++++- .../SymbolNames.cs | 2 + .../ApiConventionMethodAttribute.cs | 76 +++++++++++ .../ApiConventionTypeAttribute.cs | 2 +- .../ApiExplorer/ApiConventionResult.cs | 35 +++-- .../Properties/Resources.Designer.cs | 28 ++++ .../Resources.resx | 6 + .../ApiResponseTypeProviderTest.cs | 48 +++++++ .../ApiConventionMethodAttributeTest.cs | 122 ++++++++++++++++++ .../ApiExplorer/ApiConventionResultTest.cs | 26 ++++ .../ApiExplorerTest.cs | 28 ++++ .../ApiConventionAnalyzerIntegrationTest.cs | 4 + .../SymbolApiResponseMetadataProviderTest.cs | 49 +++++++ ...ionMethod_ReturnsUndocumentedStatusCode.cs | 16 +++ .../GetResponseMetadataTests.cs | 7 + ...ResponseTypeWithApiConventionController.cs | 11 ++ 17 files changed, 503 insertions(+), 20 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/ApiConventionMethodAttribute.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionMethodAttributeTest.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerSymbolCache.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerSymbolCache.cs index 5866ec36ba..74e687e710 100644 --- a/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerSymbolCache.cs +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerSymbolCache.cs @@ -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; } diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiResponseMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiResponseMetadataProvider.cs index 9fbe1916ff..9f51139f23 100644 --- a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiResponseMetadataProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiResponseMetadataProvider.cs @@ -36,6 +36,58 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers ApiControllerSymbolCache symbolCache, IMethodSymbol method, IReadOnlyList attributes) + { + var conventionMethod = GetMethodFromConventionMethodAttribute(symbolCache, method); + if (conventionMethod == null) + { + conventionMethod = MatchConventionMethod(symbolCache, method, attributes); + } + + if (conventionMethod != null) + { + return GetResponseMetadataFromMethodAttributes(symbolCache, conventionMethod); + } + + return Array.Empty(); + } + + 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 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(); + return null; } private static IList GetResponseMetadataFromMethodAttributes(ApiControllerSymbolCache symbolCache, IMethodSymbol methodSymbol) diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs index d57a911f2b..32b7cf62f3 100644 --- a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs @@ -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"; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionMethodAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionMethodAttribute.cs new file mode 100644 index 0000000000..a54f9a0c07 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionMethodAttribute.cs @@ -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 +{ + /// + /// API conventions to be applied to a controller action. + /// + /// API conventions are used to influence the output of ApiExplorer. + /// can be used to specify an exact convention method that applies + /// to an action. for details about applying conventions at + /// the assembly or controller level. + /// + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class ApiConventionMethodAttribute : Attribute + { + /// + /// Initializes an instance using and + /// the specified . + /// + /// + /// The of the convention. + /// + /// Conventions must be static types. Methods in a convention are + /// matched to an action method using rules specified by + /// that may be applied to a method name or it's parameters and + /// that are applied to parameters. + /// + /// + /// The method name. + 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]; + } + + /// + /// Gets the convention type. + /// + public Type ConventionType { get; } + + internal MethodInfo Method { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs index e8c10cd3ac..e546a258ea 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiConventionTypeAttribute.cs @@ -51,7 +51,7 @@ namespace Microsoft.AspNetCore.Mvc /// public Type ConventionType { get; } - private static void EnsureValid(Type conventionType) + internal static void EnsureValid(Type conventionType) { if (!conventionType.IsSealed || !conventionType.IsAbstract) { diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs index 28890d7e13..aaef7d2d24 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs @@ -26,31 +26,38 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer ApiConventionTypeAttribute[] apiConventionAttributes, out ApiConventionResult result) { - foreach (var attribute in apiConventionAttributes) + var apiConventionMethodAttribute = method.GetCustomAttribute(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() - .ToArray(); + conventionMethod = GetConventionMethod(method, apiConventionAttributes); + } - result = new ApiConventionResult(metadataProviders); - return true; - } + if (conventionMethod != null) + { + var metadataProviders = conventionMethod.GetCustomAttributes(inherit: false) + .OfType() + .ToArray(); + + result = new ApiConventionResult(metadataProviders); + return true; } result = null; return false; } - private static MethodInfo GetConventionMethod(MethodInfo method, Type conventionType) + 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; + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs index 7612620733..65b63b8d27 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/Properties/Resources.Designer.cs @@ -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); + /// + /// Method name '{0}' is ambigous for convention type '{1}'. More than one method found with the name '{0}'. + /// + internal static string ApiConventionMethod_AmbigiousMethodName + { + get => GetString("ApiConventionMethod_AmbigiousMethodName"); + } + + /// + /// Method name '{0}' is ambigous for convention type '{1}'. More than one method found with the name '{0}'. + /// + internal static string FormatApiConventionMethod_AmbigiousMethodName(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ApiConventionMethod_AmbigiousMethodName"), p0, p1); + + /// + /// A method named '{0}' was not found on convention type '{1}'. + /// + internal static string ApiConventionMethod_NoMethodFound + { + get => GetString("ApiConventionMethod_NoMethodFound"); + } + + /// + /// A method named '{0}' was not found on convention type '{1}'. + /// + 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); diff --git a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx index 652f0df157..4b31a83761 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.Core/Resources.resx @@ -451,4 +451,10 @@ 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}. + + Method name '{0}' is ambigous for convention type '{1}'. More than one method found with the name '{0}'. + + + A method named '{0}' was not found on convention type '{1}'. + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs index 29543d26f1..11c9bcb8e0 100644 --- a/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ApiExplorer.Test/ApiResponseTypeProviderTest.cs @@ -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> 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 diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionMethodAttributeTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionMethodAttributeTest.cs new file mode 100644 index 0000000000..01fc4f3773 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiConventionMethodAttributeTest.cs @@ -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)}"; + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs index 851e607880..a1b1bd5bcc 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs @@ -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(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 { } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs index 9b556a42a1..d216ad4aff 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ApiExplorerTest.cs @@ -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>(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 GetSortedMediaTypes(ApiExplorerResponseType apiResponseType) { return apiResponseType.ResponseFormats diff --git a/test/Mvc.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs b/test/Mvc.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs index 0fdd22a66a..41ada495e3 100644 --- a/test/Mvc.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs +++ b/test/Mvc.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs @@ -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); diff --git a/test/Mvc.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs b/test/Mvc.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs index 32f764fb67..ace7293af7 100644 --- a/test/Mvc.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs +++ b/test/Mvc.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs @@ -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()); + + // 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()); + + // Assert + Assert.Collection( + result, + metadata => + { + Assert.Equal(204, metadata.StatusCode); + Assert.NotNull(metadata.Attribute); + }); + } + [Fact] public async Task GetResponseMetadata_IgnoresCustomResponseTypeMetadataProvider() { diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..1b7dcc3454 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithApiConventionMethod_ReturnsUndocumentedStatusCode.cs @@ -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(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs b/test/Mvc.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs index 34e3b87237..a296f9b93f 100644 --- a/test/Mvc.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs +++ b/test/Mvc.Analyzers.Test/TestFiles/SymbolApiResponseMetadataProviderTest/GetResponseMetadataTests.cs @@ -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 { } diff --git a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs index 4a63fc746a..8d831395be 100644 --- a/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs +++ b/test/WebSites/ApiExplorerWebSite/Controllers/ApiExplorerResponseTypeWithApiConventionController.cs @@ -35,5 +35,16 @@ namespace ApiExplorerWebSite [HttpDelete] public Task DeleteProductAsync(object id) => null; + + [HttpPost] + [ApiConventionMethod(typeof(CustomConventions), nameof(CustomConventions.CustomConventionMethod))] + public Task PostItem(Product p) => null; + } + + public static class CustomConventions + { + [ProducesResponseType(302)] + [ProducesResponseType(409)] + public static void CustomConventionMethod() { } } } \ No newline at end of file