diff --git a/Directory.Packages.props b/Directory.Packages.props index e249a871f..873a5fc7d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/all.sln b/all.sln index 9a163b1d9..a0edc7240 100644 --- a/all.sln +++ b/all.sln @@ -155,6 +155,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobsSample", "examples\Jobs EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Test", "test\Dapr.Workflow.Test\Dapr.Workflow.Test.csproj", "{E90114C6-86FC-43B8-AE5C-D9273CF21FE4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Jobs.Analyzers", "src\Dapr.Jobs.Analyzer\Dapr.Jobs.Analyzers.csproj", "{28B87C37-4B52-400F-B84D-64F134931BDC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Jobs.Analyzer.Tests", "test\Dapr.Jobs.Analyzer.Test\Dapr.Jobs.Analyzer.Tests.csproj", "{CADEAE45-8981-4723-B641-9C28251C7D3B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -403,6 +407,14 @@ Global {E90114C6-86FC-43B8-AE5C-D9273CF21FE4}.Debug|Any CPU.Build.0 = Debug|Any CPU {E90114C6-86FC-43B8-AE5C-D9273CF21FE4}.Release|Any CPU.ActiveCfg = Release|Any CPU {E90114C6-86FC-43B8-AE5C-D9273CF21FE4}.Release|Any CPU.Build.0 = Release|Any CPU + {28B87C37-4B52-400F-B84D-64F134931BDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28B87C37-4B52-400F-B84D-64F134931BDC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28B87C37-4B52-400F-B84D-64F134931BDC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28B87C37-4B52-400F-B84D-64F134931BDC}.Release|Any CPU.Build.0 = Release|Any CPU + {CADEAE45-8981-4723-B641-9C28251C7D3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CADEAE45-8981-4723-B641-9C28251C7D3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CADEAE45-8981-4723-B641-9C28251C7D3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CADEAE45-8981-4723-B641-9C28251C7D3B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -477,6 +489,8 @@ Global {D9697361-232F-465D-A136-4561E0E88488} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {9CAF360E-5AD3-4C4F-89A0-327EEB70D673} = {D9697361-232F-465D-A136-4561E0E88488} {E90114C6-86FC-43B8-AE5C-D9273CF21FE4} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {28B87C37-4B52-400F-B84D-64F134931BDC} = {27C5D71D-0721-4221-9286-B94AB07B58CF} + {CADEAE45-8981-4723-B641-9C28251C7D3B} = {DD020B34-460F-455F-8D17-CF4A949F100B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/examples/Jobs/JobsSample/Program.cs b/examples/Jobs/JobsSample/Program.cs index f19e375b3..ed1e71528 100644 --- a/examples/Jobs/JobsSample/Program.cs +++ b/examples/Jobs/JobsSample/Program.cs @@ -1,4 +1,4 @@ -// ------------------------------------------------------------------------ +// ------------------------------------------------------------------------ // Copyright 2024 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/src/Dapr.Jobs.Analyzer/AnalyzerReleases.Shipped.md b/src/Dapr.Jobs.Analyzer/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..5fab0bb6f --- /dev/null +++ b/src/Dapr.Jobs.Analyzer/AnalyzerReleases.Shipped.md @@ -0,0 +1,7 @@ +## Release 1.16 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +DAPR3001| Usage | Warning | Job invocations require the MapDaprScheduledJobHandler be set and configured for each anticipated job on IEndpointRouteBuilder \ No newline at end of file diff --git a/src/Dapr.Jobs.Analyzer/AnalyzerReleases.Unshipped.md b/src/Dapr.Jobs.Analyzer/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..b1b99aaf2 --- /dev/null +++ b/src/Dapr.Jobs.Analyzer/AnalyzerReleases.Unshipped.md @@ -0,0 +1,3 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/src/Dapr.Jobs.Analyzer/Dapr.Jobs.Analyzers.csproj b/src/Dapr.Jobs.Analyzer/Dapr.Jobs.Analyzers.csproj new file mode 100644 index 000000000..78e89d0a1 --- /dev/null +++ b/src/Dapr.Jobs.Analyzer/Dapr.Jobs.Analyzers.csproj @@ -0,0 +1,27 @@ + + + + netstandard2.0 + + false + enable + enable + true + + + + + + + + + true + + + false + + + This package contains Roslyn analyzers for jobs. + $(PackageTags) + + diff --git a/src/Dapr.Jobs.Analyzer/MapDaprScheduledJobHandlerAnalyzer.cs b/src/Dapr.Jobs.Analyzer/MapDaprScheduledJobHandlerAnalyzer.cs new file mode 100644 index 000000000..498b78034 --- /dev/null +++ b/src/Dapr.Jobs.Analyzer/MapDaprScheduledJobHandlerAnalyzer.cs @@ -0,0 +1,115 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Jobs.Analyzer +{ + /// + /// DaprJobsAnalyzer. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class MapDaprScheduledJobHandlerAnalyzer : DiagnosticAnalyzer + { + private static readonly DiagnosticDescriptor DaprJobHandlerRule = new DiagnosticDescriptor( + id: "DAPR3001", + title: "Ensure Post Mapper handler is present for all the Scheduled Jobs", + messageFormat: "Job invocations require the MapDaprScheduledJobHandler be set and configured for each anticipated job on IEndpointRouteBuilder", + category: "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly string DaprJobsNameSpace = "Dapr.Jobs"; + private static readonly string DaprJobScheduleJobAsyncMethod = "ScheduleJobAsync"; + private static readonly string MethodNameSpace = "Dapr.Jobs.Extensions"; + private static readonly string MapDaprScheduledJobHandlerMethod = "MapDaprScheduledJobHandler"; + + /// + public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(DaprJobHandlerRule); } } + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeJobSchedulerHandler, SyntaxKind.InvocationExpression); + } + + private static void AnalyzeJobSchedulerHandler(SyntaxNodeAnalysisContext context) + { + var invocationExpression = (InvocationExpressionSyntax)context.Node; + + if (invocationExpression.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return; + } + + if (IsNamespaceAndMethodNameEqual(context, invocationExpression, DaprJobsNameSpace, DaprJobScheduleJobAsyncMethod)) + { + var arguments = invocationExpression.ArgumentList.Arguments; + if (arguments.Count > 0 && arguments[0].Expression is LiteralExpressionSyntax literal) + { + string jobName = literal.Token.ValueText; + + // Now, we will check for a corresponding endpoint route. + CheckForEndpointRoute(context, jobName); + } + } + } + + private static void CheckForEndpointRoute(SyntaxNodeAnalysisContext context, string jobName) + { + var root = context.SemanticModel.SyntaxTree.GetRoot(); + + // Search for MapPost with the corresponding route + var mapDaprScheduledJobHandlersCount = root.DescendantNodes() + .OfType() + .Where(invocation => IsNamespaceAndMethodNameEqual(context, invocation, MethodNameSpace, MapDaprScheduledJobHandlerMethod)) + .Count(); + + if (mapDaprScheduledJobHandlersCount > 0) + { + return; + } + + // If no matching route was found, report a diagnostic + var diagnostic = Diagnostic.Create(DaprJobHandlerRule, default, jobName); + context.ReportDiagnostic(diagnostic); + } + + /// + /// Determines whether a given method invocation matches a specified method name + /// within a given namespace. + /// + /// For eg: MapDaprScheduledJobHandler (methodname) from the "Dapr.Jobs.Extension" (symbolNamespace) is being called. + /// + /// The syntax analysis context providing semantic information. + /// The invocation expression to analyze. + /// The expected namespace of the method. + /// The expected method name. + /// + /// true if the method belongs to the specified namespace and has the expected name; + /// otherwise, false. + /// + private static bool IsNamespaceAndMethodNameEqual(SyntaxNodeAnalysisContext context, InvocationExpressionSyntax invocation, string symbolNamespace, string methodName) + { + var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation.Expression); + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + // Check if the receiver is of type DaprJobsClient + if (methodSymbol?.Name == methodName && + methodSymbol.ContainingNamespace.ToDisplayString() == symbolNamespace) + { + return true; + } + + return false; + } + } +} diff --git a/test/Dapr.Jobs.Analyzer.Test/Dapr.Jobs.Analyzer.Tests.csproj b/test/Dapr.Jobs.Analyzer.Test/Dapr.Jobs.Analyzer.Tests.csproj new file mode 100644 index 000000000..57ca6b87a --- /dev/null +++ b/test/Dapr.Jobs.Analyzer.Test/Dapr.Jobs.Analyzer.Tests.csproj @@ -0,0 +1,35 @@ + + + + enable + enable + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/test/Dapr.Jobs.Analyzer.Test/MapDaprScheduledJobHandlerTest.cs b/test/Dapr.Jobs.Analyzer.Test/MapDaprScheduledJobHandlerTest.cs new file mode 100644 index 000000000..4db2198ba --- /dev/null +++ b/test/Dapr.Jobs.Analyzer.Test/MapDaprScheduledJobHandlerTest.cs @@ -0,0 +1,260 @@ +namespace Dapr.Jobs.Analyzer.Tests +{ + using Microsoft.CodeAnalysis.Testing; + using Microsoft.CodeAnalysis; + using Microsoft.AspNetCore.Builder; + using Dapr.Jobs.Models; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using Microsoft.CodeAnalysis.CSharp.Testing; + + public class DaprJobsAnalyzerAnalyzerTests + { + +#if NET8_0 + private static readonly ReferenceAssemblies referenceAssemblies = ReferenceAssemblies.Net.Net80; +#elif NET9_0 + private static readonly ReferenceAssemblies referenceAssemblies = ReferenceAssemblies.Net.Net90; +#endif + + [Fact] + public async Task AnalyzeJobSchedulerHandler_ShouldRaiseDiagnostic_WhenJobHasNoEndpointMapping() + { + var testCode = @" + using System; + using System.Text; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.AspNetCore.Builder; + using Dapr.Jobs; + using Dapr.Jobs.Extensions; + using Dapr.Jobs.Models; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddDaprJobsClient(); + var app = builder.Build(); + using var scope = app.Services.CreateScope(); + + var daprJobsClient = scope.ServiceProvider.GetRequiredService(); + + daprJobsClient.ScheduleJobAsync(""myJob"", DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)), + Encoding.UTF8.GetBytes(""This is a test""), repeats: 10).GetAwaiter().GetResult(); + } + }"; + + var expectedDiagnostic = new DiagnosticResult("DAPR3001", DiagnosticSeverity.Warning); + + await VerifyAnalyzerAsync(testCode, expectedDiagnostic); + } + + [Fact] + public async Task AnalyzeJobSchedulerHandler_ShouldNotRaiseDiagnostic_WhenScheduleJobIsNotCalled() + { + var testCode = @" + using System; + using System.Text; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.AspNetCore.Builder; + using Dapr.Jobs; + using Dapr.Jobs.Extensions; + using Dapr.Jobs.Models; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddDaprJobsClient(); + var app = builder.Build(); + using var scope = app.Services.CreateScope(); + + var daprJobsClient = scope.ServiceProvider.GetRequiredService(); + } + }"; + + var expectedDiagnostic = new DiagnosticResult("DAPR3001", DiagnosticSeverity.Warning); + + await VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task AnalyzeJobSchedulerHandler_ShouldRaiseDiagnostic_ForEachInstanceOfScheduledJobsDontHaveMappings() + { + var testCode = @" + using System; + using System.Text; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.AspNetCore.Builder; + using Dapr.Jobs; + using Dapr.Jobs.Extensions; + using Dapr.Jobs.Models; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddDaprJobsClient(); + var app = builder.Build(); + using var scope = app.Services.CreateScope(); + + var daprJobsClient = scope.ServiceProvider.GetRequiredService(); + + daprJobsClient.ScheduleJobAsync(""myJob"", DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)), + Encoding.UTF8.GetBytes(""This is a test""), repeats: 10).GetAwaiter().GetResult(); + daprJobsClient.ScheduleJobAsync(""myJob2"", DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)), + Encoding.UTF8.GetBytes(""This is a test""), repeats: 10).GetAwaiter().GetResult(); + } + }"; + + var expectedDiagnostic = new DiagnosticResult("DAPR3001", DiagnosticSeverity.Warning); + + await VerifyAnalyzerAsync(testCode, expectedDiagnostic, expectedDiagnostic); + } + + + [Fact] + public async Task AnalyzeJobSchedulerHandler_ShouldNotRaiseDiagnostic_WhenJobHasEndpointMapping() + { + var testCode = @" + using System; + using System.Text; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.AspNetCore.Builder; + using Dapr.Jobs; + using Dapr.Jobs.Extensions; + using Dapr.Jobs.Models; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddDaprJobsClient(); + var app = builder.Build(); + using var scope = app.Services.CreateScope(); + + var daprJobsClient = scope.ServiceProvider.GetRequiredService(); + + daprJobsClient.ScheduleJobAsync(""myJob"", DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)), + Encoding.UTF8.GetBytes(""This is a test""), repeats: 10).GetAwaiter().GetResult(); + daprJobsClient.ScheduleJobAsync(""myJob2"", DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)), + Encoding.UTF8.GetBytes(""This is a test""), repeats: 10).GetAwaiter().GetResult(); + + app.MapDaprScheduledJobHandler(async (string jobName, ReadOnlyMemory jobPayload) => + { + return Task.CompletedTask; + }, TimeSpan.FromSeconds(5)); + } + }"; + + await VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task AnalyzeJobSchedulerHandler_ShouldNotRaiseDiagnostic_WhenJobHasEndpointMappingIrrespectiveOfNumberOfMethodCallsOnScheduleJob() + { + var testCode = @" + using System; + using System.Text; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.AspNetCore.Builder; + using Dapr.Jobs; + using Dapr.Jobs.Extensions; + using Dapr.Jobs.Models; + + public static class Program + { + public static async Task Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddDaprJobsClient(); + var app = builder.Build(); + using var scope = app.Services.CreateScope(); + + var daprJobsClient = scope.ServiceProvider.GetRequiredService(); + + daprJobsClient.ScheduleJobAsync(""myJob"", DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)), + Encoding.UTF8.GetBytes(""This is a test""), repeats: 10).GetAwaiter().GetResult(); + await daprJobsClient.ScheduleJobAsync(""myJob2"", DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)), + Encoding.UTF8.GetBytes(""This is a test""), repeats: 10); + + app.MapDaprScheduledJobHandler(async (string jobName, ReadOnlyMemory jobPayload) => + { + return Task.CompletedTask; + }, TimeSpan.FromSeconds(5)); + } + }"; + + await VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task AnalyzeJobSchedulerHandler_ShouldNotRaiseDiagnostic_WhenScheduleJobDoesNotBelongToDaprJobClient() + { + var testCode = @" + using System; + using System.Text; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.AspNetCore.Builder; + using Dapr.Jobs; + using Dapr.Jobs.Extensions; + using Dapr.Jobs.Models; + + public static class Program + { + public static async Task Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddDaprJobsClient(); + var app = builder.Build(); + using var scope = app.Services.CreateScope(); + + var daprJobsClient = scope.ServiceProvider.GetRequiredService(); + + await ScheduleJobAsync(""myJob""); + } + + public static Task ScheduleJobAsync(string jobNAme) + { + return Task.CompletedTask; + } + } + "; + + await VerifyAnalyzerAsync(testCode); + } + + private static async Task VerifyAnalyzerAsync(string testCode, params DiagnosticResult[] expectedDiagnostics) + { + var test = new CSharpAnalyzerTest + { + + TestCode = testCode, + }; + + test.TestState.ReferenceAssemblies = referenceAssemblies; + + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(WebApplication).Assembly.Location)); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(DaprJobsClient).Assembly.Location)); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(DaprJobSchedule).Assembly.Location)); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(EndpointRouteBuilderExtensions).Assembly.Location)); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(IApplicationBuilder).Assembly.Location)); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile( + typeof(Microsoft.Extensions.DependencyInjection.ServiceCollection).Assembly.Location)); + test.TestState.AdditionalReferences.Add(MetadataReference.CreateFromFile(typeof(IHost).Assembly.Location)); + + test.ExpectedDiagnostics.AddRange(expectedDiagnostics); + await test.RunAsync(); + } + } +}