From 7b1bd29623ac71682356d12336eec88c99d92213 Mon Sep 17 00:00:00 2001 From: sarah <35204912+satvu@users.noreply.github.com> Date: Wed, 16 Oct 2024 12:38:40 -0700 Subject: [PATCH] Analyzer for Multiple-Output Binding Scenarios with ASP.NET Core Integration (#2706) --- docs/analyzer-rules/AZFW0014.md | 2 +- docs/analyzer-rules/AZFW0015.md | 44 +++ docs/analyzer-rules/AZFW0016.md | 46 +++ .../CodeFixForHttpResultAttributeExpected.cs | 96 +++++ .../src/DiagnosticDescriptors.cs | 7 + .../HttpResultAttributeExpectedAnalyzer.cs | 125 ++++++ .../src/ITypeSymbolExtensions.cs | 32 ++ .../src/Properties/AssemblyInfo.cs | 6 + ...xtensions.Http.AspNetCore.Analyzers.csproj | 2 +- .../release_notes.md | 6 +- .../SymbolExtensionsTest.cs | 96 +++++ .../HttpResultAttributeExpectedTests.cs | 365 ++++++++++++++++++ 12 files changed, 824 insertions(+), 3 deletions(-) create mode 100644 docs/analyzer-rules/AZFW0015.md create mode 100644 docs/analyzer-rules/AZFW0016.md create mode 100644 extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/CodeFixForHttpResultAttributeExpected.cs create mode 100644 extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/HttpResultAttributeExpectedAnalyzer.cs create mode 100644 extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/ITypeSymbolExtensions.cs create mode 100644 extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Properties/AssemblyInfo.cs create mode 100644 test/Sdk.Generator.Tests/SymbolExtensionsTest/SymbolExtensionsTest.cs create mode 100644 test/extensions/Worker.Extensions.Http.AspNetCore.Tests/HttpResultAttributeExpectedTests.cs diff --git a/docs/analyzer-rules/AZFW0014.md b/docs/analyzer-rules/AZFW0014.md index b9ec2f72a..8534e7dc6 100644 --- a/docs/analyzer-rules/AZFW0014.md +++ b/docs/analyzer-rules/AZFW0014.md @@ -1,4 +1,4 @@ -# AZFW0011: Missing Registration for ASP.NET Core Integration +# AZFW0014: Missing Registration for ASP.NET Core Integration | | Value | |-|-| diff --git a/docs/analyzer-rules/AZFW0015.md b/docs/analyzer-rules/AZFW0015.md new file mode 100644 index 000000000..59b04b17d --- /dev/null +++ b/docs/analyzer-rules/AZFW0015.md @@ -0,0 +1,44 @@ +# AZFW0015: Missing HttpResult attribute for multi-output function + +| | Value | +|-|-| +| **Rule ID** |AZFW00015| +| **Category** |[Usage]| +| **Severity** |Error| + +## Cause + +This rule is triggered when a multi-output function is missing a `HttpResultAttribute` on the HTTP response type. + +## Rule description + +For [functions with multiple output bindings](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide?tabs=windows#multiple-output-bindings) using ASP.NET Core integration, the property correlating with the HTTP response needs to be decorated with the `HttpResultAttribute` in order to write the HTTP response correctly. Properties of the type `HttpResponseData` will still have their responses written correctly. + +## How to fix violations + +Add the attribute `[HttpResult]` (or `[HttpResultAttribute]`) to the relevant property. Example: + +```csharp +public static class MultiOutput +{ + [Function(nameof(MultiOutput))] + public static MyOutputType Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req, + FunctionContext context) + { + ... + } +} + +public class MyOutputType +{ + [QueueOutput("myQueue")] + public string Name { get; set; } + + [HttpResult] + public IActionResult HttpResponse { get; set; } +} +``` + +## When to suppress warnings + +This rule should not be suppressed because this error will prevent the HTTP response from being written correctly. diff --git a/docs/analyzer-rules/AZFW0016.md b/docs/analyzer-rules/AZFW0016.md new file mode 100644 index 000000000..3ee171988 --- /dev/null +++ b/docs/analyzer-rules/AZFW0016.md @@ -0,0 +1,46 @@ +# AZFW0016: Missing HttpResult attribute for multi-output function + +| | Value | +|-|-| +| **Rule ID** |AZFW00016| +| **Category** |[Usage]| +| **Severity** |Warning| + +## Cause + +This rule is triggered when a multi-output function using `HttpResponseData` is missing a `HttpResultAttribute` on the HTTP response type. + +## Rule description + +Following the introduction of ASP.NET Core integration, for [functions with multiple output bindings](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide?tabs=windows#multiple-output-bindings), the property in a custom output type correlating with the HTTP response is expected to be decorated with the `HttpResultAttribute`. + +`HttpResponseData` does not require this attribute for multi-output functions to work because support for it was available before the introduction of ASP.NET Core Integration. However, this is the expected convention moving forward as all other HTTP response types in this scenario will not work without this attribute. + +## How to fix violations + +Add the attribute `[HttpResult]` (or `[HttpResultAttribute]`) to the relevant property. Example: + +```csharp +public static class MultiOutput +{ + [Function(nameof(MultiOutput))] + public static MyOutputType Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req, + FunctionContext context) + { + ... + } +} + +public class MyOutputType +{ + [QueueOutput("myQueue")] + public string Name { get; set; } + + [HttpResult] + public HttpResponseData HttpResponse { get; set; } +} +``` + +## When to suppress warnings + +This rule can be suppressed if there is no intention to migrate from `HttpResponseData` to other types (like `IActionResult`). \ No newline at end of file diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/CodeFixForHttpResultAttributeExpected.cs b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/CodeFixForHttpResultAttributeExpected.cs new file mode 100644 index 000000000..df004ca11 --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/CodeFixForHttpResultAttributeExpected.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore +{ + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CodeFixForHttpResultAttribute)), Shared] + public sealed class CodeFixForHttpResultAttribute : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create( + DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute.Id, + DiagnosticDescriptors.MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute.Id); + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + Diagnostic diagnostic = context.Diagnostics.First(); + context.RegisterCodeFix(new AddHttpResultAttribute(context.Document, diagnostic), diagnostic); + + return Task.CompletedTask; + } + + /// + /// CodeAction implementation which adds the HttpResultAttribute on the return type of a function using the multi-output bindings pattern. + /// + private sealed class AddHttpResultAttribute : CodeAction + { + private readonly Document _document; + private readonly Diagnostic _diagnostic; + private const string ExpectedAttributeName = "HttpResult"; + + internal AddHttpResultAttribute(Document document, Diagnostic diagnostic) + { + this._document = document; + this._diagnostic = diagnostic; + } + + public override string Title => "Add HttpResultAttribute"; + + public override string EquivalenceKey => null; + + /// + /// Asynchronously retrieves the modified , with the HttpResultAttribute added to the relevant property. + /// + /// A token that can be used to propagate notifications that the operation should be canceled. + /// An updated object. + protected override async Task GetChangedDocumentAsync(CancellationToken cancellationToken) + { + // Get the syntax root of the document + var root = await _document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var semanticModel = await _document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + + var typeNode = root.FindNode(this._diagnostic.Location.SourceSpan) + .FirstAncestorOrSelf(); + + var typeSymbol = semanticModel.GetSymbolInfo(typeNode).Symbol; + var typeDeclarationSyntaxReference = typeSymbol.DeclaringSyntaxReferences.FirstOrDefault(); + if (typeDeclarationSyntaxReference is null) + { + return _document; + } + + var typeDeclarationNode = await typeDeclarationSyntaxReference.GetSyntaxAsync(cancellationToken); + + var propertyNode = typeDeclarationNode.DescendantNodes() + .OfType() + .First(prop => + { + var propertyType = semanticModel.GetTypeInfo(prop.Type).Type; + return propertyType != null && (propertyType.Name == "IActionResult" || propertyType.Name == "HttpResponseData" || propertyType.Name == "IResult"); + }); + + var attribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName(ExpectedAttributeName)); + + var newPropertyNode = propertyNode + .AddAttributeLists(SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute))); + + var newRoot = root.ReplaceNode(propertyNode, newPropertyNode); + + return _document.WithSyntaxRoot(newRoot); + } + } + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/DiagnosticDescriptors.cs b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/DiagnosticDescriptors.cs index a95118f5c..d3d9c1e12 100644 --- a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/DiagnosticDescriptors.cs +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/DiagnosticDescriptors.cs @@ -18,5 +18,12 @@ private static DiagnosticDescriptor Create(string id, string title, string messa public static DiagnosticDescriptor CorrectRegistrationExpectedInAspNetIntegration { get; } = Create(id: "AZFW0014", title: "Missing expected registration of ASP.NET Core Integration services", messageFormat: "The registration for method '{0}' is expected for ASP.NET Core Integration.", category: Usage, severity: DiagnosticSeverity.Error); + public static DiagnosticDescriptor MultipleOutputHttpTriggerWithoutHttpResultAttribute { get; } + = Create(id: "AZFW0015", title: "Missing a HttpResultAttribute in multi-output function", messageFormat: "The return type for function '{0}' is missing a HttpResultAttribute on the HTTP response type property.", + category: Usage, severity: DiagnosticSeverity.Error); + + public static DiagnosticDescriptor MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute { get; } + = Create(id: "AZFW0016", title: "Missing a HttpResultAttribute in multi-output function", messageFormat: "The return type for function '{0}' is missing a HttpResultAttribute on the HttpResponseData type property.", + category: Usage, severity: DiagnosticSeverity.Warning); } } diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/HttpResultAttributeExpectedAnalyzer.cs b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/HttpResultAttributeExpectedAnalyzer.cs new file mode 100644 index 000000000..20484ddda --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/HttpResultAttributeExpectedAnalyzer.cs @@ -0,0 +1,125 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +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.Azure.Functions.Worker.Extensions.Http.AspNetCore +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class HttpResultAttributeExpectedAnalyzer : DiagnosticAnalyzer + { + private const string FunctionAttributeFullName = "Microsoft.Azure.Functions.Worker.FunctionAttribute"; + private const string HttpTriggerAttributeFullName = "Microsoft.Azure.Functions.Worker.HttpTriggerAttribute"; + private const string HttpResultAttributeFullName = "Microsoft.Azure.Functions.Worker.HttpResultAttribute"; + public const string HttpResponseDataFullName = "Microsoft.Azure.Functions.Worker.Http.HttpResponseData"; + public const string OutputBindingFullName = "Microsoft.Azure.Functions.Worker.Extensions.Abstractions.OutputBindingAttribute"; + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute, + DiagnosticDescriptors.MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration); + } + + private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) + { + var semanticModel = context.SemanticModel; + var methodDeclaration = (MethodDeclarationSyntax)context.Node; + + var functionAttributeSymbol = semanticModel.Compilation.GetTypeByMetadataName(FunctionAttributeFullName); + var functionNameAttribute = methodDeclaration.AttributeLists + .SelectMany(attrList => attrList.Attributes) + .Where(attr => SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(attr).Type, functionAttributeSymbol)); + + if (!functionNameAttribute.Any()) + { + return; + } + + var functionName = functionNameAttribute.First().ArgumentList.Arguments[0]; // only one argument in FunctionAttribute which is the function name + + var httpTriggerAttributeSymbol = semanticModel.Compilation.GetTypeByMetadataName(HttpTriggerAttributeFullName); + var hasHttpTriggerAttribute = methodDeclaration.ParameterList.Parameters + .SelectMany(param => param.AttributeLists) + .SelectMany(attrList => attrList.Attributes) + .Select(attr => semanticModel.GetTypeInfo(attr).Type) + .Any(attrSymbol => SymbolEqualityComparer.Default.Equals(attrSymbol, httpTriggerAttributeSymbol)); + + if (!hasHttpTriggerAttribute) + { + return; + } + + var returnType = methodDeclaration.ReturnType; + var returnTypeSymbol = semanticModel.GetTypeInfo(returnType).Type; + + if (IsHttpReturnType(returnTypeSymbol, semanticModel)) + { + return; + } + + var outputBindingSymbol = semanticModel.Compilation.GetTypeByMetadataName(OutputBindingFullName); + var hasOutputBindingProperty = returnTypeSymbol.GetMembers() + .OfType() + .Any(prop => prop.GetAttributes().Any(attr => attr.AttributeClass.IsOrDerivedFrom(outputBindingSymbol))); + + if (!hasOutputBindingProperty) + { + return; + } + + var httpResponseDataSymbol = semanticModel.Compilation.GetTypeByMetadataName(HttpResponseDataFullName); + var hasHttpResponseData = returnTypeSymbol.GetMembers() + .OfType() + .Any(prop => SymbolEqualityComparer.Default.Equals(prop.Type, httpResponseDataSymbol)); + + var httpResultAttributeSymbol = semanticModel.Compilation.GetTypeByMetadataName(HttpResultAttributeFullName); + var hasHttpResultAttribute = returnTypeSymbol.GetMembers() + .SelectMany(member => member.GetAttributes()) + .Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, httpResultAttributeSymbol)); + + if (!hasHttpResultAttribute && !hasHttpResponseData) + { + var diagnostic = Diagnostic.Create(DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute, methodDeclaration.ReturnType.GetLocation(), functionName.ToString()); + context.ReportDiagnostic(diagnostic); + } + + if (!hasHttpResultAttribute && hasHttpResponseData) + { + var diagnostic = Diagnostic.Create(DiagnosticDescriptors.MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute, methodDeclaration.ReturnType.GetLocation(), functionName.ToString()); + context.ReportDiagnostic(diagnostic); + } + + } + + private static bool IsHttpReturnType(ISymbol symbol, SemanticModel semanticModel) + { + var httpRequestDataType = semanticModel.Compilation.GetTypeByMetadataName("Microsoft.Azure.Functions.Worker.Http.HttpRequestData"); + + if (SymbolEqualityComparer.Default.Equals(symbol, httpRequestDataType)) + { + return true; + } + + var iActionResultType = semanticModel.Compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.IActionResult"); + var iResultType = semanticModel.Compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Http.IResult"); + + // these two types may be false if the user is not using ASP.NET Core Integration + if (SymbolEqualityComparer.Default.Equals(symbol, iActionResultType) || + SymbolEqualityComparer.Default.Equals(symbol, iResultType)) + { + return false; + } + + return SymbolEqualityComparer.Default.Equals(symbol, iActionResultType) || SymbolEqualityComparer.Default.Equals(symbol, iResultType); + } + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/ITypeSymbolExtensions.cs b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/ITypeSymbolExtensions.cs new file mode 100644 index 000000000..25e7220f0 --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/ITypeSymbolExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore +{ + internal static class ITypeSymbolExtensions + { + internal static bool IsOrDerivedFrom(this ITypeSymbol symbol, ITypeSymbol other) + { + if (other is null) + { + return false; + } + + var current = symbol; + + while (current != null) + { + if (SymbolEqualityComparer.Default.Equals(current, other) || SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, other)) + { + return true; + } + + current = current.BaseType; + } + + return false; + } + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Properties/AssemblyInfo.cs b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..bff3655d7 --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] \ No newline at end of file diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Worker.Extensions.Http.AspNetCore.Analyzers.csproj b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Worker.Extensions.Http.AspNetCore.Analyzers.csproj index 022514d9a..6d37a744e 100644 --- a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Worker.Extensions.Http.AspNetCore.Analyzers.csproj +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Worker.Extensions.Http.AspNetCore.Analyzers.csproj @@ -1,7 +1,7 @@  - 1.0.2 + 1.0.3 Library true false diff --git a/extensions/Worker.Extensions.Http.AspNetCore/release_notes.md b/extensions/Worker.Extensions.Http.AspNetCore/release_notes.md index 7b79522ed..ac26c8851 100644 --- a/extensions/Worker.Extensions.Http.AspNetCore/release_notes.md +++ b/extensions/Worker.Extensions.Http.AspNetCore/release_notes.md @@ -6,4 +6,8 @@ ### Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore -- Fixed a bug that would lead to an empty exception message in some model binding failures. +- Updated`Updated Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers` 1.0.3 + +### Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers 1.0.3 + +- Add analyzer that detects multiple-output binding scenarios for HTTP Trigger Functions. Read more about this scenario [here](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-output?tabs=isolated-process%2Cnodejs-v4&pivots=programming-language-csharp#usage) in our official docs. (#2706) diff --git a/test/Sdk.Generator.Tests/SymbolExtensionsTest/SymbolExtensionsTest.cs b/test/Sdk.Generator.Tests/SymbolExtensionsTest/SymbolExtensionsTest.cs new file mode 100644 index 000000000..0eb389a99 --- /dev/null +++ b/test/Sdk.Generator.Tests/SymbolExtensionsTest/SymbolExtensionsTest.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System.Linq; +using Microsoft.Azure.Functions.Worker.Sdk.Generators; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace Microsoft.Azure.Functions.SdkGeneratorTests.SymbolExtensionsTest +{ + public class SymbolExtensionsTest + { + [Fact] + public void TestIsOrDerivedFrom_WhenImplementationExists() + { + var sourceCode = @" + internal class BaseAttribute + { + } + + internal class FooAttribute : BaseAttribute + { + } + + internal class FooAttributeTwo : FooAttribute + { + }"; + + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + var compilation = CSharpCompilation.Create("MyCompilation", new[] { syntaxTree }); + var semanticModel = compilation.GetSemanticModel(syntaxTree); + + // Retrieve the symbol for the FooOutAttribute class + var root = syntaxTree.GetRoot(); + var baseAttributeClassDeclaration = root.DescendantNodes().OfType().First(cd => cd.Identifier.Text == "BaseAttribute"); + var fooClassDeclaration = root.DescendantNodes().OfType().First(cd => cd.Identifier.Text == "FooAttribute"); + var fooTwoClassDeclaration = root.DescendantNodes().OfType().First(cd => cd.Identifier.Text == "FooAttributeTwo"); + var baseAttributeSymbol = semanticModel.GetDeclaredSymbol(baseAttributeClassDeclaration); + var fooSymbol = semanticModel.GetDeclaredSymbol(fooClassDeclaration); + var fooTwoSymbol = semanticModel.GetDeclaredSymbol(fooTwoClassDeclaration); + + Assert.NotNull(baseAttributeSymbol); + Assert.NotNull(fooSymbol); + Assert.NotNull(fooTwoSymbol); + + Assert.True(fooSymbol.IsOrDerivedFrom(baseAttributeSymbol)); + Assert.True(fooTwoSymbol.IsOrDerivedFrom(baseAttributeSymbol)); + Assert.True(fooTwoSymbol.IsOrDerivedFrom(fooSymbol)); + } + + [Fact] + public void TestIsOrDerivedFrom_WhenImplementationDoesNotExist() + { + var sourceCode = @" + internal class BaseAttribute + { + } + + internal class FooAttribute + { + } + + internal class OtherBaseAttribute + { + } + + internal class OtherFooAttribute : OtherBaseAttribute + { + }"; + + var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode); + var compilation = CSharpCompilation.Create("MyCompilation", new[] { syntaxTree }); + var semanticModel = compilation.GetSemanticModel(syntaxTree); + + // Retrieve the symbol for the FooOutAttribute class + var root = syntaxTree.GetRoot(); + var baseAttributeClassDeclaration = root.DescendantNodes().OfType().First(cd => cd.Identifier.Text == "BaseAttribute"); + var fooClassDeclaration = root.DescendantNodes().OfType().First(cd => cd.Identifier.Text == "FooAttribute"); + var otherBaseAttributeClassDeclaration = root.DescendantNodes().OfType().First(cd => cd.Identifier.Text == "OtherBaseAttribute"); + var otherFooClassDeclaration = root.DescendantNodes().OfType().First(cd => cd.Identifier.Text == "OtherFooAttribute"); + var baseAttributeSymbol = semanticModel.GetDeclaredSymbol(baseAttributeClassDeclaration); + var fooSymbol = semanticModel.GetDeclaredSymbol(fooClassDeclaration); + var otherBaseAttributeSymbol = semanticModel.GetDeclaredSymbol(otherBaseAttributeClassDeclaration); + var otherFooSymbol = semanticModel.GetDeclaredSymbol(otherFooClassDeclaration); + + Assert.NotNull(baseAttributeSymbol); + Assert.NotNull(fooSymbol); + Assert.NotNull(otherBaseAttributeSymbol); + Assert.NotNull(otherFooSymbol); + + Assert.False(fooSymbol.IsOrDerivedFrom(baseAttributeSymbol)); + Assert.False(otherFooSymbol.IsOrDerivedFrom(baseAttributeSymbol)); + } + } +} diff --git a/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/HttpResultAttributeExpectedTests.cs b/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/HttpResultAttributeExpectedTests.cs new file mode 100644 index 000000000..71a5c394c --- /dev/null +++ b/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/HttpResultAttributeExpectedTests.cs @@ -0,0 +1,365 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using AnalyzerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest; +using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier; +using CodeFixTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixTest; +using CodeFixVerifier = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixVerifier; +using Microsoft.CodeAnalysis.Testing; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Tests +{ + public class HttpResultAttributeExpectedTests + { + [Fact] + public async Task HttpResultAttribute_WhenUsingIActionResultAndMultiOutput_Expected() + { + string testCode = @" + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Azure.Functions.Worker; + + namespace AspNetIntegration + { + public class MultipleOutputBindings + { + [Function(""MultipleOutputBindings"")] + public MyOutputType Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequest req) + { + throw new NotImplementedException(); + } + public class MyOutputType + { + public IActionResult Result { get; set; } + + [BlobOutput(""test-samples-output/{name}-output.txt"")] + public string MessageText { get; set; } + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + test.ExpectedDiagnostics.Add(Verifier.Diagnostic(DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute) + .WithSeverity(DiagnosticSeverity.Error) + .WithLocation(12, 28) + .WithArguments("\"MultipleOutputBindings\"")); + + await test.RunAsync(); + } + + [Fact] + public async Task HttpResultAttributeUsedCorrectly_NoDiagnostic() + { + string testCode = @" + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Azure.Functions.Worker; + + namespace AspNetIntegration + { + public class MultipleOutputBindings + { + [Function(""MultipleOutputBindings"")] + public MyOutputType Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequest req) + { + throw new NotImplementedException(); + } + public class MyOutputType + { + [HttpResult] + public IActionResult Result { get; set; } + + [BlobOutput(""test-samples-output/{name}-output.txt"")] + public string MessageText { get; set; } + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + await test.RunAsync(); + } + + [Fact] + public async Task SimpleHttpTrigger_NoDiagnostic() + { + string testCode = @" + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Azure.Functions.Worker; + + namespace AspNetIntegration + { + public class MultipleOutputBindings + { + [Function(""SimpleHttpTrigger"")] + public IActionResult Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequest req) + { + throw new NotImplementedException(); + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + await test.RunAsync(); + } + + [Fact] + public async Task PocoUsedWithoutOutputBindings_NoDiagnostic() + { + string testCode = @" + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Azure.Functions.Worker; + + namespace AspNetIntegration + { + public class MultipleOutputBindings + { + [Function(""PocoOutput"")] + public MyOutputType Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequest req) + { + throw new NotImplementedException(); + } + public class MyOutputType + { + public string Name { get; set; } + + public string MessageText { get; set; } + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + await test.RunAsync(); + } + + [Fact] + public async Task HttpResultAttributeWarning_WhenUsingHttpResponseDataAndMultiOutput_Expected() + { + string testCode = @" + using System; + using Microsoft.AspNetCore.Http; + using Microsoft.Azure.Functions.Worker.Http; + using Microsoft.Azure.Functions.Worker; + + namespace AspNetIntegration + { + public class MultipleOutputBindings + { + [Function(""MultipleOutputBindings"")] + public MyOutputType Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequest req) + { + throw new NotImplementedException(); + } + public class MyOutputType + { + public HttpResponseData Result { get; set; } + + [BlobOutput(""test-samples-output/{name}-output.txt"")] + public string MessageText { get; set; } + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + test.ExpectedDiagnostics.Add(Verifier.Diagnostic(DiagnosticDescriptors.MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute) + .WithSeverity(DiagnosticSeverity.Warning) + .WithLocation(12, 28) + .WithArguments("\"MultipleOutputBindings\"")); + + await test.RunAsync(); + } + + [Fact] + public async Task HttpResultAttributeExpected_CodeFixWorks() + { + string inputCode = @" +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; + +namespace AspNetIntegration +{ + public class MultipleOutputBindings + { + [Function(""MultipleOutputBindings"")] + public MyOutputType Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequest req) + { + throw new NotImplementedException(); + } + public class MyOutputType + { + public IActionResult Result { get; set; } + + [BlobOutput(""test-samples-output/{name}-output.txt"")] + public string MessageText { get; set; } + } + } +}"; + + string expectedCode = @" +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; + +namespace AspNetIntegration +{ + public class MultipleOutputBindings + { + [Function(""MultipleOutputBindings"")] + public MyOutputType Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequest req) + { + throw new NotImplementedException(); + } + public class MyOutputType + { + [HttpResult] + public IActionResult Result { get; set; } + + [BlobOutput(""test-samples-output/{name}-output.txt"")] + public string MessageText { get; set; } + } + } +}"; + + + var expectedDiagnosticResult = CodeFixVerifier + .Diagnostic("AZFW0015") + .WithSeverity(DiagnosticSeverity.Error) + .WithLocation(12, 16) + .WithArguments("\"MultipleOutputBindings\""); + + var test = new CodeFixTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = inputCode, + FixedCode = expectedCode + }; + + test.ExpectedDiagnostics.AddRange(new[] { expectedDiagnosticResult }); + await test.RunAsync(); + } + + [Fact] + public async Task HttpResultAttributeForHttpResponseDataExpected_CodeFixWorks() + { + string inputCode = @" +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; + +namespace AspNetIntegration +{ + public class MultipleOutputBindings + { + [Function(""MultipleOutputBindings"")] + public MyOutputType Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequestData req) + { + throw new NotImplementedException(); + } + public class MyOutputType + { + public HttpResponseData Result { get; set; } + + [BlobOutput(""test-samples-output/{name}-output.txt"")] + public string MessageText { get; set; } + } + } +}"; + + string expectedCode = @" +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; + +namespace AspNetIntegration +{ + public class MultipleOutputBindings + { + [Function(""MultipleOutputBindings"")] + public MyOutputType Run([HttpTrigger(AuthorizationLevel.Function, ""post"")] HttpRequestData req) + { + throw new NotImplementedException(); + } + public class MyOutputType + { + [HttpResult] + public HttpResponseData Result { get; set; } + + [BlobOutput(""test-samples-output/{name}-output.txt"")] + public string MessageText { get; set; } + } + } +}"; + + + var expectedDiagnosticResult = CodeFixVerifier + .Diagnostic("AZFW0016") + .WithSeverity(DiagnosticSeverity.Warning) + .WithLocation(13, 16) + .WithArguments("\"MultipleOutputBindings\""); + + var test = new CodeFixTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = inputCode, + FixedCode = expectedCode + }; + + test.ExpectedDiagnostics.AddRange(new[] { expectedDiagnosticResult }); + await test.RunAsync(); + } + + private static ReferenceAssemblies LoadRequiredDependencyAssemblies() + { + var referenceAssemblies = ReferenceAssemblies.Net.Net60.WithPackages(ImmutableArray.Create( + new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.22.0"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Sdk", "1.17.4"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs", "6.0.0"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore", "1.3.2"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Abstractions", "5.0.0"), + new PackageIdentity("Microsoft.AspNetCore.Mvc.Core", "2.2.5"), + new PackageIdentity("Microsoft.Extensions.Hosting.Abstractions", "6.0.0"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Http", "3.2.0"))); + + return referenceAssemblies; + } + } +}