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();
+ }
+ }
+}