diff --git a/DotNetWorker.sln b/DotNetWorker.sln index 560d5c452..76950c9d7 100644 --- a/DotNetWorker.sln +++ b/DotNetWorker.sln @@ -136,6 +136,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DependentAssemblyWithFuncti EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Worker.Extensions.Http.AspNetCore.Tests", "test\extensions\Worker.Extensions.Http.AspNetCore.Tests\Worker.Extensions.Http.AspNetCore.Tests.csproj", "{D8E79B53-9A44-46CC-9D7A-E9494FC8CAF4}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Worker.Extensions.Http.AspNetCore.Analyzers", "extensions\Worker.Extensions.Http.AspNetCore.Analyzers\Worker.Extensions.Http.AspNetCore.Analyzers.csproj", "{7B6C2920-7A02-43B2-8DA0-7B76B9FAFC6B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -334,6 +336,10 @@ Global {AB6E1E7A-0D2C-4086-9487-566887C1E753}.Debug|Any CPU.Build.0 = Debug|Any CPU {AB6E1E7A-0D2C-4086-9487-566887C1E753}.Release|Any CPU.ActiveCfg = Release|Any CPU {AB6E1E7A-0D2C-4086-9487-566887C1E753}.Release|Any CPU.Build.0 = Release|Any CPU + {7B6C2920-7A02-43B2-8DA0-7B76B9FAFC6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B6C2920-7A02-43B2-8DA0-7B76B9FAFC6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B6C2920-7A02-43B2-8DA0-7B76B9FAFC6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B6C2920-7A02-43B2-8DA0-7B76B9FAFC6B}.Release|Any CPU.Build.0 = Release|Any CPU {D8E79B53-9A44-46CC-9D7A-E9494FC8CAF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D8E79B53-9A44-46CC-9D7A-E9494FC8CAF4}.Debug|Any CPU.Build.0 = Debug|Any CPU {D8E79B53-9A44-46CC-9D7A-E9494FC8CAF4}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -397,6 +403,7 @@ Global {D2F67410-9933-42E8-B04A-E17634D83A30} = {9D6603BD-7EA2-4D11-A69C-0D9E01317FD6} {AB6E1E7A-0D2C-4086-9487-566887C1E753} = {B5821230-6E0A-4535-88A9-ED31B6F07596} {D8E79B53-9A44-46CC-9D7A-E9494FC8CAF4} = {AA4D318D-101B-49E7-A4EC-B34165AEDB33} + {7B6C2920-7A02-43B2-8DA0-7B76B9FAFC6B} = {A7B4FF1E-3DF7-4F28-9333-D0961CDDF702} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {497D2ED4-A13E-4BCA-8D29-F30CA7D0EA4A} diff --git a/docs/analyzer-rules/AZFW0014.md b/docs/analyzer-rules/AZFW0014.md new file mode 100644 index 000000000..b9ec2f72a --- /dev/null +++ b/docs/analyzer-rules/AZFW0014.md @@ -0,0 +1,25 @@ +# AZFW0011: Missing Registration for ASP.NET Core Integration + +| | Value | +|-|-| +| **Rule ID** |AZFW00014| +| **Category** |[Usage]| +| **Severity** |Error| + +## Cause + +This rule is triggered when worker using the ASP.NET Core Integration uses `ConfigureFunctionsWorkerDefaults()`. + +## Rule description + +When using the ASP.NET Core Integration, the worker must configure the worker using the `ConfigureFunctionsWebApplication()` method. This rule will be violated when the worker uses `ConfigureFunctionsWorkerDefaults()`. + + +## How to fix violations + +Replace usage of `ConfigureFunctionsWorkerDefaults` with `ConfigureFunctionsWebApplication` method in the Function App. Refer to [public documentation](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide#aspnet-core-integration) for guidance on ASP.NET Core Integration. + + +## When to suppress + +It is okay to suppress this if `ConfigureFunctionsWebApplication` is called indirectly through a different API. \ No newline at end of file diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/DiagnosticDescriptors.cs b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/DiagnosticDescriptors.cs new file mode 100644 index 000000000..a95118f5c --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/DiagnosticDescriptors.cs @@ -0,0 +1,22 @@ +// 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 class DiagnosticDescriptors + { + public const string Usage = "Usage"; + + private static DiagnosticDescriptor Create(string id, string title, string messageFormat, string category, DiagnosticSeverity severity) + { + var helpLink = $"https://aka.ms/azfw-rules?ruleid={id}"; + return new DiagnosticDescriptor(id, title, messageFormat, category, severity, isEnabledByDefault: true, helpLinkUri: helpLink); + } + + 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); + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/RegistrationExpectedInASPNetIntegration.cs b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/RegistrationExpectedInASPNetIntegration.cs new file mode 100644 index 000000000..d8d73f732 --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/RegistrationExpectedInASPNetIntegration.cs @@ -0,0 +1,100 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore +{ + /// + /// Analyzer to verify whether expected registration is present for ASP.NET Core Integration. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class RegistrationExpectedInASPNetIntegration : DiagnosticAnalyzer + { + /// Diagnostics supported by the analyzer + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create(DiagnosticDescriptors.CorrectRegistrationExpectedInAspNetIntegration); + + private const string ExpectedRegistrationMethod = "ConfigureFunctionsWebApplication"; + private const string IncorrectRegistrationMethod = "ConfigureFunctionsWorkerDefaults"; + + /// Initialization method + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); + context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method); + } + + private static void AnalyzeMethod(SymbolAnalysisContext context) + { + var symbol = (IMethodSymbol)context.Symbol; + + if (!IsMainMethod(symbol)) + { + return; + } + + var syntaxReference = symbol.DeclaringSyntaxReferences.FirstOrDefault(); + var root = syntaxReference.SyntaxTree.GetRoot(); + var methodCallExpressions = root.DescendantNodes().OfType(); + + if (methodCallExpressions is null) + { + return; + } + + var incorrectMethodCallExpressions = methodCallExpressions.Where(invocation => (invocation.Expression as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText == IncorrectRegistrationMethod); + var incorrectMethodInvocationPresent = incorrectMethodCallExpressions.Any(); + + if (!incorrectMethodInvocationPresent) + { + return; + } + + //Finding exact location of method call + Location location = GetSymbolLocation(root, incorrectMethodCallExpressions); + + var expectedMethodInvocationPresent = methodCallExpressions.Any(invocation => (invocation.Expression as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText == ExpectedRegistrationMethod); + + if (!expectedMethodInvocationPresent) + { + var diagnostic = Diagnostic.Create(DiagnosticDescriptors.CorrectRegistrationExpectedInAspNetIntegration, location, ExpectedRegistrationMethod); + context.ReportDiagnostic(diagnostic); + } + } + + // Checks if a method symbol is a Main method. This also checks for implicit main in top-level statements + private static bool IsMainMethod(IMethodSymbol symbol) + { + return symbol?.IsStatic == true && symbol.Name switch + { + "Main" => true, + "$Main" => true, + "
$" => true, + _ => false + }; + } + + private static Location GetSymbolLocation(SyntaxNode root, IEnumerable methodCallExpressions) + { + Location location = Location.None; + + if (methodCallExpressions != null) + { + var lineSpan = methodCallExpressions.FirstOrDefault().GetLocation().SourceSpan; + var node = root.DescendantNodes(lineSpan) + .First(n => lineSpan.Contains(n.FullSpan)).DescendantNodes() + .OfType().FirstOrDefault(c => c.Identifier.Text == IncorrectRegistrationMethod); + + location = node.GetLocation(); + } + + return location; + } + } +} diff --git a/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/Worker.Extensions.Http.AspNetCore.Analyzers.csproj b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/Worker.Extensions.Http.AspNetCore.Analyzers.csproj new file mode 100644 index 000000000..9bcee7af2 --- /dev/null +++ b/extensions/Worker.Extensions.Http.AspNetCore.Analyzers/Worker.Extensions.Http.AspNetCore.Analyzers.csproj @@ -0,0 +1,34 @@ + + + + 1.0.0 + Library + true + false + netstandard2.0 + Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers + This package provides development time code analysis for ASP.NET Core extensions for .NET isolated functions. + Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers + Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers + disable + $(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToOutput + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + \ No newline at end of file diff --git a/extensions/Worker.Extensions.Http.AspNetCore/release_notes.md b/extensions/Worker.Extensions.Http.AspNetCore/release_notes.md index 795cf8dcc..d01c34552 100644 --- a/extensions/Worker.Extensions.Http.AspNetCore/release_notes.md +++ b/extensions/Worker.Extensions.Http.AspNetCore/release_notes.md @@ -7,3 +7,7 @@ ### Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore - New overload added to `ConfigureFunctionsWebApplication` to take a `HostBuilderContext` (#1925). Thank you @vmcbaptista + +### Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Analyzers + +- Analyzer to detect missing ASP.NET Core Integration registration (#1917) \ No newline at end of file diff --git a/extensions/Worker.Extensions.Http.AspNetCore/src/Worker.Extensions.Http.AspNetCore.csproj b/extensions/Worker.Extensions.Http.AspNetCore/src/Worker.Extensions.Http.AspNetCore.csproj index 5718e34b8..4a9d5d047 100644 --- a/extensions/Worker.Extensions.Http.AspNetCore/src/Worker.Extensions.Http.AspNetCore.csproj +++ b/extensions/Worker.Extensions.Http.AspNetCore/src/Worker.Extensions.Http.AspNetCore.csproj @@ -18,6 +18,7 @@ + diff --git a/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/RegistrationExpectedInAspNetIntegrationTests.cs b/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/RegistrationExpectedInAspNetIntegrationTests.cs new file mode 100644 index 000000000..2860f29a0 --- /dev/null +++ b/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/RegistrationExpectedInAspNetIntegrationTests.cs @@ -0,0 +1,339 @@ +using AnalyzerTest = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerTest; +using Verifier = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier; +using Microsoft.CodeAnalysis.Testing; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore.Tests +{ + public class RegistrationExpectedInAspNetIntegrationTests + { + private const string ExpectedRegistrationMethod = "ConfigureFunctionsWebApplication"; + + [Fact] + public async Task AspNetIntegration_MissingRegistration_Diagnostics_Expected() + { + string testCode = @" + namespace AspNetIntegration + { + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; + + class Program + { + static void Main(string[] args) + { + var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + + host.Run(); + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + test.ExpectedDiagnostics.Add(Verifier.Diagnostic() + .WithSeverity(DiagnosticSeverity.Error) + .WithSpan(16, 34, 16, 66) + .WithArguments(ExpectedRegistrationMethod)); + + await test.RunAsync(); + } + + + [Fact] + public async Task AspNetIntegration_CommentedRegistration_Diagnostics_NotExpected() + { + string testCode = @" + namespace AspNetIntegration + { + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; + + class Program + { + static void Main(string[] args) + { + var host = new HostBuilder() + //.ConfigureFunctionsWorkerDefaults() + //.ConfigureFunctionsWebApplication() + .Build(); + + host.Run(); + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + // test.ExpectedDiagnostics is an empty collection because ConfigureFunctionsWorkerDefaults() is not present + + await test.RunAsync(); + } + + [Fact] + public async Task AspNetIntegration_EmptyMain_Diagnostics_NotExpected() + { + string testCode = @" + namespace AspNetIntegration + { + class Program + { + static void Main(string[] args) + { + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + // test.ExpectedDiagnostics is an empty collection because ConfigureFunctionsWorkerDefaults() is not present + + await test.RunAsync(); + } + + [Fact] + public async Task AspNetIntegrationWithTrigger_MissingRegistration_Diagnostics_Expected() + { + string testCode = @" + namespace AspNetIntegration + { + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; + + class Program + { + static void Main(string[] args) + { + var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + + host.Run(); + } + } + } + + namespace AspNetIntegration + { + using Microsoft.Azure.Functions.Worker; + using Microsoft.Azure.Functions.Worker.Http; + + public class FunctionHttpTrigger + { + [Function(nameof(FunctionHttpTrigger))] + public void Run([HttpTrigger(AuthorizationLevel.Anonymous, ""post"")] HttpRequestData req) + { + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + test.ExpectedDiagnostics.Add(Verifier.Diagnostic() + .WithSeverity(DiagnosticSeverity.Error) + .WithSpan(16, 34, 16, 66) + .WithArguments(ExpectedRegistrationMethod)); + + await test.RunAsync(); + } + + [Fact] + public async Task AspNetIntegration_WithRegistration_Diagnostics_NotExpected() + { + string testCode = @" + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; + + namespace AspNetIntegration + { + class Program + { + static void Main(string[] args) + { + + // + var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .Build(); + + host.Run(); + // + } + + public static void Method1() + { + } + + private static void Method2() + { + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + // test.ExpectedDiagnostics is an empty collection. + + await test.RunAsync(); + } + + [Fact] + public async Task AspNetIntegration_WithMiddleWare_WithRegistration_Diagnostics_NotExpected() + { + string testCode = @" + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; + + namespace AspNetIntegration + { + class Program + { + static void Main(string[] args) + { + #if ENABLE_MIDDLEWARE + var host = new HostBuilder() + .ConfigureFunctionsWebApplication(builder => + { + // can still register middleware and use this extension method the same way + // .ConfigureFunctionsWorkerDefaults() is used + builder.UseWhen((context)=> + { + // We want to use this middleware only for http trigger invocations. + return context.FunctionDefinition.InputBindings.Values + .First(a => a.Type.EndsWith(""Trigger"")).Type == ""httpTrigger""; + }); + }) + .Build(); + host.Run(); + #else + // + var host = new HostBuilder() + .ConfigureFunctionsWebApplication() + .Build(); + + host.Run(); + // + #endif + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + // test.ExpectedDiagnostics is an empty collection. + + await test.RunAsync(); + } + + [Fact] + public async Task AspNetIntegration_WithIncorrectRegistration_Diagnostics_Expected() + { + string testCode = @" + using System.Linq; + using System.Threading.Tasks; + using Microsoft.Azure.Functions.Worker; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; + + namespace AspNetIntegration + { + class Program + { + static void Main(string[] args) + { + + // + var host = new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build(); + + host.Run(); + // + } + + public static void Method1() + { + } + + private static void Method2() + { + } + } + }"; + + var test = new AnalyzerTest + { + ReferenceAssemblies = LoadRequiredDependencyAssemblies(), + TestCode = testCode + }; + + test.ExpectedDiagnostics.Add(Verifier.Diagnostic() + .WithSeverity(DiagnosticSeverity.Error) + .WithSpan(18, 34, 18, 66) + .WithArguments(ExpectedRegistrationMethod)); + + await test.RunAsync(); + } + + private static ReferenceAssemblies LoadRequiredDependencyAssemblies() + { + var referenceAssemblies = ReferenceAssemblies.Net.Net60.WithPackages(ImmutableArray.Create( + new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.19.0"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Sdk", "1.14.1"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs", "6.0.0"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore", "1.0.0"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.Abstractions", "5.0.0"), + new PackageIdentity("Microsoft.Extensions.Hosting.Abstractions", "6.0.0"))); + + return referenceAssemblies; + } + } +} diff --git a/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/Worker.Extensions.Http.AspNetCore.Tests.csproj b/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/Worker.Extensions.Http.AspNetCore.Tests.csproj index 891ce637f..e985cf1d0 100644 --- a/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/Worker.Extensions.Http.AspNetCore.Tests.csproj +++ b/test/extensions/Worker.Extensions.Http.AspNetCore.Tests/Worker.Extensions.Http.AspNetCore.Tests.csproj @@ -1,4 +1,4 @@ - + net7.0 @@ -29,4 +29,4 @@ - + \ No newline at end of file