Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Analyzer for Multiple-Output Binding Scenarios with ASP.NET Core Integration #2706

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/analyzer-rules/AZFW0014.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# AZFW0011: Missing Registration for ASP.NET Core Integration
# AZFW0014: Missing Registration for ASP.NET Core Integration

| | Value |
|-|-|
Expand Down
44 changes: 44 additions & 0 deletions docs/analyzer-rules/AZFW0015.md
Original file line number Diff line number Diff line change
@@ -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. This does not apply to properties of the type `HttpResponseData`.

## 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 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.
liliankasem marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// 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<string> FixableDiagnosticIds =>
ImmutableArray.Create(DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute.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;
}

/// <summary>
/// CodeAction implementation which adds the HttpResultAttribute on the return type of a function using the multi-output bindings pattern.
/// </summary>
private sealed class AddHttpResultAttribute : CodeAction
{
private readonly Document _document;
private readonly Diagnostic _diagnostic;
private const string ExpectedAttributeName = "HttpResult";


satvu marked this conversation as resolved.
Show resolved Hide resolved
internal AddHttpResultAttribute(Document document, Diagnostic diagnostic)
{
this._document = document;
this._diagnostic = diagnostic;
}

public override string Title => "Add HttpResultAttribute";

public override string EquivalenceKey => null;

/// <summary>
/// Returns an updated Document with HttpResultAttribute added to the relevant property.
/// </summary>
protected override async Task<Document> GetChangedDocumentAsync(CancellationToken cancellationToken)
satvu marked this conversation as resolved.
Show resolved Hide resolved
{
// 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<TypeSyntax>();

var typeSymbol = semanticModel.GetSymbolInfo(typeNode).Symbol;
var typeDeclarationSyntaxReference = typeSymbol.DeclaringSyntaxReferences.FirstOrDefault();
if (typeDeclarationSyntaxReference == null)
satvu marked this conversation as resolved.
Show resolved Hide resolved
{
return _document;
}

var typeDeclarationNode = await typeDeclarationSyntaxReference.GetSyntaxAsync(cancellationToken);

var propertyNode = typeDeclarationNode.DescendantNodes()
.OfType<PropertyDeclarationSyntax>()
.First(prop =>
{
var propertyType = semanticModel.GetTypeInfo(prop.Type).Type;
return propertyType != null && propertyType.Name == "IActionResult";
});

var attribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName(ExpectedAttributeName));

var newPropertyNode = propertyNode
.AddAttributeLists(SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute)));

var newRoot = root.ReplaceNode(propertyNode, newPropertyNode);
satvu marked this conversation as resolved.
Show resolved Hide resolved

return _document.WithSyntaxRoot(newRoot);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ 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 an HttpResultAttribute on the HTTP response type property.",
satvu marked this conversation as resolved.
Show resolved Hide resolved
category: Usage, severity: DiagnosticSeverity.Error);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// 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<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute);

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<IPropertySymbol>()
.Any(prop => prop.GetAttributes().Any(attr => attr.AttributeClass.IsOrDerivedFrom(outputBindingSymbol)));

if (!hasOutputBindingProperty)
{
return;
}

var httpResponseDataSymbol = semanticModel.Compilation.GetTypeByMetadataName(HttpResponseDataFullName);
var hasHttpResponseData = returnTypeSymbol.GetMembers()
.OfType<IPropertySymbol>()
.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);
}
}

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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please create a unit test for this this file and test the IsOrDerivedFrom method (should be done for all helper methods/extensions)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this for another iteration - need to investigate how to best test this as we may need access to a compilation and semantic model.

{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this method need to take into account interfaces at all?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that OutputBindingAttribute is not an interface, this will successfully find classes that implement the abstract class.

For classes that implement some interface, this should just exit (BaseType returns null).

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;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<VersionPrefix>1.0.2</VersionPrefix>
<VersionPrefix>1.0.3</VersionPrefix>
<OutputType>Library</OutputType>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<IncludeBuildOutput>false</IncludeBuildOutput>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@

### Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore <version>

- 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)
Loading
Loading