From 46189abda74fe5b5892a2bb5c660297701bc1410 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Thu, 28 Jun 2018 09:49:52 -0700 Subject: [PATCH] Refactoring for ApiConvention analyzers --- .../ApiControllerSymbolCache.cs | 57 ++ .../ApiControllerTypeCache.cs | 20 - .../ApiConventionAnalyzer.cs | 277 +++++++++ .../ApiResponseMetadata.cs | 23 + .../CodeAnalysisExtensions.cs | 68 ++- .../DiagnosticDescriptors.cs | 27 + .../SymbolApiConventionMatcher.cs | 197 ++++++ .../SymbolApiResponseMetadataProvider.cs | 71 ++- .../SymbolNames.cs | 22 +- .../ApiExplorer/ApiConventionMatcher.cs | 180 ++++++ .../ApiExplorer/ApiConventionResult.cs | 173 +----- .../ApiExplorer/ApiConventionMatcherTest.cs | 571 ++++++++++++++++++ .../ApiExplorer/ApiConventionResultTest.cs | 560 ----------------- .../ApiConventionAnalyzerIntegrationTest.cs | 166 +++++ .../ApiConventionAnalyzerTest.cs | 297 +++++++++ .../CodeAnalysisExtensionsTest.cs | 147 +++++ .../Mvc.Analyzers.Test.csproj | 1 + .../SymbolApiConventionMatcherTest.cs | 568 +++++++++++++++++ .../SymbolApiResponseMetadataProviderTest.cs | 41 +- ...tOfTReturningMethodWithoutAnyAttributes.cs | 21 + ...OfTReturningMethodWithoutSomeAttributes.cs | 25 + ...urned_ForControllerWithCustomConvention.cs | 50 ++ ...Attribute_ReturnsUndocumentedStatusCode.cs | 21 + ...Attribute_ReturnsUndocumentedStatusCode.cs | 21 + ...nouslyReturnsValue_WithoutDocumentation.cs | 23 + ...ributeReturnsValue_WithoutDocumentation.cs | 19 + ...fMethodWithAttribute_ReturnsDerivedType.cs | 23 + ...ntion_DoesNotReturnDocumentedStatusCode.cs | 20 + ...onvention_ReturnsUndocumentedStatusCode.cs | 25 + ...ibute_DoesNotReturnDocumentedStatusCode.cs | 21 + ...Attribute_ReturnsUndocumentedStatusCode.cs | 17 + ...ontroller_IfStatusCodesCannotBeInferred.cs | 12 + ...Controller_WithAllDocumentedStatusCodes.cs | 28 + ...gnosticsAreReturned_ForNonApiController.cs | 18 + ...agnosticsAreReturned_ForRazorPageModels.cs | 18 + ...reReturned_ForReturnStatementsInLambdas.cs | 41 ++ ...ned_ForReturnStatementsInLocalFunctions.cs | 34 ++ .../ApiConventionAnalyzerTestFile.cs | 56 ++ .../GetAttributes_BaseTypeWithAttributes.cs | 14 + .../GetAttributes_OnTypeWithAttributes.cs | 9 + .../GetAttributes_OnTypeWithoutAttributes.cs | 8 + .../SymbolApiConventionMatcherTestFile.cs | 55 ++ 42 files changed, 3223 insertions(+), 822 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerSymbolCache.cs delete mode 100644 src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerTypeCache.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Analyzers/ApiConventionAnalyzer.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Analyzers/ApiResponseMetadata.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiConventionMatcher.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionMatcher.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionMatcherTest.cs create mode 100644 test/Mvc.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs create mode 100644 test/Mvc.Analyzers.Test/ApiConventionAnalyzerTest.cs create mode 100644 test/Mvc.Analyzers.Test/SymbolApiConventionMatcherTest.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForControllerWithCustomConvention.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLambdas.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerTest/ApiConventionAnalyzerTestFile.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_BaseTypeWithAttributes.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithAttributes.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithoutAttributes.cs create mode 100644 test/Mvc.Analyzers.Test/TestFiles/SymbolApiConventionMatcherTest/SymbolApiConventionMatcherTestFile.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerSymbolCache.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerSymbolCache.cs new file mode 100644 index 0000000000..5866ec36ba --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerSymbolCache.cs @@ -0,0 +1,57 @@ +// 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.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + internal readonly struct ApiControllerSymbolCache + { + public ApiControllerSymbolCache(Compilation compilation) + { + ActionResultOfT = compilation.GetTypeByMetadataName(SymbolNames.ActionResultOfT); + ApiConventionNameMatchAttribute = compilation.GetTypeByMetadataName(SymbolNames.ApiConventionNameMatchAttribute); + ApiConventionTypeAttribute = compilation.GetTypeByMetadataName(SymbolNames.ApiConventionTypeAttribute); + ApiConventionTypeMatchAttribute = compilation.GetTypeByMetadataName(SymbolNames.ApiConventionTypeMatchAttribute); + ControllerAttribute = compilation.GetTypeByMetadataName(SymbolNames.ControllerAttribute); + DefaultStatusCodeAttribute = compilation.GetTypeByMetadataName(SymbolNames.DefaultStatusCodeAttribute); + IActionResult = compilation.GetTypeByMetadataName(SymbolNames.IActionResult); + IApiBehaviorMetadata = compilation.GetTypeByMetadataName(SymbolNames.IApiBehaviorMetadata); + IConvertToActionResult = compilation.GetTypeByMetadataName(SymbolNames.IConvertToActionResult); + NonActionAttribute = compilation.GetTypeByMetadataName(SymbolNames.NonActionAttribute); + NonControllerAttribute = compilation.GetTypeByMetadataName(SymbolNames.NonControllerAttribute); + ProducesResponseTypeAttribute = compilation.GetTypeByMetadataName(SymbolNames.ProducesResponseTypeAttribute); + + var disposable = compilation.GetSpecialType(SpecialType.System_IDisposable); + var members = disposable.GetMembers(nameof(IDisposable.Dispose)); + IDisposableDispose = members.Length == 1 ? (IMethodSymbol)members[0] : null; + } + + public INamedTypeSymbol ActionResultOfT { get; } + + public INamedTypeSymbol ApiConventionNameMatchAttribute { get; } + + public INamedTypeSymbol ApiConventionTypeAttribute { get; } + + public INamedTypeSymbol ApiConventionTypeMatchAttribute { get; } + + public INamedTypeSymbol ControllerAttribute { get; } + + public INamedTypeSymbol DefaultStatusCodeAttribute { get; } + + public INamedTypeSymbol IActionResult { get; } + + public INamedTypeSymbol IApiBehaviorMetadata { get; } + + public INamedTypeSymbol IConvertToActionResult { get; } + + public IMethodSymbol IDisposableDispose { get; } + + public INamedTypeSymbol NonActionAttribute { get; } + + public INamedTypeSymbol NonControllerAttribute { get; } + + public INamedTypeSymbol ProducesResponseTypeAttribute { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerTypeCache.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerTypeCache.cs deleted file mode 100644 index 8f4b2948e9..0000000000 --- a/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiControllerTypeCache.cs +++ /dev/null @@ -1,20 +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 Microsoft.CodeAnalysis; - -namespace Microsoft.AspNetCore.Mvc.Analyzers -{ - internal readonly struct ApiControllerTypeCache - { - public ApiControllerTypeCache(Compilation compilation) - { - ApiConventionAttribute = compilation.GetTypeByMetadataName(SymbolNames.ApiConventionAttribute); - ProducesResponseTypeAttribute = compilation.GetTypeByMetadataName(SymbolNames.ProducesResponseTypeAttribute); - } - - public INamedTypeSymbol ApiConventionAttribute { get; } - - public INamedTypeSymbol ProducesResponseTypeAttribute { get; } - } -} diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiConventionAnalyzer.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiConventionAnalyzer.cs new file mode 100644 index 0000000000..4efc083a19 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiConventionAnalyzer.cs @@ -0,0 +1,277 @@ +// 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.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class ApiConventionAnalyzer : DiagnosticAnalyzer + { + private static readonly Func _shouldDescendIntoChildren = ShouldDescendIntoChildren; + + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( + DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, + DiagnosticDescriptors.MVC1005_ActionReturnsUndocumentedSuccessResult, + DiagnosticDescriptors.MVC1006_ActionDoesNotReturnDocumentedStatusCode); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStartAnalysisContext => + { + var symbolCache = new ApiControllerSymbolCache(compilationStartAnalysisContext.Compilation); + if (symbolCache.ApiConventionTypeAttribute == null || symbolCache.ApiConventionTypeAttribute.TypeKind == TypeKind.Error) + { + // No-op if we can't find types we care about. + return; + } + + InitializeWorker(compilationStartAnalysisContext, symbolCache); + }); + } + + private void InitializeWorker(CompilationStartAnalysisContext compilationStartAnalysisContext, ApiControllerSymbolCache symbolCache) + { + compilationStartAnalysisContext.RegisterSyntaxNodeAction(syntaxNodeContext => + { + var methodSyntax = (MethodDeclarationSyntax)syntaxNodeContext.Node; + var semanticModel = syntaxNodeContext.SemanticModel; + var method = semanticModel.GetDeclaredSymbol(methodSyntax, syntaxNodeContext.CancellationToken); + + if (!ShouldEvaluateMethod(symbolCache, method)) + { + return; + } + + var conventionAttributes = GetConventionTypeAttributes(symbolCache, method); + var expectedResponseMetadata = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, conventionAttributes); + var actualResponseMetadata = new HashSet(); + + var context = new ApiConventionContext( + symbolCache, + syntaxNodeContext, + expectedResponseMetadata, + actualResponseMetadata); + + var hasUndocumentedStatusCodes = false; + foreach (var returnStatementSyntax in methodSyntax.DescendantNodes(_shouldDescendIntoChildren).OfType()) + { + hasUndocumentedStatusCodes |= VisitReturnStatementSyntax(context, returnStatementSyntax); + } + + if (hasUndocumentedStatusCodes) + { + // If we produced analyzer warnings about undocumented status codes, don't attempt to determine + // if there are documented status codes that are missing from the method body. + return; + } + + for (var i = 0; i < expectedResponseMetadata.Count; i++) + { + var expectedStatusCode = expectedResponseMetadata[i].StatusCode; + if (!actualResponseMetadata.Contains(expectedStatusCode)) + { + context.SyntaxNodeContext.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.MVC1006_ActionDoesNotReturnDocumentedStatusCode, + methodSyntax.Identifier.GetLocation(), + expectedStatusCode)); + } + } + + }, SyntaxKind.MethodDeclaration); + } + + internal IReadOnlyList GetConventionTypeAttributes(ApiControllerSymbolCache symbolCache, IMethodSymbol method) + { + var attributes = method.ContainingType.GetAttributes(symbolCache.ApiConventionTypeAttribute).ToArray(); + if (attributes.Length == 0) + { + attributes = method.ContainingAssembly.GetAttributes(symbolCache.ApiConventionTypeAttribute).ToArray(); + } + + return attributes; + } + + // Returns true if the return statement returns an undocumented status code. + private static bool VisitReturnStatementSyntax( + in ApiConventionContext context, + ReturnStatementSyntax returnStatementSyntax) + { + var returnExpression = returnStatementSyntax.Expression; + if (returnExpression.IsMissing) + { + return false; + } + + var syntaxNodeContext = context.SyntaxNodeContext; + + var typeInfo = syntaxNodeContext.SemanticModel.GetTypeInfo(returnExpression, syntaxNodeContext.CancellationToken); + if (typeInfo.Type.TypeKind == TypeKind.Error) + { + return false; + } + + var location = returnStatementSyntax.GetLocation(); + var diagnostic = InspectReturnExpression(context, typeInfo.Type, location); + if (diagnostic != null) + { + context.SyntaxNodeContext.ReportDiagnostic(diagnostic); + return true; + } + + return false; + } + + internal static Diagnostic InspectReturnExpression(in ApiConventionContext context, ITypeSymbol type, Location location) + { + var defaultStatusCodeAttribute = type + .GetAttributes(context.SymbolCache.DefaultStatusCodeAttribute, inherit: true) + .FirstOrDefault(); + + if (defaultStatusCodeAttribute != null) + { + var statusCode = GetDefaultStatusCode(defaultStatusCodeAttribute); + if (statusCode == null) + { + // Unable to read the status code. Treat this as valid. + return null; + } + + context.ActualResponseMetadata.Add(statusCode.Value); + if (!HasStatusCode(context.ExpectedResponseMetadata, statusCode.Value)) + { + return Diagnostic.Create( + DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, + location, + statusCode); + } + } + else if (!context.SymbolCache.IActionResult.IsAssignableFrom(type)) + { + if (!HasStatusCode(context.ExpectedResponseMetadata, 200) && !HasStatusCode(context.ExpectedResponseMetadata, 201)) + { + return Diagnostic.Create( + DiagnosticDescriptors.MVC1005_ActionReturnsUndocumentedSuccessResult, + location); + } + } + + return null; + } + + internal static int? GetDefaultStatusCode(AttributeData attribute) + { + if (attribute != null && + attribute.ConstructorArguments.Length == 1 && + attribute.ConstructorArguments[0].Kind == TypedConstantKind.Primitive && + attribute.ConstructorArguments[0].Value is int statusCode) + { + return statusCode; + } + + return null; + } + + internal static bool ShouldEvaluateMethod(ApiControllerSymbolCache symbolCache, IMethodSymbol method) + { + if (method == null) + { + return false; + } + + if (method.ReturnsVoid || method.ReturnType.TypeKind == TypeKind.Error) + { + return false; + } + + if (!MvcFacts.IsController(method.ContainingType, symbolCache.ControllerAttribute, symbolCache.NonControllerAttribute)) + { + return false; + } + + if (!method.ContainingType.HasAttribute(symbolCache.IApiBehaviorMetadata, inherit: true)) + { + return false; + } + + if (!MvcFacts.IsControllerAction(method, symbolCache.NonActionAttribute, symbolCache.IDisposableDispose)) + { + return false; + } + + return true; + } + + internal static bool HasStatusCode(IList declaredApiResponseMetadata, int statusCode) + { + if (declaredApiResponseMetadata.Count == 0) + { + // When no status code is declared, a 200 OK is implied. + return statusCode == 200; + } + + for (var i = 0; i < declaredApiResponseMetadata.Count; i++) + { + if (declaredApiResponseMetadata[i].StatusCode == statusCode) + { + return true; + } + } + + return false; + } + + private static bool ShouldDescendIntoChildren(SyntaxNode syntaxNode) + { + return !syntaxNode.IsKind(SyntaxKind.LocalFunctionStatement) && + !syntaxNode.IsKind(SyntaxKind.ParenthesizedLambdaExpression) && + !syntaxNode.IsKind(SyntaxKind.SimpleLambdaExpression) && + !syntaxNode.IsKind(SyntaxKind.AnonymousMethodExpression); + } + + + internal readonly struct ApiConventionContext + { + public ApiConventionContext( + ApiControllerSymbolCache symbolCache, + SyntaxNodeAnalysisContext syntaxNodeContext, + IList expectedResponseMetadata, + HashSet actualResponseMetadata, + Action reportDiagnostic = null) + { + SymbolCache = symbolCache; + SyntaxNodeContext = syntaxNodeContext; + ExpectedResponseMetadata = expectedResponseMetadata; + ActualResponseMetadata = actualResponseMetadata; + ReportDiagnosticAction = reportDiagnostic; + } + + public ApiControllerSymbolCache SymbolCache { get; } + public SyntaxNodeAnalysisContext SyntaxNodeContext { get; } + public IList ExpectedResponseMetadata { get; } + public HashSet ActualResponseMetadata { get; } + private Action ReportDiagnosticAction { get; } + + public void ReportDiagnostic(Diagnostic diagnostic) + { + if (ReportDiagnosticAction != null) + { + ReportDiagnosticAction(diagnostic); + } + + SyntaxNodeContext.ReportDiagnostic(diagnostic); + } + } + + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiResponseMetadata.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiResponseMetadata.cs new file mode 100644 index 0000000000..29ecd0ca27 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/ApiResponseMetadata.cs @@ -0,0 +1,23 @@ +// 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 Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + internal readonly struct ApiResponseMetadata + { + public ApiResponseMetadata(int statusCode, AttributeData attributeData, IMethodSymbol convention) + { + StatusCode = statusCode; + Attribute = attributeData; + Convention = convention; + } + + public int StatusCode { get; } + + public AttributeData Attribute { get; } + + public IMethodSymbol Convention { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/CodeAnalysisExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/CodeAnalysisExtensions.cs index 3fca8e3851..4211409bf4 100644 --- a/src/Microsoft.AspNetCore.Mvc.Analyzers/CodeAnalysisExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/CodeAnalysisExtensions.cs @@ -11,29 +11,22 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers internal static class CodeAnalysisExtensions { public static bool HasAttribute(this ITypeSymbol typeSymbol, ITypeSymbol attribute, bool inherit) - { - Debug.Assert(typeSymbol != null); - Debug.Assert(attribute != null); - - if (!inherit) - { - return HasAttribute(typeSymbol, attribute); - } - - foreach (var type in typeSymbol.GetTypeHierarchy()) - { - if (type.HasAttribute(attribute)) - { - return true; - } - } - - return false; - } + => GetAttributes(typeSymbol, attribute, inherit).Any(); public static bool HasAttribute(this IMethodSymbol methodSymbol, ITypeSymbol attribute, bool inherit) => GetAttributes(methodSymbol, attribute, inherit).Any(); + public static IEnumerable GetAttributes(this ISymbol symbol, ITypeSymbol attribute) + { + foreach (var declaredAttribute in symbol.GetAttributes()) + { + if (attribute.IsAssignableFrom(declaredAttribute.AttributeClass)) + { + yield return declaredAttribute; + } + } + } + public static IEnumerable GetAttributes(this IMethodSymbol methodSymbol, ITypeSymbol attribute, bool inherit) { Debug.Assert(methodSymbol != null); @@ -55,6 +48,25 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers } } + public static IEnumerable GetAttributes(this ITypeSymbol typeSymbol, ITypeSymbol attribute, bool inherit) + { + Debug.Assert(typeSymbol != null); + Debug.Assert(attribute != null); + + foreach (var type in GetTypeHierarchy(typeSymbol)) + { + foreach (var attributeData in GetAttributes(type, attribute)) + { + yield return attributeData; + } + + if (!inherit) + { + break; + } + } + } + public static bool HasAttribute(this IPropertySymbol propertySymbol, ITypeSymbol attribute, bool inherit) { Debug.Assert(propertySymbol != null); @@ -78,11 +90,16 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers return false; } - public static bool IsAssignableFrom(this ITypeSymbol source, INamedTypeSymbol target) + public static bool IsAssignableFrom(this ITypeSymbol source, ITypeSymbol target) { Debug.Assert(source != null); Debug.Assert(target != null); + if (source == target) + { + return true; + } + if (source.TypeKind == TypeKind.Interface) { foreach (var @interface in target.AllInterfaces) @@ -120,17 +137,6 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers return false; } - private static IEnumerable GetAttributes(this ISymbol symbol, ITypeSymbol attribute) - { - foreach (var declaredAttribute in symbol.GetAttributes()) - { - if (attribute.IsAssignableFrom(declaredAttribute.AttributeClass)) - { - yield return declaredAttribute; - } - } - } - private static IEnumerable GetTypeHierarchy(this ITypeSymbol typeSymbol) { while (typeSymbol != null) diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/DiagnosticDescriptors.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/DiagnosticDescriptors.cs index b2684297bd..ea5e213385 100644 --- a/src/Microsoft.AspNetCore.Mvc.Analyzers/DiagnosticDescriptors.cs +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/DiagnosticDescriptors.cs @@ -42,5 +42,32 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MVC1004_ActionReturnsUndocumentedStatusCode = + new DiagnosticDescriptor( + "MVC1004", + "Action returns undeclared status code.", + "Action method returns undeclared status code '{0}'.", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MVC1005_ActionReturnsUndocumentedSuccessResult = + new DiagnosticDescriptor( + "MVC1005", + "Action returns undeclared success result.", + "Action method returns a success result without a corresponding ProducesResponseType.", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor MVC1006_ActionDoesNotReturnDocumentedStatusCode = + new DiagnosticDescriptor( + "MVC1006", + "Action documents status code that is not returned.", + "Action method documents status code '{0}' without a corresponding return type.", + "Usage", + DiagnosticSeverity.Info, + isEnabledByDefault: false); } } diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiConventionMatcher.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiConventionMatcher.cs new file mode 100644 index 0000000000..878d0189e9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiConventionMatcher.cs @@ -0,0 +1,197 @@ +// 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.CodeAnalysis; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + internal static class SymbolApiConventionMatcher + { + internal static bool IsMatch(ApiControllerSymbolCache symbolCache, IMethodSymbol method, IMethodSymbol conventionMethod) + { + return MethodMatches() && ParametersMatch(); + + bool MethodMatches() + { + var methodNameMatchBehavior = GetNameMatchBehavior(symbolCache, conventionMethod); + if (!IsNameMatch(method.Name, conventionMethod.Name, methodNameMatchBehavior)) + { + return false; + } + + return true; + } + + bool ParametersMatch() + { + var methodParameters = method.Parameters; + var conventionMethodParameters = conventionMethod.Parameters; + + for (var i = 0; i < conventionMethodParameters.Length; i++) + { + var conventionParameter = conventionMethodParameters[i]; + if (conventionParameter.IsParams) + { + return true; + } + + if (methodParameters.Length <= i) + { + return false; + } + + var nameMatchBehavior = GetNameMatchBehavior(symbolCache, conventionParameter); + var typeMatchBehavior = GetTypeMatchBehavior(symbolCache, conventionParameter); + + if (!IsTypeMatch(methodParameters[i].Type, conventionParameter.Type, 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 SymbolApiConventionNameMatchBehavior GetNameMatchBehavior(ApiControllerSymbolCache symbolCache, ISymbol symbol) + { + var attribute = symbol.GetAttributes(symbolCache.ApiConventionNameMatchAttribute).FirstOrDefault(); + if (attribute == null || + attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Kind != TypedConstantKind.Enum) + { + return SymbolApiConventionNameMatchBehavior.Exact; + } + + var intValue = (int)attribute.ConstructorArguments[0].Value; + return (SymbolApiConventionNameMatchBehavior)intValue; + } + + internal static SymbolApiConventionTypeMatchBehavior GetTypeMatchBehavior(ApiControllerSymbolCache symbolCache, ISymbol symbol) + { + var attribute = symbol.GetAttributes(symbolCache.ApiConventionTypeMatchAttribute).FirstOrDefault(); + if (attribute == null || + attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Kind != TypedConstantKind.Enum) + { + return SymbolApiConventionTypeMatchBehavior.AssignableFrom; + } + + var intValue = (int)attribute.ConstructorArguments[0].Value; + return (SymbolApiConventionTypeMatchBehavior)intValue; + } + + internal static bool IsNameMatch(string name, string conventionName, SymbolApiConventionNameMatchBehavior nameMatchBehavior) + { + switch (nameMatchBehavior) + { + case SymbolApiConventionNameMatchBehavior.Any: + return true; + + case SymbolApiConventionNameMatchBehavior.Exact: + return string.Equals(name, conventionName, StringComparison.Ordinal); + + case SymbolApiConventionNameMatchBehavior.Prefix: + return IsNameMatchPrefix(); + + case SymbolApiConventionNameMatchBehavior.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(ITypeSymbol type, ITypeSymbol conventionType, SymbolApiConventionTypeMatchBehavior typeMatchBehavior) + { + switch (typeMatchBehavior) + { + case SymbolApiConventionTypeMatchBehavior.Any: + return true; + + case SymbolApiConventionTypeMatchBehavior.AssignableFrom: + return conventionType.IsAssignableFrom(type); + + default: + return false; + } + } + + internal enum SymbolApiConventionTypeMatchBehavior + { + Any, + AssignableFrom + } + + internal enum SymbolApiConventionNameMatchBehavior + { + Any, + Exact, + Prefix, + Suffix, + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiResponseMetadataProvider.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiResponseMetadataProvider.cs index bfc6935572..c5c1367d43 100644 --- a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiResponseMetadataProvider.cs +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolApiResponseMetadataProvider.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.CodeAnalysis; namespace Microsoft.AspNetCore.Mvc.Analyzers @@ -12,10 +13,58 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers private const string StatusCodeProperty = "StatusCode"; private const string StatusCodeConstructorParameter = "statusCode"; - internal static IList GetResponseMetadata(ApiControllerTypeCache typeCache, IMethodSymbol methodSymbol) + internal static IList GetResponseMetadata( + ApiControllerSymbolCache symbolCache, + IMethodSymbol method, + IReadOnlyList conventionTypeAttributes) + { + var metadataItems = GetResponseMetadataFromMethodAttributes(symbolCache, method); + if (metadataItems.Count != 0) + { + return metadataItems; + } + + metadataItems = GetResponseMetadataFromConventions(symbolCache, method, conventionTypeAttributes); + return metadataItems; + } + + private static IList GetResponseMetadataFromConventions( + ApiControllerSymbolCache symbolCache, + IMethodSymbol method, + IReadOnlyList attributes) + { + foreach (var attribute in attributes) + { + if (attribute.ConstructorArguments.Length != 1 || + attribute.ConstructorArguments[0].Kind != TypedConstantKind.Type || + !(attribute.ConstructorArguments[0].Value is ITypeSymbol conventionType)) + { + continue; + } + + foreach (var conventionMethod in conventionType.GetMembers().OfType()) + { + if (!conventionMethod.IsStatic || conventionMethod.DeclaredAccessibility != Accessibility.Public) + { + continue; + } + + if (!SymbolApiConventionMatcher.IsMatch(symbolCache, method, conventionMethod)) + { + continue; + } + + return GetResponseMetadataFromMethodAttributes(symbolCache, conventionMethod); + } + } + + return Array.Empty(); + } + + private static IList GetResponseMetadataFromMethodAttributes(ApiControllerSymbolCache symbolCache, IMethodSymbol methodSymbol) { - var responseMetadataAttributes = methodSymbol.GetAttributes(typeCache.ProducesResponseTypeAttribute, inherit: true); var metadataItems = new List(); + var responseMetadataAttributes = methodSymbol.GetAttributes(symbolCache.ProducesResponseTypeAttribute, inherit: true); foreach (var attribute in responseMetadataAttributes) { var statusCode = GetStatusCode(attribute); @@ -34,7 +83,7 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers { var namedArgument = attribute.NamedArguments[i]; var namedArgumentValue = namedArgument.Value; - if (string.Equals(namedArgument.Key, StatusCodeProperty, StringComparison.Ordinal) && + if (string.Equals(namedArgument.Key, StatusCodeProperty, StringComparison.Ordinal) && namedArgumentValue.Kind == TypedConstantKind.Primitive && (namedArgumentValue.Type.SpecialType & SpecialType.System_Int32) == SpecialType.System_Int32 && namedArgumentValue.Value is int statusCode) @@ -71,20 +120,4 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers return DefaultStatusCode; } } - - internal readonly struct ApiResponseMetadata - { - public ApiResponseMetadata(int statusCode, AttributeData attributeData, IMethodSymbol convention) - { - StatusCode = statusCode; - Attribute = attributeData; - Convention = convention; - } - - public int StatusCode { get; } - - public AttributeData Attribute { get; } - - public IMethodSymbol Convention { get; } - } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs index 98cd8666be..d57a911f2b 100644 --- a/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs +++ b/src/Microsoft.AspNetCore.Mvc.Analyzers/SymbolNames.cs @@ -7,10 +7,26 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers { public const string AllowAnonymousAttribute = "Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute"; - public const string ApiConventionAttribute = "Microsoft.AspNetCore.Mvc.ApiConventionAttribute"; + public const string ApiConventionNameMatchAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorer.ApiConventionNameMatchAttribute"; + + public const string ApiConventionTypeMatchAttribute = "Microsoft.AspNetCore.Mvc.ApiExplorer.ApiConventionTypeMatchAttribute"; + + public const string ApiConventionTypeAttribute = "Microsoft.AspNetCore.Mvc.ApiConventionTypeAttribute"; + + public const string ActionResultOfT = "Microsoft.AspNetCore.Mvc.ActionResult`1"; public const string AuthorizeAttribute = "Microsoft.AspNetCore.Authorization.AuthorizeAttribute"; + public const string ControllerAttribute = "Microsoft.AspNetCore.Mvc.ControllerAttribute"; + + public const string DefaultStatusCodeAttribute = "Microsoft.AspNetCore.Mvc.Infrastructure.DefaultStatusCodeAttribute"; + + public const string IApiBehaviorMetadata = "Microsoft.AspNetCore.Mvc.Internal.IApiBehaviorMetadata"; + + public const string IActionResult = "Microsoft.AspNetCore.Mvc.IActionResult"; + + public const string IConvertToActionResult = "Microsoft.AspNetCore.Mvc.IConvertToActionResult"; + public const string IFilterMetadataType = "Microsoft.AspNetCore.Mvc.Filters.IFilterMetadata"; public const string HtmlHelperPartialExtensionsType = "Microsoft.AspNetCore.Mvc.Rendering.HtmlHelperPartialExtensions"; @@ -19,6 +35,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers public const string IRouteTemplateProvider = "Microsoft.AspNetCore.Mvc.Routing.IRouteTemplateProvider"; + public const string NonActionAttribute = "Microsoft.AspNetCore.Mvc.NonActionAttribute"; + + public const string NonControllerAttribute = "Microsoft.AspNetCore.Mvc.NonControllerAttribute"; + public const string PageModelAttributeType = "Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure.PageModelAttribute"; public const string PartialMethod = "Partial"; diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionMatcher.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionMatcher.cs new file mode 100644 index 0000000000..b9e6f593c3 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionMatcher.cs @@ -0,0 +1,180 @@ +// 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.Reflection; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + internal static class ApiConventionMatcher + { + internal static bool IsMatch(MethodInfo methodInfo, MethodInfo conventionMethod) + { + return MethodMatches() && ParametersMatch(); + + bool MethodMatches() + { + var methodNameMatchBehavior = GetNameMatchBehavior(conventionMethod); + if (!IsNameMatch(methodInfo.Name, conventionMethod.Name, methodNameMatchBehavior)) + { + return false; + } + + return true; + } + + bool ParametersMatch() + { + var methodParameters = methodInfo.GetParameters(); + var conventionMethodParameters = conventionMethod.GetParameters(); + + for (var i = 0; i < conventionMethodParameters.Length; i++) + { + var conventionParameter = conventionMethodParameters[i]; + if (conventionParameter.IsDefined(typeof(ParamArrayAttribute))) + { + return true; + } + + if (methodParameters.Length <= i) + { + return false; + } + + var nameMatchBehavior = GetNameMatchBehavior(conventionParameter); + var typeMatchBehavior = GetTypeMatchBehavior(conventionParameter); + + if (!IsTypeMatch(methodParameters[i].ParameterType, conventionParameter.ParameterType, typeMatchBehavior) || + !IsNameMatch(methodParameters[i].Name, conventionParameter.Name, nameMatchBehavior)) + { + return false; + } + } + + // Ensure convention has at least as many parameters as the method. params convention argument are handled + // inside the for loop. + return methodParameters.Length == conventionMethodParameters.Length; + } + } + + internal static ApiConventionNameMatchBehavior GetNameMatchBehavior(ICustomAttributeProvider attributeProvider) + { + var attribute = GetCustomAttribute(attributeProvider); + return attribute?.MatchBehavior ?? ApiConventionNameMatchBehavior.Exact; + } + + internal static ApiConventionTypeMatchBehavior GetTypeMatchBehavior(ICustomAttributeProvider attributeProvider) + { + var attribute = GetCustomAttribute(attributeProvider); + return attribute?.MatchBehavior ?? ApiConventionTypeMatchBehavior.AssignableFrom; + } + + private static TAttribute GetCustomAttribute(ICustomAttributeProvider attributeProvider) + { + var attributes = attributeProvider.GetCustomAttributes(inherit: false); + for (var i = 0; i < attributes.Length; i++) + { + if (attributes[i] is TAttribute attribute) + { + return attribute; + } + } + + return default; + } + + internal static bool IsNameMatch(string name, string conventionName, ApiConventionNameMatchBehavior nameMatchBehavior) + { + switch (nameMatchBehavior) + { + case ApiConventionNameMatchBehavior.Any: + return true; + + case ApiConventionNameMatchBehavior.Exact: + return string.Equals(name, conventionName, StringComparison.Ordinal); + + case ApiConventionNameMatchBehavior.Prefix: + return IsNameMatchPrefix(); + + case ApiConventionNameMatchBehavior.Suffix: + return IsNameMatchSuffix(); + + default: + return false; + } + + bool IsNameMatchPrefix() + { + if (name.Length < conventionName.Length) + { + return false; + } + + if (name.Length == conventionName.Length) + { + // name = "Post", conventionName = "Post" + return string.Equals(name, conventionName, StringComparison.Ordinal); + } + + if (!name.StartsWith(conventionName, StringComparison.Ordinal)) + { + // name = "GetPerson", conventionName = "Post" + return false; + } + + // Check for name = "PostPerson", conventionName = "Post" + // Verify the first letter after the convention name is upper case. In this case 'P' from "Person" + return char.IsUpper(name[conventionName.Length]); + } + + bool IsNameMatchSuffix() + { + if (name.Length < conventionName.Length) + { + // name = "person", conventionName = "personName" + return false; + } + + if (name.Length == conventionName.Length) + { + // name = id, conventionName = id + return string.Equals(name, conventionName, StringComparison.Ordinal); + } + + // Check for name = personName, conventionName = name + var index = name.Length - conventionName.Length - 1; + if (!char.IsLower(name[index])) + { + // Verify letter before "name" is lowercase. In this case the letter 'n' at the end of "person" + return false; + } + + index++; + if (name[index] != char.ToUpper(conventionName[0])) + { + // Verify the first letter from convention is upper case. In this case 'n' from "name" + return false; + } + + // Match the remaining letters with exact case. i.e. match "ame" from "personName", "name" + index++; + return string.Compare(name, index, conventionName, 1, conventionName.Length - 1, StringComparison.Ordinal) == 0; + } + } + + internal static bool IsTypeMatch(Type type, Type conventionType, ApiConventionTypeMatchBehavior typeMatchBehavior) + { + switch (typeMatchBehavior) + { + case ApiConventionTypeMatchBehavior.Any: + return true; + + case ApiConventionTypeMatchBehavior.AssignableFrom: + return conventionType.IsAssignableFrom(type); + + default: + return false; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs index ca4d85d177..28890d7e13 100644 --- a/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs +++ b/src/Microsoft.AspNetCore.Mvc.Core/ApiExplorer/ApiConventionResult.cs @@ -46,9 +46,9 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer private static MethodInfo GetConventionMethod(MethodInfo method, Type conventionType) { - foreach (var conventionMethod in conventionType.GetMethods()) + foreach (var conventionMethod in conventionType.GetMethods(BindingFlags.Public | BindingFlags.Static)) { - if (IsMatch(method, conventionMethod)) + if (ApiConventionMatcher.IsMatch(method, conventionMethod)) { return conventionMethod; } @@ -56,174 +56,5 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer return null; } - - internal static bool IsMatch(MethodInfo methodInfo, MethodInfo conventionMethod) - { - return MethodMatches() && ParametersMatch(); - - bool MethodMatches() - { - var methodNameMatchBehavior = GetNameMatchBehavior(conventionMethod); - if (!IsNameMatch(methodInfo.Name, conventionMethod.Name, methodNameMatchBehavior)) - { - return false; - } - - return true; - } - - bool ParametersMatch() - { - var methodParameters = methodInfo.GetParameters(); - var conventionMethodParameters = conventionMethod.GetParameters(); - - for (var i = 0; i < conventionMethodParameters.Length; i++) - { - var conventionParameter = conventionMethodParameters[i]; - if (conventionParameter.IsDefined(typeof(ParamArrayAttribute))) - { - return true; - } - - if (methodParameters.Length <= i) - { - return false; - } - - var nameMatchBehavior = GetNameMatchBehavior(conventionParameter); - var typeMatchBehavior = GetTypeMatchBehavior(conventionParameter); - - if (!IsTypeMatch(methodParameters[i].ParameterType, conventionParameter.ParameterType, typeMatchBehavior) || - !IsNameMatch(methodParameters[i].Name, conventionParameter.Name, nameMatchBehavior)) - { - return false; - } - } - - // Ensure convention has at least as many parameters as the method. params convention argument are handled - // inside the for loop. - return methodParameters.Length == conventionMethodParameters.Length; - } - } - - internal static ApiConventionNameMatchBehavior GetNameMatchBehavior(ICustomAttributeProvider attributeProvider) - { - var attribute = GetCustomAttribute(attributeProvider); - return attribute?.MatchBehavior ?? ApiConventionNameMatchBehavior.Exact; - } - - internal static ApiConventionTypeMatchBehavior GetTypeMatchBehavior(ICustomAttributeProvider attributeProvider) - { - var attribute = GetCustomAttribute(attributeProvider); - return attribute?.MatchBehavior ?? ApiConventionTypeMatchBehavior.AssignableFrom; - } - - private static TAttribute GetCustomAttribute(ICustomAttributeProvider attributeProvider) - { - var attributes = attributeProvider.GetCustomAttributes(inherit: false); - for (var i = 0; i < attributes.Length; i++) - { - if (attributes[i] is TAttribute attribute) - { - return attribute; - } - } - - return default; - } - - internal static bool IsNameMatch(string name, string conventionName, ApiConventionNameMatchBehavior nameMatchBehavior) - { - switch (nameMatchBehavior) - { - case ApiConventionNameMatchBehavior.Any: - return true; - - case ApiConventionNameMatchBehavior.Exact: - return string.Equals(name, conventionName, StringComparison.Ordinal); - - case ApiConventionNameMatchBehavior.Prefix: - return IsNameMatchPrefix(); - - case ApiConventionNameMatchBehavior.Suffix: - return IsNameMatchSuffix(); - - default: - return false; - } - - bool IsNameMatchPrefix() - { - if (name.Length < conventionName.Length) - { - return false; - } - - if (name.Length == conventionName.Length) - { - // name = "Post", conventionName = "Post" - return string.Equals(name, conventionName, StringComparison.Ordinal); - } - - if (!name.StartsWith(conventionName, StringComparison.Ordinal)) - { - // name = "GetPerson", conventionName = "Post" - return false; - } - - // Check for name = "PostPerson", conventionName = "Post" - // Verify the first letter after the convention name is upper case. In this case 'P' from "Person" - return char.IsUpper(name[conventionName.Length]); - } - - bool IsNameMatchSuffix() - { - if (name.Length < conventionName.Length) - { - // name = "person", conventionName = "personName" - return false; - } - - if (name.Length == conventionName.Length) - { - // name = id, conventionName = id - return string.Equals(name, conventionName, StringComparison.Ordinal); - } - - // Check for name = personName, conventionName = name - var index = name.Length - conventionName.Length - 1; - if (!char.IsLower(name[index])) - { - // Verify letter before "name" is lowercase. In this case the letter 'n' at the end of "person" - return false; - } - - index++; - if (name[index] != char.ToUpper(conventionName[0])) - { - // Verify the first letter from convention is upper case. In this case 'n' from "name" - return false; - } - - // Match the remaining letters with exact case. i.e. match "ame" from "personName", "name" - index++; - return string.Compare(name, index, conventionName, 1, conventionName.Length - 1, StringComparison.Ordinal) == 0; - } - } - - internal static bool IsTypeMatch(Type type, Type conventionType, ApiConventionTypeMatchBehavior typeMatchBehavior) - { - switch (typeMatchBehavior) - { - case ApiConventionTypeMatchBehavior.Any: - return true; - - case ApiConventionTypeMatchBehavior.AssignableFrom: - return conventionType.IsAssignableFrom(type); - - default: - return false; - } - } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionMatcherTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionMatcherTest.cs new file mode 100644 index 0000000000..72526bbd2b --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionMatcherTest.cs @@ -0,0 +1,571 @@ +// 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.Reflection; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ApiExplorer +{ + public class ApiConventionMatcherTest + { + [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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssignableFrom_ReturnsTrueForExact() + { + // Arrange + var type = typeof(Base); + var conventionType = typeof(Base); + + // Act + var result = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssignableFrom_ReturnsTrueForDerived() + { + // Arrange + var type = typeof(Derived); + var conventionType = typeof(Base); + + // Act + var result = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsTypeMatch_WithAssignableFrom_ReturnsFalseForBaseTypes() + { + // Arrange + var type = typeof(Base); + var conventionType = typeof(Derived); + + // Act + var result = ApiConventionMatcher.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsTypeMatch_WithAssignableFrom_ReturnsFalseForUnrelated() + { + // Arrange + var type = typeof(string); + var conventionType = typeof(Derived); + + // Act + var result = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.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 = ApiConventionMatcher.IsMatch(method, conventionMethod); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsExact_WhenNoAttributesArePresent() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Exact; + var attributes = new object[0]; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsExact_WhenNoNameMatchBehaviorAttributeIsSpecified() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Exact; + var attributes = new object[] { new CLSCompliantAttribute(false), new ProducesResponseTypeAttribute(200) }; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetNameMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = ApiConventionNameMatchBehavior.Prefix; + var attributes = new object[] + { + new CLSCompliantAttribute(false), + new ApiConventionNameMatchAttribute(expected), + new ProducesResponseTypeAttribute(200) } + ; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetNameMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoAttributesArePresent() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.AssignableFrom; + var attributes = new object[0]; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoMatchingAttributesArePresent() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.AssignableFrom; + var attributes = new object[] { new CLSCompliantAttribute(false), new ProducesResponseTypeAttribute(200) }; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void GetTypeMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = ApiConventionTypeMatchBehavior.Any; + var attributes = new object[] + { + new CLSCompliantAttribute(false), + new ApiConventionTypeMatchAttribute(expected), + new ProducesResponseTypeAttribute(200) } + ; + var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); + + // Act + var result = ApiConventionMatcher.GetTypeMatchBehavior(provider); + + // Assert + Assert.Equal(expected, result); + } + + public class Base { } + + public class Derived : Base { } + + public class TestController + { + public IActionResult Get(int id) => null; + + public IActionResult Search(string searchTerm, bool sortDescending, int page) => null; + + public IActionResult SearchEmpty() => null; + } + + public static class TestConvention + { + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Get(int id) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void GetNoArgs() { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void GetTwoArgs(int id, string name) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void Post(Derived model) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] + public static void GetParameterNotMatching([ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.AssignableFrom)] Derived model) { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void Search( + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Exact)] + string searchTerm, + params object[] others) + { } + + [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] + public static void SearchWithParams(params object[] others) { } + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs index e1290a0421..851e607880 100644 --- a/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Core.Test/ApiExplorer/ApiConventionResultTest.cs @@ -3,8 +3,6 @@ using System; using System.Linq; -using System.Reflection; -using Moq; using Xunit; namespace Microsoft.AspNetCore.Mvc.ApiExplorer @@ -196,563 +194,5 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer } public class User { } - - [Theory] - [InlineData("Method", "method")] - [InlineData("Method", "ConventionMethod")] - [InlineData("p", "model")] - [InlineData("person", "model")] - public void IsNameMatch_WithAny_AlwaysReturnsTrue(string name, string conventionName) - { - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Any); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsNameMatch_WithExact_ReturnsFalse_IfNamesDifferInCase() - { - // Arrange - var name = "Name"; - var conventionName = "name"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithExact_ReturnsFalse_IfNamesAreDifferent() - { - // Arrange - var name = "Name"; - var conventionName = "Different"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSubString() - { - // Arrange - var name = "RegularName"; - var conventionName = "Regular"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSuperString() - { - // Arrange - var name = "Regular"; - var conventionName = "RegularName"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithExact_ReturnsTrue_IfExactMatch() - { - // Arrange - var name = "parameterName"; - var conventionName = "parameterName"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Exact); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsNameMatch_WithPrefix_ReturnsTrue_IfNamesAreExact() - { - // Arrange - var name = "PostPerson"; - var conventionName = "PostPerson"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsNameMatch_WithPrefix_ReturnsTrue_IfNameIsProperPrefix() - { - // Arrange - var name = "PostPerson"; - var conventionName = "Post"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesAreDifferent() - { - // Arrange - var name = "GetPerson"; - var conventionName = "Post"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesDifferInCase() - { - // Arrange - var name = "GetPerson"; - var conventionName = "post"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsNotProperPrfix() - { - // Arrange - var name = "Postman"; - var conventionName = "Post"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsSuffix() - { - // Arrange - var name = "GoPost"; - var conventionName = "Post"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Prefix); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithSuffix_ReturnsFalse_IfNamesAreDifferent() - { - // Arrange - var name = "name"; - var conventionName = "diff"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithSuffix_ReturnsFalse_IfNameIsNotSuffix() - { - // Arrange - var name = "personId"; - var conventionName = "idx"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsExact() - { - // Arrange - var name = "test"; - var conventionName = "test"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsNameMatch_WithSuffix_ReturnFalse_IfNameDiffersInCase() - { - // Arrange - var name = "test"; - var conventionName = "Test"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsProperSuffix() - { - // Arrange - var name = "personId"; - var conventionName = "id"; - - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); - - // Assert - Assert.True(result); - } - - [Theory] - [InlineData("candid", "id")] - [InlineData("canDid", "id")] - public void IsNameMatch_WithSuffix_ReturnFalse_IfNameIsNotProperSuffix(string name, string conventionName) - { - // Act - var result = ApiConventionResult.IsNameMatch(name, conventionName, ApiConventionNameMatchBehavior.Suffix); - - // Assert - Assert.False(result); - } - - [Theory] - [InlineData(typeof(object), typeof(object))] - [InlineData(typeof(int), typeof(void))] - [InlineData(typeof(string), typeof(DateTime))] - public void IsTypeMatch_WithAny_ReturnsTrue(Type type, Type conventionType) - { - // Act - var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.Any); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsTypeMatch_WithAssinableFrom_ReturnsTrueForExact() - { - // Arrange - var type = typeof(Base); - var conventionType = typeof(Base); - - // Act - var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsTypeMatch_WithAssinableFrom_ReturnsTrueForDerived() - { - // Arrange - var type = typeof(Derived); - var conventionType = typeof(Base); - - // Act - var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsTypeMatch_WithAssinableFrom_ReturnsFalseForBaseTypes() - { - // Arrange - var type = typeof(Base); - var conventionType = typeof(Derived); - - // Act - var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsTypeMatch_WithAssinableFrom_ReturnsFalseForUnrelated() - { - // Arrange - var type = typeof(string); - var conventionType = typeof(Derived); - - // Act - var result = ApiConventionResult.IsTypeMatch(type, conventionType, ApiConventionTypeMatchBehavior.AssignableFrom); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMatch_ReturnsFalse_IfMethodNamesDoNotMatch() - { - // Arrange - var method = typeof(TestController).GetMethod(nameof(TestController.Get)); - var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Post)); - - // Act - var result = ApiConventionResult.IsMatch(method, conventionMethod); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMatch_ReturnsFalse_IMethodHasMoreParametersThanConvention() - { - // Arrange - var method = typeof(TestController).GetMethod(nameof(TestController.Get)); - var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetNoArgs)); - - // Act - var result = ApiConventionResult.IsMatch(method, conventionMethod); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMatch_ReturnsFalse_IfMethodHasFewerParametersThanConvention() - { - // Arrange - var method = typeof(TestController).GetMethod(nameof(TestController.Get)); - var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetTwoArgs)); - - // Act - var result = ApiConventionResult.IsMatch(method, conventionMethod); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMatch_ReturnsFalse_IfParametersDoNotMatch() - { - // Arrange - var method = typeof(TestController).GetMethod(nameof(TestController.Get)); - var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.GetParameterNotMatching)); - - // Act - var result = ApiConventionResult.IsMatch(method, conventionMethod); - - // Assert - Assert.False(result); - } - - [Fact] - public void IsMatch_ReturnsTrue_IfMethodNameAndParametersMatchs() - { - // Arrange - var method = typeof(TestController).GetMethod(nameof(TestController.Get)); - var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Get)); - - // Act - var result = ApiConventionResult.IsMatch(method, conventionMethod); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsMatch_ReturnsTrue_IfParamsArrayMatchesRemainingArguments() - { - // Arrange - var method = typeof(TestController).GetMethod(nameof(TestController.Search)); - var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.Search)); - - // Act - var result = ApiConventionResult.IsMatch(method, conventionMethod); - - // Assert - Assert.True(result); - } - - [Fact] - public void IsMatch_WithEmpty_MatchesMethodWithNoParameters() - { - // Arrange - var method = typeof(TestController).GetMethod(nameof(TestController.SearchEmpty)); - var conventionMethod = typeof(TestConvention).GetMethod(nameof(TestConvention.SearchWithParams)); - - // Act - var result = ApiConventionResult.IsMatch(method, conventionMethod); - - // Assert - Assert.True(result); - } - - [Fact] - public void GetNameMatchBehavior_ReturnsExact_WhenNoAttributesArePresent() - { - // Arrange - var expected = ApiConventionNameMatchBehavior.Exact; - var attributes = new object[0]; - var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); - - // Act - var result = ApiConventionResult.GetNameMatchBehavior(provider); - - // Assert - Assert.Equal(expected, result); - } - - [Fact] - public void GetNameMatchBehavior_ReturnsExact_WhenNoNameMatchBehaviorAttributeIsSpecified() - { - // Arrange - var expected = ApiConventionNameMatchBehavior.Exact; - var attributes = new object[] { new CLSCompliantAttribute(false), new ProducesResponseTypeAttribute(200) }; - var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); - - // Act - var result = ApiConventionResult.GetNameMatchBehavior(provider); - - // Assert - Assert.Equal(expected, result); - } - - [Fact] - public void GetNameMatchBehavior_ReturnsValueFromAttributes() - { - // Arrange - var expected = ApiConventionNameMatchBehavior.Prefix; - var attributes = new object[] - { - new CLSCompliantAttribute(false), - new ApiConventionNameMatchAttribute(expected), - new ProducesResponseTypeAttribute(200) } - ; - var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); - - // Act - var result = ApiConventionResult.GetNameMatchBehavior(provider); - - // Assert - Assert.Equal(expected, result); - } - - [Fact] - public void GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoAttributesArePresent() - { - // Arrange - var expected = ApiConventionTypeMatchBehavior.AssignableFrom; - var attributes = new object[0]; - var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); - - // Act - var result = ApiConventionResult.GetTypeMatchBehavior(provider); - - // Assert - Assert.Equal(expected, result); - } - - [Fact] - public void GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoMatchingAttributesArePresent() - { - // Arrange - var expected = ApiConventionTypeMatchBehavior.AssignableFrom; - var attributes = new object[] { new CLSCompliantAttribute(false), new ProducesResponseTypeAttribute(200) }; - var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); - - // Act - var result = ApiConventionResult.GetTypeMatchBehavior(provider); - - // Assert - Assert.Equal(expected, result); - } - - [Fact] - public void GetTypeMatchBehavior_ReturnsValueFromAttributes() - { - // Arrange - var expected = ApiConventionTypeMatchBehavior.Any; - var attributes = new object[] - { - new CLSCompliantAttribute(false), - new ApiConventionTypeMatchAttribute(expected), - new ProducesResponseTypeAttribute(200) } - ; - var provider = Mock.Of(p => p.GetCustomAttributes(false) == attributes); - - // Act - var result = ApiConventionResult.GetTypeMatchBehavior(provider); - - // Assert - Assert.Equal(expected, result); - } - - public class Base { } - - public class Derived : Base { } - - public class TestController - { - public IActionResult Get(int id) => null; - - public IActionResult Search(string searchTerm, bool sortDescending, int page) => null; - - public IActionResult SearchEmpty() => null; - } - - public static class TestConvention - { - [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] - public static void Get(int id) { } - - [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] - public static void GetNoArgs() { } - - [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] - public static void GetTwoArgs(int id, string name) { } - - [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] - public static void Post(Derived model) { } - - [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)] - public static void GetParameterNotMatching([ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.AssignableFrom)] Derived model) { } - - [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] - public static void Search( - [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Exact)] - string searchTerm, - params object[] others) - { } - - [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)] - public static void SearchWithParams(params object[] others) { } - } } } diff --git a/test/Mvc.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs b/test/Mvc.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs new file mode 100644 index 0000000000..75307db01f --- /dev/null +++ b/test/Mvc.Analyzers.Test/ApiConventionAnalyzerIntegrationTest.cs @@ -0,0 +1,166 @@ +// 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.Runtime.CompilerServices; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class ApiConventionAnalyzerIntegrationTest + { + private MvcDiagnosticAnalyzerRunner Executor { get; } = new MvcDiagnosticAnalyzerRunner(new ApiConventionAnalyzer()); + + [Fact] + public Task NoDiagnosticsAreReturned_ForNonApiController() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForRazorPageModels() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForReturnStatementsInLambdas() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions() + => RunNoDiagnosticsAreReturned(); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode() + => RunTest(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, 404); + + [Fact] + public Task DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode() + => RunTest(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, 404); + + [Fact] + public Task DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode() + => RunTest(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, 200); + + [Fact] + public Task DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes() + => RunTest(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, 404); + + [Fact] + public Task DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes() + => RunTest(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, 422); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode() + => RunTest(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, 400); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation() + => RunTest(DiagnosticDescriptors.MVC1005_ActionReturnsUndocumentedSuccessResult); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation() + => RunTest(DiagnosticDescriptors.MVC1005_ActionReturnsUndocumentedSuccessResult); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType() + => RunTest(DiagnosticDescriptors.MVC1005_ActionReturnsUndocumentedSuccessResult); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode() + => RunTestFor1006(400); + + [Fact] + public Task DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode() + => RunTestFor1006(404); + + private async Task RunNoDiagnosticsAreReturned([CallerMemberName] string testMethod = "") + { + // Arrange + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Executor.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Empty(result); + } + + private Task RunTest(DiagnosticDescriptor descriptor, [CallerMemberName] string testMethod = "") + => RunTest(descriptor, Array.Empty(), testMethod); + + private Task RunTest(DiagnosticDescriptor descriptor, int statusCode, [CallerMemberName] string testMethod = "") + => RunTest(descriptor, new[] { statusCode.ToString() }, testMethod); + + private async Task RunTest(DiagnosticDescriptor descriptor, object[] args, [CallerMemberName] string testMethod = "") + { + // Arrange + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + + // Act + var result = await Executor.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Collection( + result, + diagnostic => + { + Assert.Equal(descriptor.Id, diagnostic.Id); + Assert.Same(descriptor, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(expectedLocation, diagnostic.Location); + Assert.Equal(string.Format(descriptor.MessageFormat.ToString(), args), diagnostic.GetMessage()); + }); + } + + private async Task RunTestFor1006(int statusCode, [CallerMemberName] string testMethod = "") + { + // Arrange + var descriptor = DiagnosticDescriptors.MVC1006_ActionDoesNotReturnDocumentedStatusCode; + var testSource = MvcTestSource.Read(GetType().Name, testMethod); + var expectedLocation = testSource.DefaultMarkerLocation; + var executor = new ApiCoventionWith1006DiagnosticEnabledRunner(); + + // Act + var result = await executor.GetDiagnosticsAsync(testSource.Source); + + // Assert + Assert.Collection( + result, + diagnostic => + { + Assert.Equal(descriptor.Id, diagnostic.Id); + Assert.Same(descriptor, diagnostic.Descriptor); + AnalyzerAssert.DiagnosticLocation(expectedLocation, diagnostic.Location); + Assert.Equal(string.Format(descriptor.MessageFormat.ToString(), new[] { statusCode.ToString() }), diagnostic.GetMessage()); + }); + } + + private class ApiCoventionWith1006DiagnosticEnabledRunner : MvcDiagnosticAnalyzerRunner + { + public ApiCoventionWith1006DiagnosticEnabledRunner() : base(new ApiConventionAnalyzer()) + { + } + + protected override CompilationOptions ConfigureCompilationOptions(CompilationOptions options) + { + var compilationOptions = base.ConfigureCompilationOptions(options); + var specificDiagnosticOptions = compilationOptions.SpecificDiagnosticOptions.Add( + DiagnosticDescriptors.MVC1006_ActionDoesNotReturnDocumentedStatusCode.Id, + ReportDiagnostic.Info); + + return compilationOptions.WithSpecificDiagnosticOptions(specificDiagnosticOptions); + } + } + } +} diff --git a/test/Mvc.Analyzers.Test/ApiConventionAnalyzerTest.cs b/test/Mvc.Analyzers.Test/ApiConventionAnalyzerTest.cs new file mode 100644 index 0000000000..65283bc5f8 --- /dev/null +++ b/test/Mvc.Analyzers.Test/ApiConventionAnalyzerTest.cs @@ -0,0 +1,297 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; +using Microsoft.CodeAnalysis; +using Xunit; +using static Microsoft.AspNetCore.Mvc.Analyzers.ApiConventionAnalyzer; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class ApiConventionAnalyzerTest + { + [Fact] + public async Task GetDefaultStatusCode_ReturnsValueDefinedUsingStatusCodeConstants() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(TestActionResultUsingStatusCodesConstants).FullName).GetAttributes()[0]; + + // Act + var actual = ApiConventionAnalyzer.GetDefaultStatusCode(attribute); + + // Assert + Assert.Equal(412, actual); + } + + [Fact] + public async Task GetDefaultStatusCode_ReturnsValueDefinedUsingHttpStatusCast() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(TestActionResultUsingHttpStatusCodeCast).FullName).GetAttributes()[0]; + + // Act + var actual = ApiConventionAnalyzer.GetDefaultStatusCode(attribute); + + // Assert + Assert.Equal(302, actual); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsNull_ForReturnTypeIf200StatusCodeIsDeclared() + { + // Arrange + var compilation = await GetCompilation(); + + var returnType = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerBaseModel).FullName); + var context = GetContext(compilation, new[] { 200 }); + + // Act + var diagnostic = ApiConventionAnalyzer.InspectReturnExpression(context, returnType, Location.None); + + // Assert + Assert.Null(diagnostic); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsNull_ForReturnTypeIf201StatusCodeIsDeclared() + { + // Arrange + var compilation = await GetCompilation(); + + var returnType = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerBaseModel).FullName); + var context = GetContext(compilation, new[] { 201 }); + + // Act + var diagnostic = ApiConventionAnalyzer.InspectReturnExpression(context, returnType, Location.None); + + // Assert + Assert.Null(diagnostic); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsNull_ForDerivedReturnTypeIf200StatusCodeIsDeclared() + { + // Arrange + var compilation = await GetCompilation(); + + var declaredReturnType = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerBaseModel).FullName); + var context = GetContext(compilation, new[] { 201 }); + var actualReturnType = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerDerivedModel).FullName); + + // Act + var diagnostic = ApiConventionAnalyzer.InspectReturnExpression(context, actualReturnType, Location.None); + + // Assert + Assert.Null(diagnostic); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsDiagnostic_If200IsNotDocumented() + { + // Arrange + var compilation = await GetCompilation(); + + var context = GetContext(compilation, new[] { 404 }); + var actualReturnType = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerDerivedModel).FullName); + + // Act + var diagnostic = ApiConventionAnalyzer.InspectReturnExpression(context, actualReturnType, Location.None); + + // Assert + Assert.NotNull(diagnostic); + Assert.Same(DiagnosticDescriptors.MVC1005_ActionReturnsUndocumentedSuccessResult, diagnostic.Descriptor); + } + + [Fact] + public async Task InspectReturnExpression_ReturnsDiagnostic_IfReturnTypeIsActionResultReturningUndocumentedStatusCode() + { + // Arrange + var compilation = await GetCompilation(); + + var declaredReturnType = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerBaseModel).FullName); + var context = GetContext(compilation, new[] { 200, 404 }); + var actualReturnType = compilation.GetTypeByMetadataName(typeof(BadRequestObjectResult).FullName); + + // Act + var diagnostic = ApiConventionAnalyzer.InspectReturnExpression(context, actualReturnType, Location.None); + + // Assert + Assert.NotNull(diagnostic); + Assert.Same(DiagnosticDescriptors.MVC1004_ActionReturnsUndocumentedStatusCode, diagnostic.Descriptor); + } + + [Fact] + public async Task InspectReturnExpression_DoesNotReturnDiagnostic_IfReturnTypeDoesNotHaveStatusCodeAttribute() + { + // Arrange + var compilation = await GetCompilation(); + + var context = GetContext(compilation, new[] { 200, 404 }); + var actualReturnType = compilation.GetTypeByMetadataName(typeof(EmptyResult).FullName); + + // Act + var diagnostic = ApiConventionAnalyzer.InspectReturnExpression(context, actualReturnType, Location.None); + + // Assert + Assert.Null(diagnostic); + } + + [Fact] + public async Task InspectReturnExpression_DoesNotReturnDiagnostic_IfDeclaredAndActualReturnTypeAreIActionResultInstances() + { + // Arrange + var compilation = await GetCompilation(); + + var declaredReturnType = compilation.GetTypeByMetadataName(typeof(IActionResult).FullName); + var context = GetContext(compilation, new[] { 404 }); + var actualReturnType = compilation.GetTypeByMetadataName(typeof(EmptyResult).FullName); + + // Act + var diagnostic = ApiConventionAnalyzer.InspectReturnExpression(context, actualReturnType, Location.None); + + // Assert + Assert.Null(diagnostic); + } + + [Fact] + public async Task InspectReturnExpression_DoesNotReturnDiagnostic_IfDeclaredAndActualReturnTypeAreIActionResult() + { + // Arrange + var compilation = await GetCompilation(); + + var context = GetContext(compilation, new[] { 404 }); + var actualReturnType = compilation.GetTypeByMetadataName(typeof(IActionResult).FullName); + + // Act + var diagnostic = ApiConventionAnalyzer.InspectReturnExpression(context, actualReturnType, Location.None); + + // Assert + Assert.Null(diagnostic); + } + + [Fact] + public async Task ShouldEvaluateMethod_ReturnsFalse_IfMethodReturnTypeIsInvalid() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Mvc; + +namespace TestNamespace +{ + [ApiController] + public class TestController : ControllerBase + { + public DoesNotExist Get(int id) + { + if (id == 0) + { + return NotFound(); + } + + return new DoesNotExist(id); + } + } +}"; + var project = DiagnosticProject.Create(GetType().Assembly, new[] { source }); + var compilation = await project.GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var method = (IMethodSymbol)compilation.GetTypeByMetadataName("TestNamespace.TestController").GetMembers("Get").First(); + + // Act + var result = ApiConventionAnalyzer.ShouldEvaluateMethod(symbolCache, method); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ShouldEvaluateMethod_ReturnsFalse_IfContainingTypeIsNotController() + { + // Arrange + var compilation = await GetCompilation(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerTest_IndexModel).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(ApiConventionAnalyzerTest_IndexModel.OnGet)).First(); + + // Act + var result = ApiConventionAnalyzer.ShouldEvaluateMethod(symbolCache, method); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ShouldEvaluateMethod_ReturnsFalse_IfContainingTypeIsNotApiController() + { + // Arrange + var compilation = await GetCompilation(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerTest_NotApiController).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(ApiConventionAnalyzerTest_NotApiController.Index)).First(); + + // Act + var result = ApiConventionAnalyzer.ShouldEvaluateMethod(symbolCache, method); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ShouldEvaluateMethod_ReturnsFalse_IfContainingTypeIsNotAction() + { + // Arrange + var compilation = await GetCompilation(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerTest_NotAction).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(ApiConventionAnalyzerTest_NotAction.Index)).First(); + + // Act + var result = ApiConventionAnalyzer.ShouldEvaluateMethod(symbolCache, method); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ShouldEvaluateMethod_ReturnsTrue_ForValidActionMethods() + { + // Arrange + var compilation = await GetCompilation(); + var symbolCache = new ApiControllerSymbolCache(compilation); + var type = compilation.GetTypeByMetadataName(typeof(ApiConventionAnalyzerTest_Valid).FullName); + var method = (IMethodSymbol)type.GetMembers(nameof(ApiConventionAnalyzerTest_Valid.Index)).First(); + + // Act + var result = ApiConventionAnalyzer.ShouldEvaluateMethod(symbolCache, method); + + // Assert + Assert.True(result); + } + + private static ApiConventionContext GetContext(Compilation compilation, int[] expectedStatusCodes) + { + var symbolCache = new ApiControllerSymbolCache(compilation); + var context = new ApiConventionContext( + symbolCache, + default, + expectedStatusCodes.Select(s => new ApiResponseMetadata(s, null, null)).ToArray(), + new HashSet()); + return context; + } + + private Task GetCompilation() + { + var testSource = MvcTestSource.Read(GetType().Name, "ApiConventionAnalyzerTestFile"); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + return project.GetCompilationAsync(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/CodeAnalysisExtensionsTest.cs b/test/Mvc.Analyzers.Test/CodeAnalysisExtensionsTest.cs index 392a16d6bb..6c5a5a2937 100644 --- a/test/Mvc.Analyzers.Test/CodeAnalysisExtensionsTest.cs +++ b/test/Mvc.Analyzers.Test/CodeAnalysisExtensionsTest.cs @@ -67,6 +67,24 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers attributeData => Assert.Equal(400, attributeData.ConstructorArguments[0].Value)); } + [Fact] + public async Task GetAttributesSymbolOverload_OnMethodSymbol() + { + // Arrange + var compilation = await GetCompilation("GetAttributes_WithMethodOverridding"); + var attribute = compilation.GetTypeByMetadataName(typeof(ProducesResponseTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass)}"); + var method = (IMethodSymbol)testClass.GetMembers(nameof(GetAttributes_WithInheritFalse_ReturnsAllAttributesOnCurrentActionClass.Method)).First(); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(symbol: method, attribute: attribute); + + // Assert + Assert.Collection( + attributes, + attributeData => Assert.Equal(400, attributeData.ConstructorArguments[0].Value)); + } + [Fact] public async Task GetAttributes_WithInheritTrue_ReturnsAllAttributesOnCurrentActionAndOverridingMethod() { @@ -123,6 +141,120 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers attributeData => Assert.Equal(401, attributeData.ConstructorArguments[0].Value)); } + [Fact] + public async Task GetAttributes_OnTypeWithoutAttributes() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_OnTypeWithoutAttributesType).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(testClass, attribute, inherit: true); + + // Assert + Assert.Empty(attributes); + } + + [Fact] + public async Task GetAttributes_OnTypeWithAttributes() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_OnTypeWithAttributes).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(testClass, attribute, inherit: true); + + // Assert + Assert.Collection( + attributes, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Object)); + }, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_String)); + }); + } + + [Fact] + public async Task GetAttributes_BaseTypeWithAttributes() + { + // Arrange + var compilation = await GetCompilation(); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_BaseTypeWithAttributesDerived).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(testClass, attribute, inherit: true); + + // Assert + Assert.Collection( + attributes, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Int32)); + }, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Object)); + }, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_String)); + }); + } + + [Fact] + public async Task GetAttributes_OnDerivedTypeWithInheritFalse() + { + // Arrange + var compilation = await GetCompilation(nameof(GetAttributes_BaseTypeWithAttributes)); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_BaseTypeWithAttributesDerived).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(testClass, attribute, inherit: false); + + // Assert + Assert.Collection( + attributes, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Int32)); + }); + } + + [Fact] + public async Task GetAttributesSymbolOverload_OnTypeSymbol() + { + // Arrange + var compilation = await GetCompilation(nameof(GetAttributes_BaseTypeWithAttributes)); + var attribute = compilation.GetTypeByMetadataName(typeof(ApiConventionTypeAttribute).FullName); + var testClass = compilation.GetTypeByMetadataName(typeof(GetAttributes_BaseTypeWithAttributesDerived).FullName); + + // Act + var attributes = CodeAnalysisExtensions.GetAttributes(symbol: testClass, attribute: attribute); + + // Assert + Assert.Collection( + attributes, + attributeData => + { + Assert.Same(attribute, attributeData.AttributeClass); + Assert.Equal(attributeData.ConstructorArguments[0].Value, compilation.GetSpecialType(SpecialType.System_Int32)); + }); + } + [Fact] public async Task HasAttribute_ReturnsFalseIfSymbolDoesNotHaveAttribute() { @@ -325,6 +457,21 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers Assert.False(isAssignableFromDerived); // Inverse shouldn't be true } + [Fact] + public async Task IsAssignable_ReturnsTrue_IfSourceAndDestinationAreTheSameInterface() + { + // Arrange + var compilation = await GetCompilation(nameof(IsAssignable_ReturnsTrueIfTypeImplementsInterface)); + var source = compilation.GetTypeByMetadataName(typeof(IsAssignable_ReturnsTrueIfTypeImplementsInterface).FullName); + var target = compilation.GetTypeByMetadataName(typeof(IsAssignable_ReturnsTrueIfTypeImplementsInterface).FullName); + + // Act + var isAssignableFrom = CodeAnalysisExtensions.IsAssignableFrom(source, target); + + // Assert + Assert.True(isAssignableFrom); + } + [Fact] public async Task IsAssignable_ReturnsTrueIfAncestorTypeImplementsInterface() { diff --git a/test/Mvc.Analyzers.Test/Mvc.Analyzers.Test.csproj b/test/Mvc.Analyzers.Test/Mvc.Analyzers.Test.csproj index 92de24ec50..275232d4ab 100644 --- a/test/Mvc.Analyzers.Test/Mvc.Analyzers.Test.csproj +++ b/test/Mvc.Analyzers.Test/Mvc.Analyzers.Test.csproj @@ -3,6 +3,7 @@ $(StandardTestTfms) true + Microsoft.AspNetCore.Mvc.Analyzers diff --git a/test/Mvc.Analyzers.Test/SymbolApiConventionMatcherTest.cs b/test/Mvc.Analyzers.Test/SymbolApiConventionMatcherTest.cs new file mode 100644 index 0000000000..34441fc218 --- /dev/null +++ b/test/Mvc.Analyzers.Test/SymbolApiConventionMatcherTest.cs @@ -0,0 +1,568 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.AspNetCore.Mvc.Analyzers.Infrastructure; +using Microsoft.CodeAnalysis; +using Xunit; +using static Microsoft.AspNetCore.Mvc.Analyzers.SymbolApiConventionMatcher; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class SymbolApiConventionMatcherTest + { + private static readonly string BaseTypeName = typeof(Base).FullName; + private static readonly string DerivedTypeName = typeof(Derived).FullName; + private static readonly string TestControllerName = typeof(TestController).FullName; + private static readonly string TestConventionName = typeof(TestConvention).FullName; + + [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 = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfNamesDifferInCase() + { + // Arrange + var name = "Name"; + var conventionName = "name"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "Name"; + var conventionName = "Different"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSubString() + { + // Arrange + var name = "RegularName"; + var conventionName = "Regular"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsFalse_IfConventionNameIsSuperString() + { + // Arrange + var name = "Regular"; + var conventionName = "RegularName"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithExact_ReturnsTrue_IfExactMatch() + { + // Arrange + var name = "parameterName"; + var conventionName = "parameterName"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Exact); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsTrue_IfNamesAreExact() + { + // Arrange + var name = "PostPerson"; + var conventionName = "PostPerson"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsTrue_IfNameIsProperPrefix() + { + // Arrange + var name = "PostPerson"; + var conventionName = "Post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "GetPerson"; + var conventionName = "Post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNamesDifferInCase() + { + // Arrange + var name = "GetPerson"; + var conventionName = "post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsNotProperPrfix() + { + // Arrange + var name = "Postman"; + var conventionName = "Post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithPrefix_ReturnsFalse_IfNameIsSuffix() + { + // Arrange + var name = "GoPost"; + var conventionName = "Post"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Prefix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnsFalse_IfNamesAreDifferent() + { + // Arrange + var name = "name"; + var conventionName = "diff"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnsFalse_IfNameIsNotSuffix() + { + // Arrange + var name = "personId"; + var conventionName = "idx"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsExact() + { + // Arrange + var name = "test"; + var conventionName = "test"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnFalse_IfNameDiffersInCase() + { + // Arrange + var name = "test"; + var conventionName = "Test"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsNameMatch_WithSuffix_ReturnTrue_IfNameIsProperSuffix() + { + // Arrange + var name = "personId"; + var conventionName = "id"; + + // Act + var result = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.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 = IsNameMatch(name, conventionName, SymbolApiConventionNameMatchBehavior.Suffix); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(typeof(object), typeof(object))] + [InlineData(typeof(int), typeof(void))] + [InlineData(typeof(string), typeof(DateTime))] + public async Task IsTypeMatch_WithAny_ReturnsTrue(Type type, Type conventionType) + { + // Arrange + var compilation = await GetCompilationAsync(); + var typeSymbol = compilation.GetTypeByMetadataName(type.FullName); + var conventionTypeSymbol = compilation.GetTypeByMetadataName(conventionType.FullName); + + // Act + var result = IsTypeMatch(typeSymbol, conventionTypeSymbol, SymbolApiConventionTypeMatchBehavior.Any); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsTypeMatch_WithAssignableFrom_ReturnsTrueForExact() + { + // Arrange + var compilation = await GetCompilationAsync(); + + var type = compilation.GetTypeByMetadataName(BaseTypeName); + var conventionType = compilation.GetTypeByMetadataName(BaseTypeName); + + // Act + var result = IsTypeMatch(type, conventionType, SymbolApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsTypeMatch_WithAssinableFrom_ReturnsTrueForDerived() + { + // Arrange + var compilation = await GetCompilationAsync(); + + var type = compilation.GetTypeByMetadataName(DerivedTypeName); + var conventionType = compilation.GetTypeByMetadataName(BaseTypeName); + + + // Act + var result = IsTypeMatch(type, conventionType, SymbolApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsTypeMatch_WithAssinableFrom_ReturnsFalseForBaseTypes() + { + // Arrange + var compilation = await GetCompilationAsync(); + + var type = compilation.GetTypeByMetadataName(BaseTypeName); + var conventionType = compilation.GetTypeByMetadataName(DerivedTypeName); + + // Act + var result = IsTypeMatch(type, conventionType, SymbolApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsTypeMatch_WithAssinableFrom_ReturnsFalseForUnrelated() + { + // Arrange + var compilation = await GetCompilationAsync(); + + var type = compilation.GetSpecialType(SpecialType.System_String); + var conventionType = compilation.GetTypeByMetadataName(BaseTypeName); + + // Act + var result = IsTypeMatch(type, conventionType, SymbolApiConventionTypeMatchBehavior.AssignableFrom); + + // Assert + Assert.False(result); + } + + [Fact] + public Task IsMatch_ReturnsFalse_IfMethodNamesDoNotMatch() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.Post); + var expected = false; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsFalse_IMethodHasMoreParametersThanConvention() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.GetNoArgs); + var expected = false; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsFalse_IfMethodHasFewerParametersThanConvention() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.GetTwoArgs); + var expected = false; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsFalse_IfParametersDoNotMatch() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.GetParameterNotMatching); + var expected = false; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsTrue_IfMethodNameAndParametersMatchs() + { + // Arrange + var methodName = nameof(TestController.Get); + var conventionMethodName = nameof(TestConvention.Get); + var expected = true; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_ReturnsTrue_IfParamsArrayMatchesRemainingArguments() + { + // Arrange + var methodName = nameof(TestController.Search); + var conventionMethodName = nameof(TestConvention.Search); + var expected = true; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + [Fact] + public Task IsMatch_WithEmpty_MatchesMethodWithNoParameters() + { + // Arrange + var methodName = nameof(TestController.SearchEmpty); + var conventionMethodName = nameof(TestConvention.SearchWithParams); + var expected = true; + + return RunMatchTest(methodName, conventionMethodName, expected); + } + + private async Task RunMatchTest(string methodName, string conventionMethodName, bool expected) + { + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testController = compilation.GetTypeByMetadataName(TestControllerName); + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = (IMethodSymbol)testController.GetMembers(methodName).First(); + var conventionMethod = (IMethodSymbol)testConvention.GetMembers(conventionMethodName).First(); + + // Act + var result = IsMatch(symbolCache, method, conventionMethod); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetNameMatchBehavior_ReturnsExact_WhenNoAttributesArePresent() + { + // Arrange + var expected = SymbolApiConventionNameMatchBehavior.Exact; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = testConvention.GetMembers(nameof(TestConvention.MethodWithoutMatchBehavior)).First(); + + // Act + var result = GetNameMatchBehavior(symbolCache, method); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetNameMatchBehavior_ReturnsExact_WhenNoNameMatchBehaviorAttributeIsSpecified() + { + // Arrange + var expected = SymbolApiConventionNameMatchBehavior.Exact; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = testConvention.GetMembers(nameof(TestConvention.MethodWithRandomAttributes)).First(); + + // Act + var result = GetNameMatchBehavior(symbolCache, method); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetNameMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = SymbolApiConventionNameMatchBehavior.Prefix; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = testConvention.GetMembers(nameof(TestConvention.Get)).First(); + + // Act + var result = GetNameMatchBehavior(symbolCache, method); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoAttributesArePresent() + { + // Arrange + var expected = SymbolApiConventionTypeMatchBehavior.AssignableFrom; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = (IMethodSymbol)testConvention.GetMembers(nameof(TestConvention.Get)).First(); + var parameter = method.Parameters[0]; + + // Act + var result = GetTypeMatchBehavior(symbolCache, parameter); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetTypeMatchBehavior_ReturnsIsAssignableFrom_WhenNoMatchingAttributesArePresent() + { + // Arrange + var expected = SymbolApiConventionTypeMatchBehavior.AssignableFrom; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = (IMethodSymbol)testConvention.GetMembers(nameof(TestConvention.MethodParameterWithRandomAttributes)).First(); + var parameter = method.Parameters[0]; + + // Act + var result = GetTypeMatchBehavior(symbolCache, parameter); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public async Task GetTypeMatchBehavior_ReturnsValueFromAttributes() + { + // Arrange + var expected = SymbolApiConventionTypeMatchBehavior.Any; + var compilation = await GetCompilationAsync(); + var symbolCache = new ApiControllerSymbolCache(compilation); + + var testConvention = compilation.GetTypeByMetadataName(TestConventionName); + var method = (IMethodSymbol)testConvention.GetMembers(nameof(TestConvention.MethodWithAnyTypeMatchBehaviorParameter)).First(); + var parameter = method.Parameters[0]; + + // Act + var result = GetTypeMatchBehavior(symbolCache, parameter); + + // Assert + Assert.Equal(expected, result); + } + + private Task GetCompilationAsync(string test = "SymbolApiConventionMatcherTestFile") + { + var testSource = MvcTestSource.Read(GetType().Name, test); + var project = DiagnosticProject.Create(GetType().Assembly, new[] { testSource.Source }); + + return project.GetCompilationAsync(); + } + } +} \ No newline at end of file diff --git a/test/Mvc.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs b/test/Mvc.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs index 3f7aad5564..c31846a88d 100644 --- a/test/Mvc.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs +++ b/test/Mvc.Analyzers.Test/SymbolApiResponseMetadataProviderTest.cs @@ -1,6 +1,7 @@ // 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.Threading.Tasks; using Microsoft.AspNetCore.Analyzer.Testing; @@ -21,10 +22,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerWithoutConvention)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerWithoutConvention.GetPerson)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Empty(result); @@ -37,10 +38,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerWithoutConvention)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerWithoutConvention.PostPerson)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Empty(result); @@ -53,10 +54,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesAttribute)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Empty(result); @@ -69,10 +70,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeInConstructor)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Collection( @@ -92,10 +93,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeAndTypeInConstructor)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Collection( @@ -115,10 +116,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeInConstructorAndProperty)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Collection( @@ -138,10 +139,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithProducesResponseType_StatusCodeAndTypeInConstructorAndProperty)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Collection( @@ -161,10 +162,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithCustomProducesResponseTypeAttributeWithArguments)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Collection( @@ -184,10 +185,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{nameof(GetResponseMetadata_ControllerActionWithAttributes)}"); var method = (IMethodSymbol)controller.GetMembers(nameof(GetResponseMetadata_ControllerActionWithAttributes.ActionWithCustomApiResponseMetadataProvider)).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Empty(result); @@ -215,10 +216,10 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers var compilation = await GetResponseMetadataCompilation(); var controller = compilation.GetTypeByMetadataName($"{Namespace}.{typeName}"); var method = (IMethodSymbol)controller.GetMembers(methodName).First(); - var typeCache = new ApiControllerTypeCache(compilation); + var symbolCache = new ApiControllerSymbolCache(compilation); // Act - var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(typeCache, method); + var result = SymbolApiResponseMetadataProvider.GetResponseMetadata(symbolCache, method, Array.Empty()); // Assert Assert.Collection( diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes.cs new file mode 100644 index 0000000000..2e0fc59064 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutAnyAttributes : ControllerBase + { + public ActionResult Method(Guid? id) + { + if (id == null) + { + /*MM*/return NotFound(); + } + + return "Hello world"; + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes.cs new file mode 100644 index 0000000000..7d6169af7e --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_ForActionResultOfTReturningMethodWithoutSomeAttributes : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 404)] + public IActionResult Put(int id, object model) + { + if (id == 0) + { + return NotFound(); + } + + if (!ModelState.IsValid) + { + /*MM*/return UnprocessableEntity(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForControllerWithCustomConvention.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForControllerWithCustomConvention.cs new file mode 100644 index 0000000000..07ea47c1a4 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_ForControllerWithCustomConvention.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Analyzers; + +[assembly: ApiConventionType(typeof(DiagnosticsAreReturned_ForControllerWithCustomConvention))] + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_ForControllerWithCustomConventionController : ControllerBase + { + public async Task Update(int id, Product product) + { + if (id < 0) + { + /*MM*/return BadRequest(); + } + + try + { + await product.Update(); + + } + catch + { + return Conflict(); + } + + return Ok(); + } + } + + public static class DiagnosticsAreReturned_ForControllerWithCustomConvention + { + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status422UnprocessableEntity)] + public static void Update(int id, Product product) + { + + } + } + + public class Product + { + public Task Update() => Task.CompletedTask; + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..ce686e8dcf --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfAsyncMethodReturningValueTaskWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode : ControllerBase + { + [ProducesResponseType(typeof(string), 404)] + public async ValueTask Method(int id) + { + await Task.Yield(); + if (id == 0) + { + return NotFound(); + } + + /*MM*/return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..eb8f5cd36a --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,21 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfAsyncMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + public async Task Method(int id) + { + await Task.Yield(); + if (id == 0) + { + /*MM*/return NotFound(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation.cs new file mode 100644 index 0000000000..b27ed2dc61 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentation : ControllerBase + { + [ProducesResponseType(404)] + public async Task> Method(int id) + { + await Task.Yield(); + + if (id == 0) + { + return NotFound(); + } + + /*MM*/return new DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentationModel(); + } + } + + public class DiagnosticsAreReturned_IfMethodWithAttributeAsynchronouslyReturnsValue_WithoutDocumentationModel { } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation.cs new file mode 100644 index 0000000000..3de2b09935 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation.cs @@ -0,0 +1,19 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentation : ControllerBase + { + [ProducesResponseType(404)] + public ActionResult Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + /*MM*/return new DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentationModel(); + } + } + + public class DiagnosticsAreReturned_IfMethodWithAttributeReturnsValue_WithoutDocumentationModel { } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType.cs new file mode 100644 index 0000000000..ef4bf9ff03 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedType : ControllerBase + { + [ProducesResponseType(404)] + public ActionResult Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + /*MM*/return new DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedTypeDerived(); + } + } + + public class DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedTypeBaseModel { } + + public class DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedTypeDerived : DiagnosticsAreReturned_IfMethodWithAttribute_ReturnsDerivedTypeBaseModel { } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode.cs new file mode 100644 index 0000000000..fb880c322b --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithConvention_DoesNotReturnDocumentedStatusCode : ControllerBase + { + public IActionResult /*MM*/Delete(int id) + { + if (!ModelState.IsValid) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..a338f55d1a --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithConvention_ReturnsUndocumentedStatusCode : ControllerBase + { + public IActionResult Get(int id) + { + if (id < 0) + { + /*MM*/return BadRequest(); + } + + if (id == 0) + { + return NotFound(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode.cs new file mode 100644 index 0000000000..8893357089 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_DoesNotReturnDocumentedStatusCode : ControllerBase + { + [ProducesResponseType(200)] + [ProducesResponseType(400)] + [ProducesResponseType(404)] + public IActionResult /*MM*/Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs new file mode 100644 index 0000000000..2541e2f98e --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode.cs @@ -0,0 +1,17 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class DiagnosticsAreReturned_IfMethodWithProducesResponseTypeAttribute_ReturnsUndocumentedStatusCode : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + public IActionResult Method(int id) + { + if (id == 0) + { + /*MM*/return NotFound(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred.cs new file mode 100644 index 0000000000..ad6e5956b4 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred.cs @@ -0,0 +1,12 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForApiController_IfStatusCodesCannotBeInferred : ControllerBase + { + [ProducesResponseType(201)] + public IActionResult Method(int id) + { + return id == 0 ? (IActionResult)NotFound() : Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes.cs new file mode 100644 index 0000000000..06bb57f1ee --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForApiController_WithAllDocumentedStatusCodes : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 400)] + [ProducesResponseType(typeof(string), 404)] + public IActionResult Put(int id, object model) + { + if (id == 0) + { + return NotFound(); + } + + if (!ModelState.IsValid) + { + return BadRequest(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs new file mode 100644 index 0000000000..0e55a8c574 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForNonApiController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class NoDiagnosticsAreReturned_ForNonApiController : Controller + { + [ProducesResponseType(typeof(string), 200)] + public IActionResult Method(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs new file mode 100644 index 0000000000..9299b5b5b8 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForRazorPageModels.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class Home : PageModel + { + [ProducesResponseType(302)] + public IActionResult OnPost(int id) + { + if (id == 0) + { + return NotFound(); + } + + return Page(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLambdas.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLambdas.cs new file mode 100644 index 0000000000..bd62df3b3b --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLambdas.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +[assembly: ApiConventionType(typeof(DefaultApiConventions))] + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForReturnStatementsInLambdas : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 404)] + public IActionResult Put(int id, object model) + { + Func someLambda = () => + { + if (id < -1) + { + // We should not process this. + return UnprocessableEntity(); + } + + return null; + }; + + + if (id == 0) + { + return NotFound(); + } + + + if (id == 1) + { + return someLambda(); + } + + return Ok(); + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions.cs new file mode 100644 index 0000000000..332de79235 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerIntegrationTest/NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions.cs @@ -0,0 +1,34 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiController] + public class NoDiagnosticsAreReturned_ForReturnStatementsInLocalFunctions : ControllerBase + { + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 404)] + public IActionResult Put(int id, object model) + { + if (id == 0) + { + return NotFound(); + } + + if (id == 1) + { + return LocalFunction(); + } + + return Ok(); + + IActionResult LocalFunction() + { + if (id < -1) + { + // We should not process this. + return UnprocessableEntity(); + } + + return null; + } + } + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerTest/ApiConventionAnalyzerTestFile.cs b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerTest/ApiConventionAnalyzerTestFile.cs new file mode 100644 index 0000000000..c4d6a646a9 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/ApiConventionAnalyzerTest/ApiConventionAnalyzerTestFile.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class UnwrapMethodReturnType + { + public ApiConventionAnalyzerBaseModel ReturnsBaseModel() => null; + + public ActionResult ReturnsActionResultOfBaseModel() => null; + + public Task> ReturnsTaskOfActionResultOfBaseModel() => null; + + public ValueTask> ReturnsValueTaskOfActionResultOfBaseModel() => default(ValueTask>); + + public ActionResult> ReturnsActionResultOfIEnumerableOfBaseModel() => null; + + public IEnumerable ReturnsIEnumerableOfBaseModel() => null; + } + + [DefaultStatusCode(StatusCodes.Status412PreconditionFailed)] + public class TestActionResultUsingStatusCodesConstants { } + + [DefaultStatusCode((int)HttpStatusCode.Found)] + public class TestActionResultUsingHttpStatusCodeCast { } + + public class ApiConventionAnalyzerBaseModel { } + + public class ApiConventionAnalyzerDerivedModel : ApiConventionAnalyzerBaseModel { } + + public class ApiConventionAnalyzerTest_IndexModel : PageModel + { + public IActionResult OnGet() => null; + } + + public class ApiConventionAnalyzerTest_NotApiController : Controller + { + public IActionResult Index() => null; + } + + public class ApiConventionAnalyzerTest_NotAction : Controller + { + [NonAction] + public IActionResult Index() => null; + } + + [ApiController] + public class ApiConventionAnalyzerTest_Valid : Controller + { + public IActionResult Index() => null; + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_BaseTypeWithAttributes.cs b/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_BaseTypeWithAttributes.cs new file mode 100644 index 0000000000..8e0ee1e5dc --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_BaseTypeWithAttributes.cs @@ -0,0 +1,14 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiConventionType(typeof(object))] + [ApiController] + [ApiConventionType(typeof(string))] + public class GetAttributes_BaseTypeWithAttributesBase + { + } + + [ApiConventionType(typeof(int))] + public class GetAttributes_BaseTypeWithAttributesDerived : GetAttributes_BaseTypeWithAttributesBase + { + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithAttributes.cs b/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithAttributes.cs new file mode 100644 index 0000000000..a402f5cea9 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithAttributes.cs @@ -0,0 +1,9 @@ +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + [ApiConventionType(typeof(object))] + [ApiController] + [ApiConventionType(typeof(string))] + public class GetAttributes_OnTypeWithAttributes + { + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithoutAttributes.cs b/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithoutAttributes.cs new file mode 100644 index 0000000000..e4aea55674 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/CodeAnalysisExtensionsTest/GetAttributes_OnTypeWithoutAttributes.cs @@ -0,0 +1,8 @@ +using System; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + public class GetAttributes_OnTypeWithoutAttributesType + { + } +} diff --git a/test/Mvc.Analyzers.Test/TestFiles/SymbolApiConventionMatcherTest/SymbolApiConventionMatcherTestFile.cs b/test/Mvc.Analyzers.Test/TestFiles/SymbolApiConventionMatcherTest/SymbolApiConventionMatcherTestFile.cs new file mode 100644 index 0000000000..9e549f8016 --- /dev/null +++ b/test/Mvc.Analyzers.Test/TestFiles/SymbolApiConventionMatcherTest/SymbolApiConventionMatcherTestFile.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Mvc.ApiExplorer; + +namespace Microsoft.AspNetCore.Mvc.Analyzers +{ + 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) { } + + public static void MethodWithoutMatchBehavior() { } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void MethodWithRandomAttributes() { } + + public static void MethodParameterWithRandomAttributes([FromRoute] int value) { } + + public static void MethodWithAnyTypeMatchBehaviorParameter([ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] int value) { } + } +}