Skip to content

Commit

Permalink
Analyzer for Multiple-Output Binding Scenarios with ASP.NET Core Inte…
Browse files Browse the repository at this point in the history
…gration (#2706)
  • Loading branch information
satvu authored Oct 16, 2024
1 parent b1a94a1 commit 7b1bd29
Show file tree
Hide file tree
Showing 12 changed files with 824 additions and 3 deletions.
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. 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.
46 changes: 46 additions & 0 deletions docs/analyzer-rules/AZFW0016.md
Original file line number Diff line number Diff line change
@@ -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`).
Original file line number Diff line number Diff line change
@@ -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<string> FixableDiagnosticIds =>
ImmutableArray.Create<string>(
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;
}

/// <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";

internal AddHttpResultAttribute(Document document, Diagnostic diagnostic)
{
this._document = document;
this._diagnostic = diagnostic;
}

public override string Title => "Add HttpResultAttribute";

public override string EquivalenceKey => null;

/// <summary>
/// Asynchronously retrieves the modified <see cref="Document"/>, with the HttpResultAttribute added to the relevant property.
/// </summary>
/// <param name="cancellationToken">A token that can be used to propagate notifications that the operation should be canceled.</param>
/// <returns>An updated <see cref="Document"/> object.</returns>
protected override async Task<Document> 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<TypeSyntax>();

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<PropertyDeclarationSyntax>()
.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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> 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<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);
}

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);
}
}
}
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
{
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
@@ -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")]
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

0 comments on commit 7b1bd29

Please sign in to comment.