Skip to content

Commit 7b1bd29

Browse files
authored
Analyzer for Multiple-Output Binding Scenarios with ASP.NET Core Integration (#2706)
1 parent b1a94a1 commit 7b1bd29

File tree

12 files changed

+824
-3
lines changed

12 files changed

+824
-3
lines changed

docs/analyzer-rules/AZFW0014.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# AZFW0011: Missing Registration for ASP.NET Core Integration
1+
# AZFW0014: Missing Registration for ASP.NET Core Integration
22

33
| | Value |
44
|-|-|

docs/analyzer-rules/AZFW0015.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# AZFW0015: Missing HttpResult attribute for multi-output function
2+
3+
| | Value |
4+
|-|-|
5+
| **Rule ID** |AZFW00015|
6+
| **Category** |[Usage]|
7+
| **Severity** |Error|
8+
9+
## Cause
10+
11+
This rule is triggered when a multi-output function is missing a `HttpResultAttribute` on the HTTP response type.
12+
13+
## Rule description
14+
15+
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.
16+
17+
## How to fix violations
18+
19+
Add the attribute `[HttpResult]` (or `[HttpResultAttribute]`) to the relevant property. Example:
20+
21+
```csharp
22+
public static class MultiOutput
23+
{
24+
[Function(nameof(MultiOutput))]
25+
public static MyOutputType Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req,
26+
FunctionContext context)
27+
{
28+
...
29+
}
30+
}
31+
32+
public class MyOutputType
33+
{
34+
[QueueOutput("myQueue")]
35+
public string Name { get; set; }
36+
37+
[HttpResult]
38+
public IActionResult HttpResponse { get; set; }
39+
}
40+
```
41+
42+
## When to suppress warnings
43+
44+
This rule should not be suppressed because this error will prevent the HTTP response from being written correctly.

docs/analyzer-rules/AZFW0016.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# AZFW0016: Missing HttpResult attribute for multi-output function
2+
3+
| | Value |
4+
|-|-|
5+
| **Rule ID** |AZFW00016|
6+
| **Category** |[Usage]|
7+
| **Severity** |Warning|
8+
9+
## Cause
10+
11+
This rule is triggered when a multi-output function using `HttpResponseData` is missing a `HttpResultAttribute` on the HTTP response type.
12+
13+
## Rule description
14+
15+
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`.
16+
17+
`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.
18+
19+
## How to fix violations
20+
21+
Add the attribute `[HttpResult]` (or `[HttpResultAttribute]`) to the relevant property. Example:
22+
23+
```csharp
24+
public static class MultiOutput
25+
{
26+
[Function(nameof(MultiOutput))]
27+
public static MyOutputType Run([HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req,
28+
FunctionContext context)
29+
{
30+
...
31+
}
32+
}
33+
34+
public class MyOutputType
35+
{
36+
[QueueOutput("myQueue")]
37+
public string Name { get; set; }
38+
39+
[HttpResult]
40+
public HttpResponseData HttpResponse { get; set; }
41+
}
42+
```
43+
44+
## When to suppress warnings
45+
46+
This rule can be suppressed if there is no intention to migrate from `HttpResponseData` to other types (like `IActionResult`).
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System.Collections.Immutable;
5+
using System.Composition;
6+
using System.Linq;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.CodeActions;
11+
using Microsoft.CodeAnalysis.CodeFixes;
12+
using Microsoft.CodeAnalysis.CSharp;
13+
using Microsoft.CodeAnalysis.CSharp.Syntax;
14+
15+
namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
16+
{
17+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CodeFixForHttpResultAttribute)), Shared]
18+
public sealed class CodeFixForHttpResultAttribute : CodeFixProvider
19+
{
20+
public override ImmutableArray<string> FixableDiagnosticIds =>
21+
ImmutableArray.Create<string>(
22+
DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute.Id,
23+
DiagnosticDescriptors.MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute.Id);
24+
25+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
26+
27+
public override Task RegisterCodeFixesAsync(CodeFixContext context)
28+
{
29+
Diagnostic diagnostic = context.Diagnostics.First();
30+
context.RegisterCodeFix(new AddHttpResultAttribute(context.Document, diagnostic), diagnostic);
31+
32+
return Task.CompletedTask;
33+
}
34+
35+
/// <summary>
36+
/// CodeAction implementation which adds the HttpResultAttribute on the return type of a function using the multi-output bindings pattern.
37+
/// </summary>
38+
private sealed class AddHttpResultAttribute : CodeAction
39+
{
40+
private readonly Document _document;
41+
private readonly Diagnostic _diagnostic;
42+
private const string ExpectedAttributeName = "HttpResult";
43+
44+
internal AddHttpResultAttribute(Document document, Diagnostic diagnostic)
45+
{
46+
this._document = document;
47+
this._diagnostic = diagnostic;
48+
}
49+
50+
public override string Title => "Add HttpResultAttribute";
51+
52+
public override string EquivalenceKey => null;
53+
54+
/// <summary>
55+
/// Asynchronously retrieves the modified <see cref="Document"/>, with the HttpResultAttribute added to the relevant property.
56+
/// </summary>
57+
/// <param name="cancellationToken">A token that can be used to propagate notifications that the operation should be canceled.</param>
58+
/// <returns>An updated <see cref="Document"/> object.</returns>
59+
protected override async Task<Document> GetChangedDocumentAsync(CancellationToken cancellationToken)
60+
{
61+
// Get the syntax root of the document
62+
var root = await _document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
63+
var semanticModel = await _document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
64+
65+
var typeNode = root.FindNode(this._diagnostic.Location.SourceSpan)
66+
.FirstAncestorOrSelf<TypeSyntax>();
67+
68+
var typeSymbol = semanticModel.GetSymbolInfo(typeNode).Symbol;
69+
var typeDeclarationSyntaxReference = typeSymbol.DeclaringSyntaxReferences.FirstOrDefault();
70+
if (typeDeclarationSyntaxReference is null)
71+
{
72+
return _document;
73+
}
74+
75+
var typeDeclarationNode = await typeDeclarationSyntaxReference.GetSyntaxAsync(cancellationToken);
76+
77+
var propertyNode = typeDeclarationNode.DescendantNodes()
78+
.OfType<PropertyDeclarationSyntax>()
79+
.First(prop =>
80+
{
81+
var propertyType = semanticModel.GetTypeInfo(prop.Type).Type;
82+
return propertyType != null && (propertyType.Name == "IActionResult" || propertyType.Name == "HttpResponseData" || propertyType.Name == "IResult");
83+
});
84+
85+
var attribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName(ExpectedAttributeName));
86+
87+
var newPropertyNode = propertyNode
88+
.AddAttributeLists(SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute)));
89+
90+
var newRoot = root.ReplaceNode(propertyNode, newPropertyNode);
91+
92+
return _document.WithSyntaxRoot(newRoot);
93+
}
94+
}
95+
}
96+
}

extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/DiagnosticDescriptors.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,12 @@ private static DiagnosticDescriptor Create(string id, string title, string messa
1818
public static DiagnosticDescriptor CorrectRegistrationExpectedInAspNetIntegration { get; }
1919
= 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.",
2020
category: Usage, severity: DiagnosticSeverity.Error);
21+
public static DiagnosticDescriptor MultipleOutputHttpTriggerWithoutHttpResultAttribute { get; }
22+
= 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.",
23+
category: Usage, severity: DiagnosticSeverity.Error);
24+
25+
public static DiagnosticDescriptor MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute { get; }
26+
= 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.",
27+
category: Usage, severity: DiagnosticSeverity.Warning);
2128
}
2229
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System.Collections.Immutable;
5+
using System.Linq;
6+
using Microsoft.CodeAnalysis;
7+
using Microsoft.CodeAnalysis.CSharp;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
9+
using Microsoft.CodeAnalysis.Diagnostics;
10+
11+
namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
12+
{
13+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
14+
public sealed class HttpResultAttributeExpectedAnalyzer : DiagnosticAnalyzer
15+
{
16+
private const string FunctionAttributeFullName = "Microsoft.Azure.Functions.Worker.FunctionAttribute";
17+
private const string HttpTriggerAttributeFullName = "Microsoft.Azure.Functions.Worker.HttpTriggerAttribute";
18+
private const string HttpResultAttributeFullName = "Microsoft.Azure.Functions.Worker.HttpResultAttribute";
19+
public const string HttpResponseDataFullName = "Microsoft.Azure.Functions.Worker.Http.HttpResponseData";
20+
public const string OutputBindingFullName = "Microsoft.Azure.Functions.Worker.Extensions.Abstractions.OutputBindingAttribute";
21+
22+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute,
23+
DiagnosticDescriptors.MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute);
24+
25+
public override void Initialize(AnalysisContext context)
26+
{
27+
context.EnableConcurrentExecution();
28+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
29+
context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration);
30+
}
31+
32+
private static void AnalyzeMethod(SyntaxNodeAnalysisContext context)
33+
{
34+
var semanticModel = context.SemanticModel;
35+
var methodDeclaration = (MethodDeclarationSyntax)context.Node;
36+
37+
var functionAttributeSymbol = semanticModel.Compilation.GetTypeByMetadataName(FunctionAttributeFullName);
38+
var functionNameAttribute = methodDeclaration.AttributeLists
39+
.SelectMany(attrList => attrList.Attributes)
40+
.Where(attr => SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(attr).Type, functionAttributeSymbol));
41+
42+
if (!functionNameAttribute.Any())
43+
{
44+
return;
45+
}
46+
47+
var functionName = functionNameAttribute.First().ArgumentList.Arguments[0]; // only one argument in FunctionAttribute which is the function name
48+
49+
var httpTriggerAttributeSymbol = semanticModel.Compilation.GetTypeByMetadataName(HttpTriggerAttributeFullName);
50+
var hasHttpTriggerAttribute = methodDeclaration.ParameterList.Parameters
51+
.SelectMany(param => param.AttributeLists)
52+
.SelectMany(attrList => attrList.Attributes)
53+
.Select(attr => semanticModel.GetTypeInfo(attr).Type)
54+
.Any(attrSymbol => SymbolEqualityComparer.Default.Equals(attrSymbol, httpTriggerAttributeSymbol));
55+
56+
if (!hasHttpTriggerAttribute)
57+
{
58+
return;
59+
}
60+
61+
var returnType = methodDeclaration.ReturnType;
62+
var returnTypeSymbol = semanticModel.GetTypeInfo(returnType).Type;
63+
64+
if (IsHttpReturnType(returnTypeSymbol, semanticModel))
65+
{
66+
return;
67+
}
68+
69+
var outputBindingSymbol = semanticModel.Compilation.GetTypeByMetadataName(OutputBindingFullName);
70+
var hasOutputBindingProperty = returnTypeSymbol.GetMembers()
71+
.OfType<IPropertySymbol>()
72+
.Any(prop => prop.GetAttributes().Any(attr => attr.AttributeClass.IsOrDerivedFrom(outputBindingSymbol)));
73+
74+
if (!hasOutputBindingProperty)
75+
{
76+
return;
77+
}
78+
79+
var httpResponseDataSymbol = semanticModel.Compilation.GetTypeByMetadataName(HttpResponseDataFullName);
80+
var hasHttpResponseData = returnTypeSymbol.GetMembers()
81+
.OfType<IPropertySymbol>()
82+
.Any(prop => SymbolEqualityComparer.Default.Equals(prop.Type, httpResponseDataSymbol));
83+
84+
var httpResultAttributeSymbol = semanticModel.Compilation.GetTypeByMetadataName(HttpResultAttributeFullName);
85+
var hasHttpResultAttribute = returnTypeSymbol.GetMembers()
86+
.SelectMany(member => member.GetAttributes())
87+
.Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, httpResultAttributeSymbol));
88+
89+
if (!hasHttpResultAttribute && !hasHttpResponseData)
90+
{
91+
var diagnostic = Diagnostic.Create(DiagnosticDescriptors.MultipleOutputHttpTriggerWithoutHttpResultAttribute, methodDeclaration.ReturnType.GetLocation(), functionName.ToString());
92+
context.ReportDiagnostic(diagnostic);
93+
}
94+
95+
if (!hasHttpResultAttribute && hasHttpResponseData)
96+
{
97+
var diagnostic = Diagnostic.Create(DiagnosticDescriptors.MultipleOutputWithHttpResponseDataWithoutHttpResultAttribute, methodDeclaration.ReturnType.GetLocation(), functionName.ToString());
98+
context.ReportDiagnostic(diagnostic);
99+
}
100+
101+
}
102+
103+
private static bool IsHttpReturnType(ISymbol symbol, SemanticModel semanticModel)
104+
{
105+
var httpRequestDataType = semanticModel.Compilation.GetTypeByMetadataName("Microsoft.Azure.Functions.Worker.Http.HttpRequestData");
106+
107+
if (SymbolEqualityComparer.Default.Equals(symbol, httpRequestDataType))
108+
{
109+
return true;
110+
}
111+
112+
var iActionResultType = semanticModel.Compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Mvc.IActionResult");
113+
var iResultType = semanticModel.Compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Http.IResult");
114+
115+
// these two types may be false if the user is not using ASP.NET Core Integration
116+
if (SymbolEqualityComparer.Default.Equals(symbol, iActionResultType) ||
117+
SymbolEqualityComparer.Default.Equals(symbol, iResultType))
118+
{
119+
return false;
120+
}
121+
122+
return SymbolEqualityComparer.Default.Equals(symbol, iActionResultType) || SymbolEqualityComparer.Default.Equals(symbol, iResultType);
123+
}
124+
}
125+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using Microsoft.CodeAnalysis;
5+
6+
namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
7+
{
8+
internal static class ITypeSymbolExtensions
9+
{
10+
internal static bool IsOrDerivedFrom(this ITypeSymbol symbol, ITypeSymbol other)
11+
{
12+
if (other is null)
13+
{
14+
return false;
15+
}
16+
17+
var current = symbol;
18+
19+
while (current != null)
20+
{
21+
if (SymbolEqualityComparer.Default.Equals(current, other) || SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, other))
22+
{
23+
return true;
24+
}
25+
26+
current = current.BaseType;
27+
}
28+
29+
return false;
30+
}
31+
}
32+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System.Runtime.CompilerServices;
5+
6+
[assembly: InternalsVisibleTo("Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")]

extensions/Worker.Extensions.Http.AspNetCore.Analyzers/src/Worker.Extensions.Http.AspNetCore.Analyzers.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<VersionPrefix>1.0.2</VersionPrefix>
4+
<VersionPrefix>1.0.3</VersionPrefix>
55
<OutputType>Library</OutputType>
66
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
77
<IncludeBuildOutput>false</IncludeBuildOutput>

extensions/Worker.Extensions.Http.AspNetCore/release_notes.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@
66

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

9-
- Fixed a bug that would lead to an empty exception message in some model binding failures.
9+
- Updated`Updated Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers` 1.0.3
10+
11+
### Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers 1.0.3
12+
13+
- 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)

0 commit comments

Comments
 (0)