diff --git a/Directory.Packages.props b/Directory.Packages.props
index 394243a4d..ed9c9381c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,6 +15,7 @@
+
diff --git a/all.sln b/all.sln
index 47e798512..d1e5a9f9f 100644
--- a/all.sln
+++ b/all.sln
@@ -264,6 +264,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.AI.Microsoft.Extension
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.DistributedLock.Test", "test\Dapr.DistributedLock.Test\Dapr.DistributedLock.Test.csproj", "{2B8E9CAD-F9A2-43B9-BB1C-619CF64476A0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Common.Generators", "src\Dapr.Common.Generators\Dapr.Common.Generators.csproj", "{DA1F9FE7-6041-4581-B9AD-685034869CE8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Common.Generators.Tests", "test\Dapr.Common.Generators.Tests\Dapr.Common.Generators.Tests.csproj", "{E015C5ED-F93F-4DB6-BA01-BC59B7859A60}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1558,6 +1562,30 @@ Global
{2B8E9CAD-F9A2-43B9-BB1C-619CF64476A0}.Release|x64.Build.0 = Release|Any CPU
{2B8E9CAD-F9A2-43B9-BB1C-619CF64476A0}.Release|x86.ActiveCfg = Release|Any CPU
{2B8E9CAD-F9A2-43B9-BB1C-619CF64476A0}.Release|x86.Build.0 = Release|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Debug|x64.Build.0 = Debug|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Debug|x86.Build.0 = Debug|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Release|x64.ActiveCfg = Release|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Release|x64.Build.0 = Release|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Release|x86.ActiveCfg = Release|Any CPU
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8}.Release|x86.Build.0 = Release|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Debug|x64.Build.0 = Debug|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Debug|x86.Build.0 = Debug|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Release|x64.ActiveCfg = Release|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Release|x64.Build.0 = Release|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Release|x86.ActiveCfg = Release|Any CPU
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1681,11 +1709,13 @@ Global
{01A20A89-53A1-4D5B-B563-89E157718474} = {8462B106-175A-423A-BA94-BE0D39D0BD8E}
{7B14879F-156B-417E-ACA3-0B5A69CC2F39} = {8462B106-175A-423A-BA94-BE0D39D0BD8E}
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {8462B106-175A-423A-BA94-BE0D39D0BD8E}
+ {8777EAD2-419B-4683-826A-82B7C1F4F69F} = {8462B106-175A-423A-BA94-BE0D39D0BD8E}
+ {C6948155-C70C-4C03-A733-0624402AEC97} = {8462B106-175A-423A-BA94-BE0D39D0BD8E}
{6C77A2C4-0A96-4B90-AFEA-16E86A906259} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9}
{86CBB08F-601A-4B0B-87BF-383D391A961C} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD}
{2B8E9CAD-F9A2-43B9-BB1C-619CF64476A0} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD}
- {C6948155-C70C-4C03-A733-0624402AEC97} = {8462B106-175A-423A-BA94-BE0D39D0BD8E}
- {8777EAD2-419B-4683-826A-82B7C1F4F69F} = {8462B106-175A-423A-BA94-BE0D39D0BD8E}
+ {DA1F9FE7-6041-4581-B9AD-685034869CE8} = {27C5D71D-0721-4221-9286-B94AB07B58CF}
+ {E015C5ED-F93F-4DB6-BA01-BC59B7859A60} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40}
diff --git a/src/Dapr.AI/Conversation/DaprConversationClientBuilder.cs b/src/Dapr.AI/Conversation/DaprConversationClientBuilder.cs
index a8a7983f1..17084b39e 100644
--- a/src/Dapr.AI/Conversation/DaprConversationClientBuilder.cs
+++ b/src/Dapr.AI/Conversation/DaprConversationClientBuilder.cs
@@ -37,6 +37,7 @@ public override DaprConversationClient Build()
{
var daprClientDependencies = BuildDaprClientDependencies(typeof(DaprConversationClient).Assembly);
var client = new Autogenerated.DaprClient(daprClientDependencies.channel);
- return new DaprConversationGrpcClient(client, daprClientDependencies.httpClient, daprClientDependencies.daprApiToken);
+ IDaprRuntimeCapabilities runtimeCapabilities = new DaprRuntimeCapabilities(daprClientDependencies.channel);
+ return new DaprConversationGrpcClient(client, daprClientDependencies.httpClient, runtimeCapabilities, daprClientDependencies.daprApiToken);
}
}
diff --git a/src/Dapr.AI/Conversation/DaprConversationGrpcClient.cs b/src/Dapr.AI/Conversation/DaprConversationGrpcClient.cs
index 7a9c2c37b..36573856f 100644
--- a/src/Dapr.AI/Conversation/DaprConversationGrpcClient.cs
+++ b/src/Dapr.AI/Conversation/DaprConversationGrpcClient.cs
@@ -22,14 +22,18 @@ namespace Dapr.AI.Conversation;
///
/// The Dapr client.
/// The HTTP client used by the client for calling the Dapr runtime.
+/// Used to determine which gRPC API variants the connected Dapr runtime supports.
/// An optional token required to send requests to the Dapr sidecar.
[Experimental("DAPR_CONVERSATION",
UrlFormat = "https://docs.dapr.io/developing-applications/building-blocks/conversation/conversation-overview/")]
internal sealed class DaprConversationGrpcClient(
Autogenerated.Dapr.DaprClient client,
HttpClient httpClient,
+ IDaprRuntimeCapabilities runtimeCapabilities,
string? daprApiToken = null) : DaprConversationClient(client, httpClient, daprApiToken: daprApiToken)
{
+ private readonly IDaprRuntimeCapabilities runtimeCapabilities = runtimeCapabilities;
+
///
public override async Task ConverseAsync(IReadOnlyList inputs,
ConversationOptions options, CancellationToken cancellationToken = default)
diff --git a/src/Dapr.Client/Dapr.Client.csproj b/src/Dapr.Client/Dapr.Client.csproj
index 7d74a7bb3..3c031d2cc 100644
--- a/src/Dapr.Client/Dapr.Client.csproj
+++ b/src/Dapr.Client/Dapr.Client.csproj
@@ -3,6 +3,9 @@
This package contains the reference assemblies for developing services using Dapr.
+
+ true
@@ -15,6 +18,10 @@
+
+
diff --git a/src/Dapr.Client/DaprClientBuilder.cs b/src/Dapr.Client/DaprClientBuilder.cs
index ad3fe6b0f..11db83674 100644
--- a/src/Dapr.Client/DaprClientBuilder.cs
+++ b/src/Dapr.Client/DaprClientBuilder.cs
@@ -16,6 +16,7 @@ namespace Dapr.Client;
using System;
using System.Net.Http;
using System.Text.Json;
+using Common;
using Grpc.Net.Client;
using Autogenerated = Autogen.Grpc.v1;
@@ -57,7 +58,16 @@ public DaprClientBuilder()
// property exposed for testing purposes
internal GrpcChannelOptions GrpcChannelOptions { get; private set; }
internal string DaprApiToken { get; private set; }
- internal TimeSpan Timeout { get; private set; }
+ internal TimeSpan Timeout { get; private set; }
+ // Allows tests to inject a pre-configured IDaprRuntimeCapabilities instead of
+ // creating DaprRuntimeCapabilities, which requires a live gRPC reflection endpoint.
+ internal IDaprRuntimeCapabilities RuntimeCapabilitiesOverride { get; private set; }
+
+ internal DaprClientBuilder UseRuntimeCapabilities(IDaprRuntimeCapabilities capabilities)
+ {
+ this.RuntimeCapabilitiesOverride = capabilities;
+ return this;
+ }
///
/// Overrides the HTTP endpoint used by for communicating with the Dapr runtime.
@@ -174,16 +184,16 @@ public DaprClient Build()
var channel = GrpcChannel.ForAddress(this.GrpcEndpoint, this.GrpcChannelOptions);
var client = new Autogenerated.Dapr.DaprClient(channel);
-
+ IDaprRuntimeCapabilities runtimeCapabilities = this.RuntimeCapabilitiesOverride ?? new DaprRuntimeCapabilities(channel);
var apiTokenHeader = DaprClient.GetDaprApiTokenHeader(this.DaprApiToken);
var httpClient = HttpClientFactory is object ? HttpClientFactory() : new HttpClient();
-
+
if (this.Timeout > TimeSpan.Zero)
{
httpClient.Timeout = this.Timeout;
}
- return new DaprClientGrpc(channel, client, httpClient, httpEndpoint, this.JsonSerializerOptions, apiTokenHeader);
+ return new DaprClientGrpc(channel, client, httpClient, httpEndpoint, this.JsonSerializerOptions, apiTokenHeader, runtimeCapabilities);
}
-}
\ No newline at end of file
+}
diff --git a/src/Dapr.Client/DaprClientGrpc.cs b/src/Dapr.Client/DaprClientGrpc.cs
index 95d1b3bdb..767774db7 100644
--- a/src/Dapr.Client/DaprClientGrpc.cs
+++ b/src/Dapr.Client/DaprClientGrpc.cs
@@ -13,6 +13,7 @@
namespace Dapr.Client;
+using Common;
using Crypto;
using System;
using System.Buffers;
@@ -48,6 +49,7 @@ internal class DaprClientGrpc : DaprClient
private readonly GrpcChannel channel;
private readonly Autogenerated.Dapr.DaprClient client;
private readonly KeyValuePair? apiTokenHeader;
+ private readonly IVersionAwareDaprClient versionAwareClient;
// property exposed for testing purposes
internal Autogenerated.Dapr.DaprClient Client => client;
@@ -60,7 +62,8 @@ internal DaprClientGrpc(
HttpClient httpClient,
Uri httpEndpoint,
JsonSerializerOptions jsonSerializerOptions,
- KeyValuePair? apiTokenHeader)
+ KeyValuePair? apiTokenHeader,
+ IDaprRuntimeCapabilities runtimeCapabilities)
{
this.channel = channel;
this.client = inner;
@@ -68,6 +71,7 @@ internal DaprClientGrpc(
this.httpEndpoint = httpEndpoint;
this.jsonSerializerOptions = jsonSerializerOptions;
this.apiTokenHeader = apiTokenHeader;
+ this.versionAwareClient = new VersionAwareDaprClient(inner, runtimeCapabilities);
this.httpClient.DefaultRequestHeaders.UserAgent.Add(UserAgent());
}
@@ -246,7 +250,7 @@ private async Task> MakeBulkPublishRequest(
try
{
- var response = await client.BulkPublishEventAsync(envelope, options);
+ var response = await versionAwareClient.BulkPublishEventAsync(envelope, options);
List> failedEntries = [];
diff --git a/src/Dapr.Common.Generators/Analysis/DaprClientAnalyzer.cs b/src/Dapr.Common.Generators/Analysis/DaprClientAnalyzer.cs
new file mode 100644
index 000000000..25d3007b2
--- /dev/null
+++ b/src/Dapr.Common.Generators/Analysis/DaprClientAnalyzer.cs
@@ -0,0 +1,329 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using System.Collections.Generic;
+using System.Linq;
+using Dapr.Common.Generators.Models;
+using Microsoft.CodeAnalysis;
+
+namespace Dapr.Common.Generators.Analysis;
+
+///
+/// Discovers gRPC method variants on the Dapr runtime client and classifies them
+/// into instances for code generation.
+///
+internal static class DaprClientAnalyzer
+{
+ private const string DaprServiceNamespace = "dapr.proto.runtime.v1.Dapr";
+
+ // The Dapr gRPC client is a nested class: Dapr.Client.Autogen.Grpc.v1.Dapr (static class) + DaprClient (nested).
+ private const string DaprOuterClassName = "Dapr.Client.Autogen.Grpc.v1.Dapr";
+ private const string DaprClientNestedName = "DaprClient";
+ private const string CallOptionsMetadataName = "Grpc.Core.CallOptions";
+ private const string AsyncUnaryCallMetadataName = "Grpc.Core.AsyncUnaryCall`1";
+
+ ///
+ /// Finds the Dapr.Client.Autogen.Grpc.v1.Dapr.DaprClient type in the compilation,
+ /// enumerates its unary async methods, groups them by base name, and classifies each group.
+ /// Returns null if the DaprClient type or required Grpc.Core types are not found.
+ ///
+ public static IReadOnlyList? AnalyzeCompilation(Compilation compilation)
+ {
+ // Locate DaprClient (nested type: Dapr.Client.Autogen.Grpc.v1.Dapr+DaprClient)
+ var daprClientType = FindDaprClientType(compilation);
+ if (daprClientType is null)
+ return null;
+
+ var callOptionsType = compilation.GetTypeByMetadataName(CallOptionsMetadataName);
+ if (callOptionsType is null)
+ return null;
+
+ var asyncUnaryCallType = compilation.GetTypeByMetadataName(AsyncUnaryCallMetadataName);
+ if (asyncUnaryCallType is null)
+ return null;
+
+ // Enumerate async unary methods with signature: (TRequest, CallOptions)
+ var variants = daprClientType.GetMembers()
+ .OfType()
+ .Where(m => IsAsyncUnaryWithCallOptions(m, callOptionsType, asyncUnaryCallType))
+ .Select(ParseVariant)
+ .Where(v => v is not null)
+ .Select(v => v!)
+ .ToList();
+
+ if (variants.Count == 0)
+ return null;
+
+ // Group by base name and sort by descending maturity
+ var grouped = variants
+ .GroupBy(v => v.BaseName)
+ .Select(g => BuildGroup(g.Key, g.ToList()))
+ .ToList();
+
+ return grouped;
+ }
+
+ private static INamedTypeSymbol? FindDaprClientType(Compilation compilation)
+ {
+ // Try metadata name with nested-type separator ("+")
+ var type = compilation.GetTypeByMetadataName($"{DaprOuterClassName}+{DaprClientNestedName}");
+ if (type is not null)
+ return type;
+
+ // Fallback: walk namespace tree manually
+ var parts = DaprOuterClassName.Split('.');
+ INamespaceOrTypeSymbol current = compilation.GlobalNamespace;
+ foreach (var part in parts)
+ {
+ var next = current.GetMembers(part).OfType().FirstOrDefault();
+ if (next is null)
+ return null;
+ current = next;
+ }
+ return (current as INamedTypeSymbol)?.GetTypeMembers(DaprClientNestedName).FirstOrDefault();
+ }
+
+ private static bool IsAsyncUnaryWithCallOptions(
+ IMethodSymbol method,
+ INamedTypeSymbol callOptionsType,
+ INamedTypeSymbol asyncUnaryCallType)
+ {
+ if (!method.Name.EndsWith("Async"))
+ return false;
+
+ // Note: obsolete methods ARE included. The [Obsolete] attribute on gRPC stubs
+ // marks the older alpha/beta variants that have been promoted to stable — those
+ // are precisely the fallback targets we need for older Dapr runtimes.
+
+ if (method.Parameters.Length != 2)
+ return false;
+
+ // Second parameter must be CallOptions
+ if (!SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, callOptionsType))
+ return false;
+
+ // Return type must be AsyncUnaryCall
+ if (method.ReturnType is not INamedTypeSymbol returnNamed)
+ return false;
+
+ return SymbolEqualityComparer.Default.Equals(
+ returnNamed.OriginalDefinition,
+ asyncUnaryCallType);
+ }
+
+ private static MethodVariant? ParseVariant(IMethodSymbol method)
+ {
+ // Strip "Async" suffix to get the gRPC method name
+ var grpcName = method.Name.Substring(0, method.Name.Length - "Async".Length);
+
+ // Parse base name and maturity suffix
+ var (baseName, suffix) = SplitMaturitySuffix(grpcName);
+ var (level, levelNumber) = ParseMaturityLevel(suffix);
+
+ // Extract request type from first parameter
+ if (method.Parameters[0].Type is not INamedTypeSymbol requestType)
+ return null;
+
+ // Extract response type from AsyncUnaryCall
+ if (method.ReturnType is not INamedTypeSymbol returnNamed)
+ return null;
+ if (returnNamed.TypeArguments.Length != 1)
+ return null;
+ if (returnNamed.TypeArguments[0] is not INamedTypeSymbol responseType)
+ return null;
+
+ return new MethodVariant
+ {
+ CSharpMethodName = method.Name,
+ BaseName = baseName,
+ Suffix = suffix,
+ Level = level,
+ LevelNumber = levelNumber,
+ GrpcMethodName = grpcName,
+ FullyQualifiedMethodName = $"{DaprServiceNamespace}/{grpcName}",
+ Symbol = method,
+ RequestType = requestType,
+ ResponseType = responseType
+ };
+ }
+
+ ///
+ /// Splits a gRPC method name (without "Async") into base name and maturity suffix.
+ /// E.g. "ScheduleJobAlpha1" → ("ScheduleJob", "Alpha1").
+ /// "ListJobs" → ("ListJobs", "").
+ /// "ConverseAlpha2" → ("Converse", "Alpha2").
+ ///
+ internal static (string baseName, string suffix) SplitMaturitySuffix(string grpcName)
+ {
+ // Try suffixes in descending specificity: RC, Beta, Alpha
+ string[] prefixes = ["RC", "Beta", "Alpha"];
+ foreach (var prefix in prefixes)
+ {
+ var idx = grpcName.LastIndexOf(prefix, System.StringComparison.Ordinal);
+ if (idx <= 0)
+ continue;
+
+ var after = grpcName.Substring(idx + prefix.Length);
+ if (after.Length == 0 || IsAllDigits(after))
+ return (grpcName.Substring(0, idx), grpcName.Substring(idx));
+ }
+
+ return (grpcName, string.Empty);
+ }
+
+ private static (MaturityLevel level, int number) ParseMaturityLevel(string suffix)
+ {
+ if (string.IsNullOrEmpty(suffix))
+ return (MaturityLevel.Stable, 0);
+
+ if (suffix.StartsWith("RC"))
+ {
+ int.TryParse(suffix.Substring(2), out var n);
+ return (MaturityLevel.ReleaseCandidate, n);
+ }
+
+ if (suffix.StartsWith("Beta"))
+ {
+ int.TryParse(suffix.Substring(4), out var n);
+ return (MaturityLevel.Beta, n);
+ }
+
+ if (suffix.StartsWith("Alpha"))
+ {
+ int.TryParse(suffix.Substring(5), out var n);
+ return (MaturityLevel.Alpha, n);
+ }
+
+ return (MaturityLevel.Stable, 0);
+ }
+
+ private static bool IsAllDigits(string s)
+ {
+ foreach (var c in s)
+ if (c is < '0' or > '9')
+ return false;
+ return true;
+ }
+
+ private static MethodGroup BuildGroup(string baseName, List variants)
+ {
+ // Sort: Stable first, then by level descending, then by level number descending
+ variants.Sort((a, b) =>
+ {
+ var levelCmp = b.Level.CompareTo(a.Level);
+ return levelCmp != 0 ? levelCmp : b.LevelNumber.CompareTo(a.LevelNumber);
+ });
+
+ var mostRecent = variants[0];
+ var fallbacks = variants.Skip(1).ToList();
+
+ var classification = fallbacks.Count == 0
+ ? MethodClassification.PassThrough
+ : DetermineCompatibility(mostRecent, fallbacks);
+
+ return new MethodGroup
+ {
+ BaseName = baseName,
+ MostRecent = mostRecent,
+ Fallbacks = fallbacks,
+ Classification = classification
+ };
+ }
+
+ ///
+ /// Returns AutoCompatible if every fallback variant's request AND response types can be
+ /// automatically field-mapped from the most-recent variant's types (either same type,
+ /// or all instance user-fields of the older type have a name-matching field in the newer type).
+ /// Returns SchemaDivergent if any mapping would be lossy.
+ ///
+ private static MethodClassification DetermineCompatibility(
+ MethodVariant mostRecent,
+ IReadOnlyList fallbacks) =>
+ fallbacks.Any(fallback => !AreFieldsCompatible(mostRecent.RequestType, fallback.RequestType) ||
+ !AreFieldsCompatible(mostRecent.ResponseType, fallback.ResponseType))
+ ? MethodClassification.SchemaDivergent
+ : MethodClassification.AutoCompatible;
+
+ ///
+ /// Returns true if all user-facing instance properties of
+ /// have a name-and-type-compatible counterpart in .
+ /// Identical types are trivially compatible.
+ ///
+ internal static bool AreFieldsCompatible(INamedTypeSymbol newerType, INamedTypeSymbol olderType)
+ {
+ if (SymbolEqualityComparer.Default.Equals(newerType, olderType))
+ return true;
+
+ var olderProps = GetUserInstanceProperties(olderType);
+ if (olderProps.Count == 0)
+ return true; // Empty old type maps trivially to any newer type
+
+ var newerByName = GetUserInstanceProperties(newerType)
+ .ToDictionary(p => NormalizePropertyName(p.Name), p => p);
+
+ foreach (var oldProp in olderProps)
+ {
+ if (!newerByName.TryGetValue(NormalizePropertyName(oldProp.Name), out var newProp))
+ return false;
+
+ // Check that the type names match recursively (including generic type arguments).
+ // This prevents RepeatedField and RepeatedField
+ // from being treated as compatible just because they share the container type name.
+ if (!TypeNamesCompatible(newProp.Type, oldProp.Type))
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Returns true if and have the
+ /// same simple name and all their generic type arguments recursively match by name.
+ /// This is an intentionally loose check — it compares names, not symbols — so that
+ /// structurally identical types from different assemblies are still treated as compatible.
+ ///
+ private static bool TypeNamesCompatible(ITypeSymbol newerType, ITypeSymbol olderType)
+ {
+ if (newerType.Name != olderType.Name)
+ return false;
+
+ if (newerType is not INamedTypeSymbol newerNamed || olderType is not INamedTypeSymbol olderNamed)
+ return true;
+
+ if (newerNamed.TypeArguments.Length != olderNamed.TypeArguments.Length)
+ return false;
+
+ return !newerNamed.TypeArguments.Where((t, i) => !TypeNamesCompatible(t, olderNamed.TypeArguments[i])).Any();
+
+ }
+
+ ///
+ /// Returns all public, non-static properties of a proto-generated type that
+ /// represent user-defined fields (excludes the static infrastructure "Parser"
+ /// and "Descriptor" properties).
+ ///
+ internal static IReadOnlyList GetUserInstanceProperties(INamedTypeSymbol type) =>
+ type.GetMembers()
+ .OfType()
+ .Where(p => !p.IsStatic)
+ .Where(p => p.DeclaredAccessibility == Accessibility.Public)
+ .Where(p => p.Name != "Parser" && p.Name != "Descriptor")
+ .ToList();
+
+ ///
+ /// Normalizes a property name for case/underscore-insensitive matching.
+ /// "NamePrefix" and "name_prefix" both normalize to "nameprefix".
+ ///
+ private static string NormalizePropertyName(string name)
+ => name.Replace("_", "").ToLowerInvariant();
+}
diff --git a/src/Dapr.Common.Generators/Dapr.Common.Generators.csproj b/src/Dapr.Common.Generators/Dapr.Common.Generators.csproj
new file mode 100644
index 000000000..999774bfa
--- /dev/null
+++ b/src/Dapr.Common.Generators/Dapr.Common.Generators.csproj
@@ -0,0 +1,31 @@
+
+
+
+
+ netstandard2.0
+ false
+ enable
+ latest
+
+ true
+ true
+
+ Dapr.Common.Generators
+ Source generator that produces version-aware Dapr gRPC client wrappers with automatic runtime version fallback.
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+
+
+
+
+
+
+
+
diff --git a/src/Dapr.Common.Generators/DaprVersionAwareGenerator.cs b/src/Dapr.Common.Generators/DaprVersionAwareGenerator.cs
new file mode 100644
index 000000000..23d322d24
--- /dev/null
+++ b/src/Dapr.Common.Generators/DaprVersionAwareGenerator.cs
@@ -0,0 +1,68 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using Dapr.Common.Generators.Analysis;
+using Dapr.Common.Generators.Emission;
+using Microsoft.CodeAnalysis;
+
+namespace Dapr.Common.Generators;
+
+///
+/// Roslyn incremental source generator that emits IVersionAwareDaprClient
+/// and the VersionAwareDaprClient partial class whenever the consuming
+/// project sets <IsDaprSdkProject>true</IsDaprSdkProject> in
+/// its MSBuild project file.
+///
+///
+/// The generator scans the compilation for the Dapr gRPC autogenerated client
+/// (Dapr.Client.Autogen.Grpc.v1.Dapr.DaprClient), groups its async unary
+/// methods by base name, classifies each group as PassThrough / AutoCompatible /
+/// SchemaDivergent, and emits a wrapper that automatically selects the
+/// highest-maturity method variant supported by the connected Dapr runtime.
+///
+[Generator]
+public sealed class DaprVersionAwareGenerator : IIncrementalGenerator
+{
+ private const string MsBuildPropertyKey = "build_property.IsDaprSdkProject";
+
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // Combine the MSBuild opt-in flag with the full compilation.
+ // Using Combine keeps both values in a single pipeline node so that
+ // Roslyn can cache and invalidate them independently.
+ var combined = context.AnalyzerConfigOptionsProvider
+ .Combine(context.CompilationProvider);
+
+ context.RegisterSourceOutput(combined, static (spc, pair) =>
+ {
+ var (optionsProvider, compilation) = pair;
+
+ // Only run for Dapr SDK projects that have opted in.
+ optionsProvider.GlobalOptions.TryGetValue(MsBuildPropertyKey, out var flagValue);
+ if (!string.Equals(flagValue, "true", System.StringComparison.OrdinalIgnoreCase))
+ return;
+
+ // Analyse the compilation to discover versioned DaprClient method groups.
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+ if (groups is null || groups.Count == 0)
+ return;
+
+ // Emit the interface and implementation class.
+ var (interfaceSource, classSource) = WrapperCodeEmitter.Emit(groups);
+
+ spc.AddSource($"{WrapperCodeEmitter.InterfaceName}.g.cs", interfaceSource);
+ spc.AddSource($"{WrapperCodeEmitter.ClassName}.g.cs", classSource);
+ });
+ }
+}
diff --git a/src/Dapr.Common.Generators/Emission/WrapperCodeEmitter.cs b/src/Dapr.Common.Generators/Emission/WrapperCodeEmitter.cs
new file mode 100644
index 000000000..88b8bb210
--- /dev/null
+++ b/src/Dapr.Common.Generators/Emission/WrapperCodeEmitter.cs
@@ -0,0 +1,398 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Dapr.Common.Generators.Analysis;
+using Dapr.Common.Generators.Models;
+using Microsoft.CodeAnalysis;
+
+namespace Dapr.Common.Generators.Emission;
+
+///
+/// Emits the C# source code for IVersionAwareDaprClient and
+/// VersionAwareDaprClient from the analysed list.
+///
+internal static class WrapperCodeEmitter
+{
+ internal const string InterfaceName = "IVersionAwareDaprClient";
+ internal const string ClassName = "VersionAwareDaprClient";
+ private const string TargetNamespace = "Dapr.Common";
+
+ private const string InnerFieldName = "_inner";
+ private const string CapabilitiesFieldName = "_capabilities";
+
+ ///
+ /// Emits the interface and partial class source files.
+ ///
+ ///
+ /// A tuple of (interfaceSource, classSource) ready for SourceProductionContext.AddSource.
+ ///
+ public static (string interfaceSource, string classSource) Emit(IReadOnlyList groups)
+ => (EmitInterface(groups), EmitClass(groups));
+
+ // -------------------------------------------------------------------------
+ // Interface
+ // -------------------------------------------------------------------------
+
+ internal static string EmitInterface(IReadOnlyList groups)
+ {
+ var sb = new StringBuilder();
+ AppendFileHeader(sb);
+ sb.AppendLine($"namespace {TargetNamespace};");
+ sb.AppendLine();
+ sb.AppendLine("/// ");
+ sb.AppendLine("/// Exposes the most-recent Dapr gRPC API variants with automatic runtime-version");
+ sb.AppendLine("/// fallback. Implemented by the generated .");
+ sb.AppendLine("/// ");
+ sb.AppendLine($"internal interface {InterfaceName}");
+ sb.AppendLine("{");
+
+ foreach (var group in groups)
+ {
+ var (reqFqn, respFqn) = GetRequestResponseFqns(group.MostRecent);
+ sb.Append($" global::System.Threading.Tasks.Task<{respFqn}> {group.BaseName}Async(");
+ sb.Append($"{reqFqn} request, ");
+ sb.AppendLine("global::Grpc.Core.CallOptions options);");
+ sb.AppendLine();
+ }
+
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ // -------------------------------------------------------------------------
+ // Implementation class
+ // -------------------------------------------------------------------------
+
+ internal static string EmitClass(IReadOnlyList groups)
+ {
+ var sb = new StringBuilder();
+ AppendFileHeader(sb);
+ sb.AppendLine($"namespace {TargetNamespace};");
+ sb.AppendLine();
+ sb.AppendLine("/// ");
+ sb.AppendLine("/// Version-aware gRPC wrapper that automatically selects the highest-supported");
+ sb.AppendLine("/// Dapr runtime API variant for each operation and falls back to older variants");
+ sb.AppendLine("/// when the connected runtime does not yet support the newest API.");
+ sb.AppendLine("/// ");
+ sb.AppendLine($"internal sealed partial class {ClassName} : {InterfaceName}");
+ sb.AppendLine("{");
+
+ // Fields
+ sb.AppendLine($" private readonly global::Dapr.Client.Autogen.Grpc.v1.Dapr.DaprClient {InnerFieldName};");
+ sb.AppendLine($" private readonly global::Dapr.Common.IDaprRuntimeCapabilities {CapabilitiesFieldName};");
+ sb.AppendLine();
+
+ // Constructor
+ sb.AppendLine($" public {ClassName}(");
+ sb.AppendLine(" global::Dapr.Client.Autogen.Grpc.v1.Dapr.DaprClient inner,");
+ sb.AppendLine(" global::Dapr.Common.IDaprRuntimeCapabilities capabilities)");
+ sb.AppendLine(" {");
+ sb.AppendLine($" {InnerFieldName} = inner;");
+ sb.AppendLine($" {CapabilitiesFieldName} = capabilities;");
+ sb.AppendLine(" }");
+ sb.AppendLine();
+
+ // Methods
+ foreach (var group in groups)
+ {
+ EmitMethod(sb, group);
+ }
+
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ // -------------------------------------------------------------------------
+ // Method emission
+ // -------------------------------------------------------------------------
+
+ private static void EmitMethod(StringBuilder sb, MethodGroup group)
+ {
+ switch (group.Classification)
+ {
+ case MethodClassification.PassThrough:
+ EmitPassThroughMethod(sb, group);
+ break;
+ case MethodClassification.AutoCompatible:
+ EmitAutoCompatibleMethod(sb, group);
+ break;
+ case MethodClassification.SchemaDivergent:
+ EmitSchemaDivergentMethod(sb, group);
+ break;
+ }
+ }
+
+ ///
+ /// PassThrough: single variant, direct delegation via expression body.
+ ///
+ private static void EmitPassThroughMethod(StringBuilder sb, MethodGroup group)
+ {
+ var (reqFqn, respFqn) = GetRequestResponseFqns(group.MostRecent);
+ var m = group.MostRecent;
+
+ sb.AppendLine($" /// ");
+ sb.Append($" public global::System.Threading.Tasks.Task<{respFqn}> {group.BaseName}Async(");
+ sb.AppendLine($"{reqFqn} request, global::Grpc.Core.CallOptions options)");
+ sb.AppendLine($" => {InnerFieldName}.{m.CSharpMethodName}(request, options).ResponseAsync;");
+ sb.AppendLine();
+ }
+
+ ///
+ /// AutoCompatible: async capability-check chain with field-mapping for fallback variants.
+ ///
+ private static void EmitAutoCompatibleMethod(StringBuilder sb, MethodGroup group)
+ {
+ var mostRecent = group.MostRecent;
+ var (reqFqn, respFqn) = GetRequestResponseFqns(mostRecent);
+
+ sb.AppendLine($" /// ");
+ sb.Append($" public async global::System.Threading.Tasks.Task<{respFqn}> {group.BaseName}Async(");
+ sb.AppendLine($"{reqFqn} request, global::Grpc.Core.CallOptions options)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" var __ct = options.CancellationToken;");
+ sb.AppendLine();
+
+ // Most-recent variant: check capability then call, catching Unimplemented so that a method
+ // defined in the runtime's proto but not yet backed by a handler falls through to the older variant.
+ // Also catch Unknown with the Dapr proxy-routing error that older runtimes emit when they cannot
+ // match the method internally and attempt (and fail) to forward it as a service invocation.
+ sb.AppendLine($" if (await {CapabilitiesFieldName}.SupportsMethodAsync(\"{mostRecent.FullyQualifiedMethodName}\", __ct).ConfigureAwait(false))");
+ sb.AppendLine(" {");
+ sb.AppendLine(" try");
+ sb.AppendLine(" {");
+ sb.AppendLine($" return await {InnerFieldName}.{mostRecent.CSharpMethodName}(request, options).ResponseAsync.ConfigureAwait(false);");
+ sb.AppendLine(" }");
+ sb.AppendLine(" catch (global::Grpc.Core.RpcException __implEx) when (");
+ sb.AppendLine(" __implEx.StatusCode == global::Grpc.Core.StatusCode.Unimplemented ||");
+ sb.AppendLine(" (__implEx.StatusCode == global::Grpc.Core.StatusCode.Unknown &&");
+ sb.AppendLine(" __implEx.Status.Detail.Contains(\"dapr-callee-app-id or dapr-app-id not found\")))");
+ sb.AppendLine(" {");
+ sb.AppendLine(" // Method is in the runtime proto but not yet implemented, or the runtime");
+ sb.AppendLine(" // does not recognise the method and attempted to proxy it as a service invocation.");
+ sb.AppendLine(" // Either way, fall through to the older variant.");
+ sb.AppendLine(" }");
+ sb.AppendLine(" }");
+ sb.AppendLine();
+
+ // Fallback variants
+ foreach (var fallback in group.Fallbacks)
+ {
+ var sameRequest = SymbolEqualityComparer.Default.Equals(mostRecent.RequestType, fallback.RequestType);
+ var sameResponse = SymbolEqualityComparer.Default.Equals(mostRecent.ResponseType, fallback.ResponseType);
+
+ sb.AppendLine($" if (await {CapabilitiesFieldName}.SupportsMethodAsync(\"{fallback.FullyQualifiedMethodName}\", __ct).ConfigureAwait(false))");
+
+ if (sameRequest && sameResponse)
+ {
+ // No type conversion needed — compact one-liner
+ sb.AppendLine($" return await {InnerFieldName}.{fallback.CSharpMethodName}(request, options).ResponseAsync.ConfigureAwait(false);");
+ }
+ else
+ {
+ sb.AppendLine(" {");
+ EmitRequestConversion(sb, mostRecent, fallback, " ");
+ sb.AppendLine($" var __fallbackResponse = await {InnerFieldName}.{fallback.CSharpMethodName}(__fallbackRequest, options).ResponseAsync.ConfigureAwait(false);");
+ EmitResponseConversion(sb, mostRecent, fallback, " ");
+ sb.AppendLine(" }");
+ }
+
+ sb.AppendLine();
+ }
+
+ // Nothing supported → exception
+ EmitFeatureNotAvailableThrow(sb, group, " ");
+ sb.AppendLine(" }");
+ sb.AppendLine();
+ }
+
+ ///
+ /// SchemaDivergent: async capability-check for most-recent; older fallbacks throw
+ /// because the schemas are incompatible.
+ /// The class is partial so SDK maintainers can override individual methods.
+ ///
+ private static void EmitSchemaDivergentMethod(StringBuilder sb, MethodGroup group)
+ {
+ var mostRecent = group.MostRecent;
+ var (reqFqn, respFqn) = GetRequestResponseFqns(mostRecent);
+
+ sb.AppendLine($" /// ");
+ sb.Append($" public async global::System.Threading.Tasks.Task<{respFqn}> {group.BaseName}Async(");
+ sb.AppendLine($"{reqFqn} request, global::Grpc.Core.CallOptions options)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" var __ct = options.CancellationToken;");
+ sb.AppendLine();
+
+ // Most-recent variant: catch Unimplemented so a proto-defined-but-not-yet-handled method
+ // falls through to the schema-divergent NotSupportedException path rather than surfacing a raw RpcException.
+ // Also catch Unknown with the Dapr proxy-routing error that older runtimes emit when they cannot
+ // match the method internally and attempt (and fail) to forward it as a service invocation.
+ sb.AppendLine($" if (await {CapabilitiesFieldName}.SupportsMethodAsync(\"{mostRecent.FullyQualifiedMethodName}\", __ct).ConfigureAwait(false))");
+ sb.AppendLine(" {");
+ sb.AppendLine(" try");
+ sb.AppendLine(" {");
+ sb.AppendLine($" return await {InnerFieldName}.{mostRecent.CSharpMethodName}(request, options).ResponseAsync.ConfigureAwait(false);");
+ sb.AppendLine(" }");
+ sb.AppendLine(" catch (global::Grpc.Core.RpcException __implEx) when (");
+ sb.AppendLine(" __implEx.StatusCode == global::Grpc.Core.StatusCode.Unimplemented ||");
+ sb.AppendLine(" (__implEx.StatusCode == global::Grpc.Core.StatusCode.Unknown &&");
+ sb.AppendLine(" __implEx.Status.Detail.Contains(\"dapr-callee-app-id or dapr-app-id not found\")))");
+ sb.AppendLine(" {");
+ sb.AppendLine(" // Method is in the runtime proto but not yet implemented, or the runtime");
+ sb.AppendLine(" // does not recognise the method and attempted to proxy it as a service invocation.");
+ sb.AppendLine(" // Either way, fall through to the older variant.");
+ sb.AppendLine(" }");
+ sb.AppendLine(" }");
+ sb.AppendLine();
+
+ // Older, incompatible variants → NotSupportedException
+ foreach (var fallback in group.Fallbacks)
+ {
+ sb.AppendLine($" if (await {CapabilitiesFieldName}.SupportsMethodAsync(\"{fallback.FullyQualifiedMethodName}\", __ct).ConfigureAwait(false))");
+ sb.AppendLine(" throw new global::System.NotSupportedException(");
+ sb.AppendLine($" \"The '{group.BaseName}' operation cannot automatically fall back from '{mostRecent.GrpcMethodName}' to '{fallback.GrpcMethodName}' \" +");
+ sb.AppendLine($" \"because the schemas are incompatible. Provide a partial-class override of {ClassName} to handle this older runtime version.\");");
+ sb.AppendLine();
+ }
+
+ EmitFeatureNotAvailableThrow(sb, group, " ");
+ sb.AppendLine(" }");
+ sb.AppendLine();
+ }
+
+ // -------------------------------------------------------------------------
+ // Type-mapping helpers
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Emits code that converts 's request type to
+ /// 's request type, assigning the result to a local
+ /// variable named __fallbackRequest.
+ ///
+ private static void EmitRequestConversion(
+ StringBuilder sb,
+ MethodVariant mostRecent,
+ MethodVariant fallback,
+ string indent)
+ {
+ if (SymbolEqualityComparer.Default.Equals(mostRecent.RequestType, fallback.RequestType))
+ {
+ sb.AppendLine($"{indent}var __fallbackRequest = request;");
+ return;
+ }
+
+ var fallbackReqFqn = fallback.RequestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ sb.AppendLine($"{indent}var __fallbackRequest = new {fallbackReqFqn}();");
+
+ // Copy every compatible property from the new request into the old one.
+ // We iterate the *fallback* type's properties so we only touch fields that exist there.
+ foreach (var prop in DaprClientAnalyzer.GetUserInstanceProperties(fallback.RequestType))
+ {
+ EmitPropertyCopy(sb, prop, "request", "__fallbackRequest", indent);
+ }
+ }
+
+ ///
+ /// Emits code that converts 's response into
+ /// 's response type, ending with a return
+ /// statement.
+ ///
+ private static void EmitResponseConversion(
+ StringBuilder sb,
+ MethodVariant mostRecent,
+ MethodVariant fallback,
+ string indent)
+ {
+ if (SymbolEqualityComparer.Default.Equals(mostRecent.ResponseType, fallback.ResponseType))
+ {
+ sb.AppendLine($"{indent}return __fallbackResponse;");
+ return;
+ }
+
+ var newerRespFqn = mostRecent.ResponseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ sb.AppendLine($"{indent}var __convertedResponse = new {newerRespFqn}();");
+
+ // Copy every compatible property from the old response into the new one.
+ // We iterate the *fallback* type's properties so we only copy fields that exist there;
+ // the newer type may have additional fields which remain at their defaults.
+ foreach (var prop in DaprClientAnalyzer.GetUserInstanceProperties(fallback.ResponseType))
+ {
+ EmitPropertyCopy(sb, prop, "__fallbackResponse", "__convertedResponse", indent);
+ }
+
+ sb.AppendLine($"{indent}return __convertedResponse;");
+ }
+
+ ///
+ /// Emits a single property copy statement from to
+ /// , choosing the correct idiom for repeated / map / scalar fields.
+ /// Read-only non-collection properties (e.g. proto HasXxx sentinels) are skipped.
+ ///
+ private static void EmitPropertyCopy(
+ StringBuilder sb,
+ IPropertySymbol prop,
+ string source,
+ string target,
+ string indent)
+ {
+ var typeName = prop.Type.Name;
+
+ if (typeName == "RepeatedField")
+ {
+ // RepeatedField.AddRange() is the idiomatic copy
+ sb.AppendLine($"{indent}{target}.{prop.Name}.AddRange({source}.{prop.Name});");
+ }
+ else if (typeName == "MapField")
+ {
+ // MapField: iterate and add each entry
+ sb.AppendLine($"{indent}foreach (var __kvp in {source}.{prop.Name})");
+ sb.AppendLine($"{indent} {target}.{prop.Name}[__kvp.Key] = __kvp.Value;");
+ }
+ else if (!prop.IsReadOnly)
+ {
+ // Writable scalar / message field
+ sb.AppendLine($"{indent}{target}.{prop.Name} = {source}.{prop.Name};");
+ }
+ // else: read-only non-collection (e.g. proto HasXxx bool) — skip
+ }
+
+ // -------------------------------------------------------------------------
+ // Shared helpers
+ // -------------------------------------------------------------------------
+
+ private static void EmitFeatureNotAvailableThrow(StringBuilder sb, MethodGroup group, string indent)
+ {
+ var allVariants = new[] { group.MostRecent }.Concat(group.Fallbacks);
+ var variantList = string.Join(", ", allVariants.Select(v => $"\"{v.FullyQualifiedMethodName}\""));
+ sb.AppendLine($"{indent}throw new global::Dapr.Common.DaprFeatureNotAvailableException(\"{group.BaseName}\", new string[] {{ {variantList} }});");
+ }
+
+ private static (string reqFqn, string respFqn) GetRequestResponseFqns(MethodVariant variant) =>
+ (
+ variant.RequestType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
+ variant.ResponseType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ );
+
+ private static void AppendFileHeader(StringBuilder sb)
+ {
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine("// The generated fallback chain intentionally calls [Obsolete]-tagged alpha/beta gRPC");
+ sb.AppendLine("// stub methods so that older Dapr runtimes are still supported.");
+ sb.AppendLine("// CS0612: [Obsolete] with no message. CS0618: [Obsolete(\"message\")].");
+ sb.AppendLine("#pragma warning disable CS0612, CS0618");
+ sb.AppendLine();
+ }
+}
diff --git a/src/Dapr.Common.Generators/Models/MaturityLevel.cs b/src/Dapr.Common.Generators/Models/MaturityLevel.cs
new file mode 100644
index 000000000..f96f74594
--- /dev/null
+++ b/src/Dapr.Common.Generators/Models/MaturityLevel.cs
@@ -0,0 +1,12 @@
+namespace Dapr.Common.Generators.Models;
+
+///
+/// Maturity level of a gRPC method variant.
+///
+internal enum MaturityLevel
+{
+ Alpha = 1,
+ Beta = 2,
+ ReleaseCandidate = 3,
+ Stable = 4
+}
diff --git a/src/Dapr.Common.Generators/Models/MethodClassification.cs b/src/Dapr.Common.Generators/Models/MethodClassification.cs
new file mode 100644
index 000000000..07f7ca556
--- /dev/null
+++ b/src/Dapr.Common.Generators/Models/MethodClassification.cs
@@ -0,0 +1,26 @@
+namespace Dapr.Common.Generators.Models;
+
+///
+/// How the generator handles fallback for a method group.
+///
+internal enum MethodClassification
+{
+ ///
+ /// Only one variant exists; the generated wrapper simply forwards the call.
+ ///
+ PassThrough,
+
+ ///
+ /// Multiple variants exist and request/response types are field-compatible, so the
+ /// generator can auto-produce the fallback chain with field mapping.
+ ///
+ AutoCompatible,
+
+ ///
+ /// Multiple variants exist but the schemas differ significantly enough that automatic
+ /// field mapping is not safe. The wrapper checks capability and throws
+ /// on the older path unless a partial-class
+ /// override is provided.
+ ///
+ SchemaDivergent
+}
diff --git a/src/Dapr.Common.Generators/Models/MethodGroup.cs b/src/Dapr.Common.Generators/Models/MethodGroup.cs
new file mode 100644
index 000000000..dc1263178
--- /dev/null
+++ b/src/Dapr.Common.Generators/Models/MethodGroup.cs
@@ -0,0 +1,41 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using System.Collections.Generic;
+
+namespace Dapr.Common.Generators.Models;
+
+///
+/// A group of gRPC method variants that share a base name, ordered from
+/// most-recent (highest maturity) to oldest.
+///
+internal sealed class MethodGroup
+{
+ /// Shared base name, e.g. "ScheduleJob" or "Converse".
+ public string BaseName { get; init; } = string.Empty;
+
+ ///
+ /// The highest-maturity variant: Stable if one exists, otherwise the
+ /// most-recent pre-stable variant. This is what the interface exposes.
+ ///
+ public MethodVariant MostRecent { get; init; } = null!;
+
+ ///
+ /// Variants to fall back to, in descending maturity order
+ /// (2nd-most-recent first, oldest last).
+ ///
+ public IReadOnlyList Fallbacks { get; init; } = [];
+
+ /// How the generator handles this group.
+ public MethodClassification Classification { get; init; }
+}
diff --git a/src/Dapr.Common.Generators/Models/MethodVariant.cs b/src/Dapr.Common.Generators/Models/MethodVariant.cs
new file mode 100644
index 000000000..276da2125
--- /dev/null
+++ b/src/Dapr.Common.Generators/Models/MethodVariant.cs
@@ -0,0 +1,53 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using Microsoft.CodeAnalysis;
+
+namespace Dapr.Common.Generators.Models;
+
+///
+/// Represents a single versioned variant of a Dapr gRPC method
+/// (e.g., ScheduleJobAlpha1Async or ScheduleJobAsync).
+///
+internal sealed class MethodVariant
+{
+ /// C# method name including the Async suffix, e.g. "ScheduleJobAlpha1Async".
+ public string CSharpMethodName { get; init; } = string.Empty;
+
+ /// Shared base name across all variants, e.g. "ScheduleJob".
+ public string BaseName { get; init; } = string.Empty;
+
+ /// Maturity suffix portion only, e.g. "Alpha1" or "" for stable.
+ public string Suffix { get; init; } = string.Empty;
+
+ /// Maturity tier (Stable, Beta, Alpha).
+ public MaturityLevel Level { get; init; }
+
+ /// Numeric discriminator within the tier (e.g. 1 for Alpha1, 2 for Alpha2).
+ public int LevelNumber { get; init; }
+
+ /// The gRPC method name on the service, e.g. "ScheduleJobAlpha1".
+ public string GrpcMethodName { get; init; } = string.Empty;
+
+ /// Fully-qualified gRPC method name, e.g. "dapr.proto.runtime.v1.Dapr/ScheduleJobAlpha1".
+ public string FullyQualifiedMethodName { get; init; } = string.Empty;
+
+ /// The Roslyn symbol for the method on DaprClient.
+ public IMethodSymbol Symbol { get; init; } = null!;
+
+ /// The request type for this variant.
+ public INamedTypeSymbol RequestType { get; init; } = null!;
+
+ /// The response type for this variant.
+ public INamedTypeSymbol ResponseType { get; init; } = null!;
+}
diff --git a/src/Dapr.Common.Generators/build/Dapr.Common.Generators.props b/src/Dapr.Common.Generators/build/Dapr.Common.Generators.props
new file mode 100644
index 000000000..14f3653b8
--- /dev/null
+++ b/src/Dapr.Common.Generators/build/Dapr.Common.Generators.props
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/src/Dapr.Common/AssemblyInfo.cs b/src/Dapr.Common/AssemblyInfo.cs
index 5875274af..ff3bebf40 100644
--- a/src/Dapr.Common/AssemblyInfo.cs
+++ b/src/Dapr.Common/AssemblyInfo.cs
@@ -49,3 +49,5 @@
[assembly: InternalsVisibleTo("Dapr.Extensions.Configuration.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")]
[assembly: InternalsVisibleTo("Dapr.Jobs.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")]
[assembly: InternalsVisibleTo("Dapr.Messaging.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")]
+// Allow Moq / Castle DynamicProxy to create proxies of internal types at test time.
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]
diff --git a/src/Dapr.Common/Dapr.Common.csproj b/src/Dapr.Common/Dapr.Common.csproj
index 03d23f9bd..242d9ab3b 100644
--- a/src/Dapr.Common/Dapr.Common.csproj
+++ b/src/Dapr.Common/Dapr.Common.csproj
@@ -7,11 +7,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Dapr.Common/DaprFeatureNotAvailableException.cs b/src/Dapr.Common/DaprFeatureNotAvailableException.cs
new file mode 100644
index 000000000..026b63bc2
--- /dev/null
+++ b/src/Dapr.Common/DaprFeatureNotAvailableException.cs
@@ -0,0 +1,47 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+namespace Dapr.Common;
+
+///
+/// Thrown when a Dapr feature is not available on the connected Dapr runtime in any known API version.
+/// This exception is distinct from general failures: it specifically indicates
+/// that gRPC reflection confirmed none of the checked method variants exist on the runtime, meaning the
+/// feature requires a newer Dapr runtime version.
+///
+/// The logical name of the feature (e.g., "ListJobs").
+///
+/// The fully-qualified gRPC method names that were checked and not found
+/// (e.g., "dapr.proto.runtime.v1.Dapr/ListJobs", "dapr.proto.runtime.v1.Dapr/ListJobsAlpha1").
+///
+public sealed class DaprFeatureNotAvailableException(string featureName, string[] checkedVariants) : DaprException(BuildMessage(featureName, checkedVariants))
+{
+ ///
+ /// The logical name of the unavailable feature.
+ ///
+ public string FeatureName { get; } = featureName;
+
+ ///
+ /// The fully-qualified gRPC method names that were checked against the runtime
+ /// and not found to be supported.
+ ///
+ public IReadOnlyList CheckedVariants { get; } = checkedVariants;
+
+ private static string BuildMessage(string featureName, string[] checkedVariants)
+ {
+ var variantList = string.Join(", ", checkedVariants);
+ return $"The '{featureName}' feature is not available on the connected Dapr runtime. " +
+ $"Checked for: {variantList}. " +
+ $"This feature may require a newer Dapr runtime version.";
+ }
+}
diff --git a/src/Dapr.Common/DaprRuntimeCapabilities.cs b/src/Dapr.Common/DaprRuntimeCapabilities.cs
new file mode 100644
index 000000000..74141e22f
--- /dev/null
+++ b/src/Dapr.Common/DaprRuntimeCapabilities.cs
@@ -0,0 +1,150 @@
+using Google.Protobuf.Reflection;
+using Grpc.Core;
+using Grpc.Net.Client;
+using Grpc.Reflection.V1;
+
+namespace Dapr.Common;
+
+///
+/// Used to determine Dapr runtime capability for fallback purposes by the SDKs.
+///
+/// The to validate with.
+internal sealed class DaprRuntimeCapabilities(GrpcChannel channel) : IDaprRuntimeCapabilities, IDisposable
+{
+ private readonly ServerReflection.ServerReflectionClient _reflectionClient = new(channel);
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private HashSet? _cachedServices = null;
+ private readonly Dictionary> _cachedMethodsByService = [];
+
+ public const string Namespace = "dapr.proto.runtime.v1.Dapr";
+
+ ///
+ public async Task SupportsMethodAsync(string fullyQualifiedMethodName, CancellationToken cancellationToken = default)
+ {
+ var slash = fullyQualifiedMethodName.LastIndexOf('/');
+ if (slash <= 0)
+ {
+ throw new ArgumentException("Expected the form 'package.Service/Method.", nameof(fullyQualifiedMethodName));
+ }
+
+ var service = fullyQualifiedMethodName[..slash];
+ var method = fullyQualifiedMethodName[(slash + 1)..];
+
+ try
+ {
+ var methods = await GetMethodsForServiceAsync(service, cancellationToken).ConfigureAwait(false);
+ return methods.Contains(method);
+ }
+ catch (RpcException)
+ {
+ // gRPC reflection is unavailable or returned an error for this service.
+ // Return true (optimistic) so the version-aware caller will attempt the method
+ // and handle StatusCode.Unimplemented as the runtime-version fallback signal.
+ return true;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ // The HTTP/2 stream underlying the reflection call was internally cancelled
+ // (e.g. connection reset, keep-alive expiry, or stream setup race on .NET 9+).
+ // This is NOT a caller cancellation — treat it the same as an unavailable
+ // reflection service and proceed optimistically.
+ return true;
+ }
+ }
+
+ ///
+ public async Task SupportsServiceAsync(string serviceName, CancellationToken cancellationToken = default)
+ {
+ var services = await GetServicesAsync(cancellationToken).ConfigureAwait(false);
+ return services.Contains(serviceName);
+ }
+
+ private async Task> GetServicesAsync(CancellationToken cancellationToken)
+ {
+ if (_cachedServices is not null)
+ return _cachedServices;
+
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (_cachedServices is not null)
+ return _cachedServices;
+
+ using var call = _reflectionClient.ServerReflectionInfo(cancellationToken: cancellationToken);
+ await call.RequestStream.WriteAsync(new ServerReflectionRequest { ListServices = "" }, cancellationToken)
+ .ConfigureAwait(false);
+ await call.RequestStream.CompleteAsync().ConfigureAwait(false);
+
+ var set = new HashSet(StringComparer.Ordinal);
+ await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (response.MessageResponseCase ==
+ ServerReflectionResponse.MessageResponseOneofCase.ListServicesResponse)
+ {
+ foreach (var s in response.ListServicesResponse.Service)
+ {
+ set.Add(s.Name);
+ }
+ }
+ }
+
+ _cachedServices = set;
+ return set;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async Task> GetMethodsForServiceAsync(string serviceName,
+ CancellationToken cancellationToken)
+ {
+ if (_cachedMethodsByService.TryGetValue(serviceName, out var existing))
+ return existing;
+
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (_cachedMethodsByService.TryGetValue(serviceName, out existing))
+ return existing;
+
+ using var call = _reflectionClient.ServerReflectionInfo(cancellationToken: cancellationToken);
+ await call.RequestStream.WriteAsync(new ServerReflectionRequest { FileContainingSymbol = serviceName }, cancellationToken)
+ .ConfigureAwait(false);
+ await call.RequestStream.CompleteAsync().ConfigureAwait(false);
+
+ var set = new HashSet(StringComparer.Ordinal);
+ await foreach (var response in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false))
+ {
+ if (response.MessageResponseCase !=
+ ServerReflectionResponse.MessageResponseOneofCase.FileDescriptorResponse)
+ continue;
+
+ foreach (var raw in response.FileDescriptorResponse.FileDescriptorProto)
+ {
+ var fd = FileDescriptorProto.Parser.ParseFrom(raw);
+ foreach (var svc in fd.Service)
+ {
+ var fqn = string.IsNullOrEmpty(fd.Package) ? svc.Name : $"{fd.Package}.{svc.Name}";
+ if (fqn != serviceName)
+ continue;
+ foreach (var m in svc.Method)
+ {
+ set.Add(m.Name);
+ }
+ }
+ }
+ }
+
+ _cachedMethodsByService[serviceName] = set;
+ return set;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public void Dispose() => _gate.Dispose();
+}
diff --git a/src/Dapr.Common/Extensions/DaprClientBuilderExtensions.cs b/src/Dapr.Common/Extensions/DaprClientBuilderExtensions.cs
index 1070133c2..656a9393e 100644
--- a/src/Dapr.Common/Extensions/DaprClientBuilderExtensions.cs
+++ b/src/Dapr.Common/Extensions/DaprClientBuilderExtensions.cs
@@ -44,7 +44,7 @@ internal static TServiceBuilder AddDaprClient
{
- ArgumentNullException.ThrowIfNull(services, nameof(services));
+ ArgumentNullException.ThrowIfNull(services);
//Ensure that TConcreteClient is a concrete class
if (typeof(TConcreteClient).IsInterface || typeof(TConcreteClient).IsAbstract)
@@ -61,22 +61,25 @@ internal static TServiceBuilder AddDaprClient(provider =>
+
+ // Register the TClient type for use by the SDKs
+ services.Add(new ServiceDescriptor(typeof(TClient), provider =>
{
var configuration = provider.GetService();
var builder = (TClientBuilder)Activator.CreateInstance(typeof(TClientBuilder), configuration)!;
- builder.UseDaprApiToken(DaprDefaults.GetDefaultDaprApiToken(configuration));
- configure?.Invoke(provider, builder);
- var (channel, httpClient, _, daprApiToken) =
- builder.BuildDaprClientDependencies(Assembly.GetExecutingAssembly());
- var daprClient = new Autogenerated.DaprClient(channel);
- return (TClient)Activator.CreateInstance(typeof(TConcreteClient), daprClient, httpClient, daprApiToken)!;
- });
+ builder.UseDaprApiToken(DaprDefaults.GetDefaultDaprApiToken(configuration));
+ configure?.Invoke(provider, builder);
+ var (channel, httpClient, _, daprApiToken) =
+ builder.BuildDaprClientDependencies(Assembly.GetExecutingAssembly());
+ var daprClient = new Autogenerated.DaprClient(channel);
+
+ // Dapr runtime capabilities
+ IDaprRuntimeCapabilities runtimeCapabilities = new DaprRuntimeCapabilities(channel);
+
+ return (TClient)Activator.CreateInstance(typeof(TConcreteClient), daprClient, httpClient, runtimeCapabilities, daprApiToken)!;
+ }, lifetime));
- services.Add(new ServiceDescriptor(typeof(TClient), registration, lifetime));
-
return (TServiceBuilder)Activator.CreateInstance(typeof(TServiceBuilder), services)!;
}
}
diff --git a/src/Dapr.Common/IDaprRuntimeCapabilities.cs b/src/Dapr.Common/IDaprRuntimeCapabilities.cs
new file mode 100644
index 000000000..dc5362eaf
--- /dev/null
+++ b/src/Dapr.Common/IDaprRuntimeCapabilities.cs
@@ -0,0 +1,38 @@
+// ------------------------------------------------------------------------
+// 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+namespace Dapr.Common;
+
+///
+/// Exposes information about which gRPC methods/types the connected Dapr runtime supports, using the
+/// standard gRPC Server Reflection protocol. This is intended to enable backwards compatibility fallbacks
+/// in the various Dapr SDKs.
+///
+internal interface IDaprRuntimeCapabilities
+{
+ ///
+ /// Validates whether the connected Dapr runtime supports the specified fully-qualified gRPC method.
+ ///
+ /// The name of the fully-qualified gRPC method.
+ /// Cancellation token.
+ /// Returns true if the runtime exposes the given fully-qualified gRPC method (e.g. dapr.proto.runtime.v1.Dapr/ScheduleJob
+ Task SupportsMethodAsync(string fullyQualifiedMethodName, CancellationToken cancellationToken = default);
+
+ ///
+ /// Validates whether the connected Dapr runtime supports the specified service.
+ ///
+ /// The name of the service to validate.
+ /// Cancellation token.
+ /// Returns true if the runtime exposes the given service (e.g. dapr.proto.runtime.v1.Dapr
+ Task SupportsServiceAsync(string serviceName, CancellationToken cancellationToken = default);
+}
diff --git a/src/Dapr.Common/build/Dapr.Common.props b/src/Dapr.Common/build/Dapr.Common.props
new file mode 100644
index 000000000..b89dda64d
--- /dev/null
+++ b/src/Dapr.Common/build/Dapr.Common.props
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/src/Dapr.Cryptography/Encryption/DaprEncryptionClientBuilder.cs b/src/Dapr.Cryptography/Encryption/DaprEncryptionClientBuilder.cs
index 833a849f3..3142c3608 100644
--- a/src/Dapr.Cryptography/Encryption/DaprEncryptionClientBuilder.cs
+++ b/src/Dapr.Cryptography/Encryption/DaprEncryptionClientBuilder.cs
@@ -33,6 +33,6 @@ public override DaprEncryptionClient Build()
{
var daprClientDependencies = this.BuildDaprClientDependencies(typeof(DaprEncryptionClient).Assembly);
var client = new Autogenerated.Dapr.DaprClient(daprClientDependencies.channel);
- return new DaprEncryptionGrpcClient(client, daprClientDependencies.httpClient, daprClientDependencies.daprApiToken);
+ return new DaprEncryptionGrpcClient(client, daprClientDependencies.httpClient, new DaprRuntimeCapabilities(daprClientDependencies.channel), daprClientDependencies.daprApiToken);
}
}
diff --git a/src/Dapr.Cryptography/Encryption/DaprEncryptionGrpcClient.cs b/src/Dapr.Cryptography/Encryption/DaprEncryptionGrpcClient.cs
index 3ebee7c6a..9f5fc62b1 100644
--- a/src/Dapr.Cryptography/Encryption/DaprEncryptionGrpcClient.cs
+++ b/src/Dapr.Cryptography/Encryption/DaprEncryptionGrpcClient.cs
@@ -26,8 +26,10 @@ namespace Dapr.Cryptography.Encryption;
/// A client for performing cryptography operations with Dapr.
///
[Experimental("DAPR_CRYPTOGRAPHY", UrlFormat = "https://docs.dapr.io/developing-applications/building-blocks/cryptography/cryptography-overview/")]
-internal sealed class DaprEncryptionGrpcClient(Autogenerated.Dapr.DaprClient client, HttpClient httpClient, string? daprApiToken = null) : DaprEncryptionClient(client, httpClient, daprApiToken: daprApiToken)
+internal sealed class DaprEncryptionGrpcClient(Autogenerated.Dapr.DaprClient client, HttpClient httpClient, IDaprRuntimeCapabilities runtimeCapabilities, string? daprApiToken = null) : DaprEncryptionClient(client, httpClient, daprApiToken: daprApiToken)
{
+ private readonly IDaprRuntimeCapabilities runtimeCapabilities = runtimeCapabilities;
+
///
/// Encrypts an array of bytes using the Dapr Cryptography encryption functionality.
///
diff --git a/src/Dapr.DistributedLock/DaprDistributedLockGrpcClient.cs b/src/Dapr.DistributedLock/DaprDistributedLockGrpcClient.cs
index 56e985b89..dc29fb5be 100644
--- a/src/Dapr.DistributedLock/DaprDistributedLockGrpcClient.cs
+++ b/src/Dapr.DistributedLock/DaprDistributedLockGrpcClient.cs
@@ -28,7 +28,7 @@ internal sealed class DaprDistributedLockGrpcClient : DaprDistributedLockClient
///
/// A client for performing distributed locking operations with Dapr.
///
- public DaprDistributedLockGrpcClient(Autogenerated.Dapr.DaprClient client, HttpClient httpClient, string daprApiToken) : base(client, httpClient, daprApiToken)
+ public DaprDistributedLockGrpcClient(Autogenerated.Dapr.DaprClient client, HttpClient httpClient, IDaprRuntimeCapabilities runtimeCapabilities, string daprApiToken) : base(client, httpClient, daprApiToken)
{
}
diff --git a/src/Dapr.DistributedLock/Extensions/DaprDistributedLockBuilder.cs b/src/Dapr.DistributedLock/Extensions/DaprDistributedLockBuilder.cs
index 666b0117b..dc4b3fc99 100644
--- a/src/Dapr.DistributedLock/Extensions/DaprDistributedLockBuilder.cs
+++ b/src/Dapr.DistributedLock/Extensions/DaprDistributedLockBuilder.cs
@@ -33,6 +33,6 @@ public override DaprDistributedLockClient Build()
{
var daprClientDependencies = this.BuildDaprClientDependencies(typeof(DaprDistributedLockClient).Assembly);
var client = new Autogenerated.Dapr.DaprClient(daprClientDependencies.channel);
- return new DaprDistributedLockGrpcClient(client, daprClientDependencies.httpClient, daprClientDependencies.daprApiToken);
+ return new DaprDistributedLockGrpcClient(client, daprClientDependencies.httpClient, new DaprRuntimeCapabilities(daprClientDependencies.channel), daprClientDependencies.daprApiToken);
}
}
diff --git a/src/Dapr.Jobs/Dapr.Jobs.csproj b/src/Dapr.Jobs/Dapr.Jobs.csproj
index 753a377a4..d8f337c78 100644
--- a/src/Dapr.Jobs/Dapr.Jobs.csproj
+++ b/src/Dapr.Jobs/Dapr.Jobs.csproj
@@ -6,6 +6,9 @@
Dapr.Jobs
Dapr Jobs Authoring SDK
Dapr Jobs SDK for scheduling jobs and tasks with Dapr
+
+ true
@@ -22,6 +25,10 @@
+
+
diff --git a/src/Dapr.Jobs/DaprJobsClientBuilder.cs b/src/Dapr.Jobs/DaprJobsClientBuilder.cs
index 12a66a892..6496c5408 100644
--- a/src/Dapr.Jobs/DaprJobsClientBuilder.cs
+++ b/src/Dapr.Jobs/DaprJobsClientBuilder.cs
@@ -31,6 +31,7 @@ public override DaprJobsClient Build()
{
var daprClientDependencies = this.BuildDaprClientDependencies(typeof(DaprJobsClient).Assembly);
var client = new Autogenerated.Dapr.DaprClient(daprClientDependencies.channel);
- return new DaprJobsGrpcClient(client, daprClientDependencies.httpClient, daprClientDependencies.daprApiToken);
+ IDaprRuntimeCapabilities runtimeCapabilities = new DaprRuntimeCapabilities(daprClientDependencies.channel);
+ return new DaprJobsGrpcClient(client, daprClientDependencies.httpClient, runtimeCapabilities, daprClientDependencies.daprApiToken);
}
}
diff --git a/src/Dapr.Jobs/DaprJobsGrpcClient.cs b/src/Dapr.Jobs/DaprJobsGrpcClient.cs
index 71637ad7b..d669d420b 100644
--- a/src/Dapr.Jobs/DaprJobsGrpcClient.cs
+++ b/src/Dapr.Jobs/DaprJobsGrpcClient.cs
@@ -24,8 +24,14 @@ namespace Dapr.Jobs;
///
/// A client for interacting with the Dapr endpoints.
///
-internal sealed class DaprJobsGrpcClient(Autogenerated.Dapr.DaprClient client, HttpClient httpClient, string? daprApiToken = null) : DaprJobsClient(client, httpClient, daprApiToken: daprApiToken)
+internal sealed class DaprJobsGrpcClient(Autogenerated.Dapr.DaprClient client, HttpClient httpClient, IDaprRuntimeCapabilities runtimeCapabilities, string? daprApiToken = null) : DaprJobsClient(client, httpClient, daprApiToken: daprApiToken)
{
+ ///
+ /// Version-aware wrapper that automatically falls back to Alpha1 gRPC variants
+ /// when the connected Dapr runtime does not yet support the stable API.
+ ///
+ private readonly IVersionAwareDaprClient _versionAwareClient = new VersionAwareDaprClient(client, runtimeCapabilities);
+
///
/// Schedules a job with Dapr.
///
@@ -132,7 +138,7 @@ public override async Task ScheduleJobAsync(string jobName, DaprJobSchedule sche
try
{
- await Client.ScheduleJobAsync(envelope, grpcCallOptions).ConfigureAwait(false);
+ await _versionAwareClient.ScheduleJobAsync(envelope, grpcCallOptions).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -158,16 +164,13 @@ public override async Task ScheduleJobAsync(string jobName, DaprJobSchedule sche
/// The details comprising the job.
public override async Task GetJobAsync(string jobName, CancellationToken cancellationToken = default)
{
- if (string.IsNullOrWhiteSpace(jobName))
- {
- throw new ArgumentNullException(nameof(jobName));
- }
-
+ ArgumentException.ThrowIfNullOrWhiteSpace(jobName);
+
try
{
var envelope = new Autogenerated.GetJobRequest { Name = jobName };
var grpcCallOptions = DaprClientUtilities.ConfigureGrpcCallOptions(typeof(DaprJobsClient).Assembly, this.DaprApiToken, cancellationToken);
- var response = await Client.GetJobAsync(envelope, grpcCallOptions);
+ var response = await _versionAwareClient.GetJobAsync(envelope, grpcCallOptions);
return DeserializeJobResponse(response);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -249,7 +252,7 @@ public override async Task DeleteJobAsync(string jobName, CancellationToken canc
{
var envelope = new Autogenerated.DeleteJobRequest { Name = jobName };
var grpcCallOptions = DaprClientUtilities.ConfigureGrpcCallOptions(typeof(DaprJobsClient).Assembly, this.DaprApiToken, cancellationToken);
- await Client.DeleteJobAsync(envelope, grpcCallOptions);
+ await _versionAwareClient.DeleteJobAsync(envelope, grpcCallOptions);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
diff --git a/src/Dapr.Messaging/PublishSubscribe/DaprPublishSubscribeClientBuilder.cs b/src/Dapr.Messaging/PublishSubscribe/DaprPublishSubscribeClientBuilder.cs
index 691ff9d38..c300036c7 100644
--- a/src/Dapr.Messaging/PublishSubscribe/DaprPublishSubscribeClientBuilder.cs
+++ b/src/Dapr.Messaging/PublishSubscribe/DaprPublishSubscribeClientBuilder.cs
@@ -34,6 +34,7 @@ public override DaprPublishSubscribeClient Build()
{
var daprClientDependencies = BuildDaprClientDependencies(typeof(DaprPublishSubscribeClient).Assembly);
var client = new Autogenerated.Dapr.DaprClient(daprClientDependencies.channel);
- return new DaprPublishSubscribeGrpcClient(client, daprClientDependencies.httpClient, daprClientDependencies.daprApiToken);
+ IDaprRuntimeCapabilities runtimeCapabilities = new DaprRuntimeCapabilities(daprClientDependencies.channel);
+ return new DaprPublishSubscribeGrpcClient(client, daprClientDependencies.httpClient, runtimeCapabilities, daprClientDependencies.daprApiToken);
}
}
diff --git a/src/Dapr.Messaging/PublishSubscribe/DaprPublishSubscribeGrpcClient.cs b/src/Dapr.Messaging/PublishSubscribe/DaprPublishSubscribeGrpcClient.cs
index ace670df3..a10a84e40 100644
--- a/src/Dapr.Messaging/PublishSubscribe/DaprPublishSubscribeGrpcClient.cs
+++ b/src/Dapr.Messaging/PublishSubscribe/DaprPublishSubscribeGrpcClient.cs
@@ -11,6 +11,7 @@
// limitations under the License.
// ------------------------------------------------------------------------
+using Dapr.Common;
using P = Dapr.Client.Autogen.Grpc.v1.Dapr;
namespace Dapr.Messaging.PublishSubscribe;
@@ -21,8 +22,11 @@ namespace Dapr.Messaging.PublishSubscribe;
internal sealed class DaprPublishSubscribeGrpcClient(
P.DaprClient client,
HttpClient httpClient,
- string? daprApiToken = null) : DaprPublishSubscribeClient(client, httpClient, daprApiToken)
+ IDaprRuntimeCapabilities runtimeCapabilities,
+ string? daprApiToken = null) : DaprPublishSubscribeClient(client, httpClient,daprApiToken)
{
+ private readonly IDaprRuntimeCapabilities runtimeCapabilities = runtimeCapabilities;
+
///
/// Dynamically subscribes to a Publish/Subscribe component and topic.
///
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index a82e4e7af..dbedf39f5 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -19,6 +19,13 @@
+
+
+
+
+
true
diff --git a/test/Dapr.AspNetCore.IntegrationTest/StateTestClient.cs b/test/Dapr.AspNetCore.IntegrationTest/StateTestClient.cs
index 83a18ad5a..c4475b309 100644
--- a/test/Dapr.AspNetCore.IntegrationTest/StateTestClient.cs
+++ b/test/Dapr.AspNetCore.IntegrationTest/StateTestClient.cs
@@ -18,6 +18,7 @@ namespace Dapr.Client;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
+using Dapr.Common;
using Grpc.Net.Client;
using Autogenerated = Dapr.Client.Autogen.Grpc.v1;
@@ -29,11 +30,20 @@ internal class StateTestClient : DaprClientGrpc
///
/// Initializes a new instance of the class.
///
- internal StateTestClient()
- : base(channel, new Autogenerated.Dapr.DaprClient(channel), new HttpClient(), new Uri("http://localhost"), null, default)
+ internal StateTestClient()
+ : base(channel, new Autogenerated.Dapr.DaprClient(channel), new HttpClient(), new Uri("http://localhost"), null, default, new AlwaysAvailableCapabilities())
{
}
+ private sealed class AlwaysAvailableCapabilities : IDaprRuntimeCapabilities
+ {
+ public Task SupportsMethodAsync(string fullyQualifiedMethodName, CancellationToken cancellationToken = default)
+ => Task.FromResult(true);
+
+ public Task SupportsServiceAsync(string serviceName, CancellationToken cancellationToken = default)
+ => Task.FromResult(true);
+ }
+
public override Task GetStateAsync(string storeName, string key, ConsistencyMode? consistencyMode = default, IReadOnlyDictionary metadata = default, CancellationToken cancellationToken = default)
{
ArgumentVerifier.ThrowIfNullOrEmpty(storeName, nameof(storeName));
diff --git a/test/Dapr.Client.Test/MockClient.cs b/test/Dapr.Client.Test/MockClient.cs
index 1ccdf14c7..249d88bee 100644
--- a/test/Dapr.Client.Test/MockClient.cs
+++ b/test/Dapr.Client.Test/MockClient.cs
@@ -16,7 +16,9 @@ namespace Dapr.Client;
using System;
using System.Net.Http;
using System.Text.Json;
+using System.Threading;
using System.Threading.Tasks;
+using Dapr.Common;
using Grpc.Core;
using Grpc.Net.Client;
using Moq;
@@ -26,7 +28,18 @@ public class MockClient
public MockClient()
{
Mock = new Mock(MockBehavior.Strict);
- DaprClient = new DaprClientGrpc(GrpcChannel.ForAddress("http://localhost"), Mock.Object, new HttpClient(), new Uri("http://localhost:3500"), new JsonSerializerOptions(), default);
+ DaprClient = new DaprClientGrpc(GrpcChannel.ForAddress("http://localhost"), Mock.Object, new HttpClient(), new Uri("http://localhost:3500"), new JsonSerializerOptions(), default, new AlwaysAvailableCapabilities());
+ }
+
+ // Reports every gRPC method as available so tests that mock the DaprClient directly
+ // are not required to handle the gRPC reflection round-trip.
+ private sealed class AlwaysAvailableCapabilities : IDaprRuntimeCapabilities
+ {
+ public Task SupportsMethodAsync(string fullyQualifiedMethodName, CancellationToken cancellationToken = default)
+ => Task.FromResult(true);
+
+ public Task SupportsServiceAsync(string serviceName, CancellationToken cancellationToken = default)
+ => Task.FromResult(true);
}
public Mock Mock { get; }
diff --git a/test/Dapr.Common.Generators.Tests/Analysis/DaprClientAnalyzerTests.cs b/test/Dapr.Common.Generators.Tests/Analysis/DaprClientAnalyzerTests.cs
new file mode 100644
index 000000000..349ef2125
--- /dev/null
+++ b/test/Dapr.Common.Generators.Tests/Analysis/DaprClientAnalyzerTests.cs
@@ -0,0 +1,525 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using Dapr.Common.Generators.Analysis;
+using Dapr.Common.Generators.Models;
+using Dapr.Common.Generators.Tests.Helpers;
+
+namespace Dapr.Common.Generators.Tests.Analysis;
+
+public sealed class DaprClientAnalyzerTests
+{
+ // -------------------------------------------------------------------------
+ // SplitMaturitySuffix – pure string tests
+ // -------------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("ScheduleJob", "ScheduleJob", "")]
+ [InlineData("ScheduleJobAlpha1", "ScheduleJob", "Alpha1")]
+ [InlineData("ScheduleJobAlpha2", "ScheduleJob", "Alpha2")]
+ [InlineData("ScheduleJobBeta1", "ScheduleJob", "Beta1")]
+ [InlineData("ScheduleJobRC1", "ScheduleJob", "RC1")]
+ [InlineData("ListJobs", "ListJobs", "")]
+ [InlineData("ConverseAlpha1", "Converse", "Alpha1")]
+ [InlineData("ConverseAlpha2", "Converse", "Alpha2")]
+ [InlineData("Alpha", "Alpha", "")] // no digit after — not a suffix
+ [InlineData("GetAlphaFoo", "GetAlphaFoo", "")] // "Alpha" not at end
+ public void SplitMaturitySuffix_ReturnsExpected(string grpcName, string expectedBase, string expectedSuffix)
+ {
+ var (baseName, suffix) = DaprClientAnalyzer.SplitMaturitySuffix(grpcName);
+ Assert.Equal(expectedBase, baseName);
+ Assert.Equal(expectedSuffix, suffix);
+ }
+
+ [Theory]
+ [InlineData("FooRC1", "RC1")]
+ [InlineData("FooRC2", "RC2")]
+ [InlineData("FooBeta1", "Beta1")]
+ [InlineData("FooBeta2", "Beta2")]
+ [InlineData("FooAlpha1", "Alpha1")]
+ [InlineData("FooAlpha2", "Alpha2")]
+ [InlineData("Foo", "")]
+ public void SplitMaturitySuffix_SuffixRoundTrips(string grpcName, string expectedSuffix)
+ {
+ var (_, actualSuffix) = DaprClientAnalyzer.SplitMaturitySuffix(grpcName);
+ Assert.Equal(expectedSuffix, actualSuffix);
+ }
+
+ // -------------------------------------------------------------------------
+ // AnalyzeCompilation – compilation-based tests
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void StubCompilation_TypesResolve()
+ {
+ // Sanity check: all three types the analyzer needs must resolve in the stub compilation.
+ var compilation = StubCompilation.WithSingleStableVariant();
+
+ var callOptions = compilation.GetTypeByMetadataName("Grpc.Core.CallOptions");
+ var asyncCall = compilation.GetTypeByMetadataName("Grpc.Core.AsyncUnaryCall`1");
+ var daprClient = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.Dapr+DaprClient");
+
+ Assert.NotNull(callOptions);
+ Assert.NotNull(asyncCall);
+ Assert.NotNull(daprClient);
+
+ // DaprClient must have at least one method
+ Assert.NotEmpty(daprClient!.GetMembers().OfType());
+ }
+
+ [Fact]
+ public void StubCompilation_MethodParameterTypesMatch()
+ {
+ // Verify that the method parameter type IS the same symbol as the one
+ // returned by GetTypeByMetadataName (SymbolEqualityComparer must agree).
+ var compilation = StubCompilation.WithSingleStableVariant();
+
+ var callOptionsType = compilation.GetTypeByMetadataName("Grpc.Core.CallOptions")!;
+ var asyncCallType = compilation.GetTypeByMetadataName("Grpc.Core.AsyncUnaryCall`1")!;
+ var daprClientType = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.Dapr+DaprClient")!;
+
+ var methods = daprClientType.GetMembers()
+ .OfType()
+ .Where(m => m.Name.EndsWith("Async"))
+ .ToList();
+
+ Assert.NotEmpty(methods);
+ var method = methods[0];
+ Assert.Equal(2, method.Parameters.Length);
+
+ // Second parameter must be identical to the resolved CallOptions symbol
+ Assert.True(
+ Microsoft.CodeAnalysis.SymbolEqualityComparer.Default.Equals(
+ method.Parameters[1].Type, callOptionsType),
+ $"Parameter type '{method.Parameters[1].Type}' should equal '{callOptionsType}'");
+
+ // Return type's original definition must match AsyncUnaryCall
+ var returnNamed = (Microsoft.CodeAnalysis.INamedTypeSymbol)method.ReturnType;
+ Assert.True(
+ Microsoft.CodeAnalysis.SymbolEqualityComparer.Default.Equals(
+ returnNamed.OriginalDefinition, asyncCallType),
+ $"Return type '{returnNamed}' OriginalDefinition should equal '{asyncCallType}'");
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_ReturnsNull_WhenDaprClientNotFound()
+ {
+ // Compilation with no DaprClient at all
+ var compilation = StubCompilation.Create(daprClientMethods: "");
+
+ // Remove the outer class by using a compilation with only grpc stubs
+ var emptyCompilation = StubCompilation.Create(
+ daprClientMethods: "",
+ extraTypes: "");
+
+ // The stub DOES define a DaprClient, so AnalyzeCompilation should return null
+ // when there are no matching methods (no Async unary methods).
+ var result = DaprClientAnalyzer.AnalyzeCompilation(emptyCompilation);
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_PassThrough_SingleStableVariant()
+ {
+ var compilation = StubCompilation.WithSingleStableVariant();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal("Qux", group.BaseName);
+ Assert.Equal(MethodClassification.PassThrough, group.Classification);
+ Assert.Empty(group.Fallbacks);
+ Assert.Equal("QuxAsync", group.MostRecent.CSharpMethodName);
+ Assert.Equal(MaturityLevel.Stable, group.MostRecent.Level);
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_AutoCompatible_IdenticalTypes()
+ {
+ var compilation = StubCompilation.WithIdenticalTypeVariants();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal("Foo", group.BaseName);
+ Assert.Equal(MethodClassification.AutoCompatible, group.Classification);
+ Assert.Single(group.Fallbacks);
+
+ // Stable variant is most recent
+ Assert.Equal(MaturityLevel.Stable, group.MostRecent.Level);
+ Assert.Equal("FooAsync", group.MostRecent.CSharpMethodName);
+
+ // Alpha1 is the single fallback
+ Assert.Equal(MaturityLevel.Alpha, group.Fallbacks[0].Level);
+ Assert.Equal("FooAlpha1Async", group.Fallbacks[0].CSharpMethodName);
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_AutoCompatible_DifferentButCompatibleTypes()
+ {
+ var compilation = StubCompilation.WithCompatibleDifferentTypes();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal("Bar", group.BaseName);
+ Assert.Equal(MethodClassification.AutoCompatible, group.Classification);
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_SchemaDivergent_IncompatibleAlphaTypes()
+ {
+ var compilation = StubCompilation.WithIncompatibleAlphaVariants();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal("Baz", group.BaseName);
+ Assert.Equal(MethodClassification.SchemaDivergent, group.Classification);
+
+ // Alpha2 (higher number) is the most recent
+ Assert.Equal(MaturityLevel.Alpha, group.MostRecent.Level);
+ Assert.Equal(2, group.MostRecent.LevelNumber);
+ Assert.Equal("BazAlpha2Async", group.MostRecent.CSharpMethodName);
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_AutoCompatible_ObsoleteAlphaVariantIncludedAsFallback()
+ {
+ // Regression test: the real Dapr gRPC stubs mark Alpha1 methods [Obsolete] once the
+ // stable API is promoted. The analyzer must NOT skip those — they are the fallback targets
+ // for older runtimes that only support the alpha API.
+ var compilation = StubCompilation.WithObsoleteAlphaVariant();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal("Corf", group.BaseName);
+ Assert.Equal(MethodClassification.AutoCompatible, group.Classification);
+ Assert.Single(group.Fallbacks);
+ Assert.Equal("CorfAsync", group.MostRecent.CSharpMethodName);
+ Assert.Equal("CorfAlpha1Async", group.Fallbacks[0].CSharpMethodName);
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_MultipleGroups_ReturnsOneGroupPerBaseName()
+ {
+ var compilation = StubCompilation.Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall Method1Async(Q1Request r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall Method2Async(Q2Request r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class Q1Request { }
+ public class Q1Response { }
+ public class Q2Request { }
+ public class Q2Response { }
+ """);
+
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ Assert.Equal(2, groups.Count);
+ Assert.Contains(groups, g => g.BaseName == "Method1");
+ Assert.Contains(groups, g => g.BaseName == "Method2");
+ }
+
+ // -------------------------------------------------------------------------
+ // AreFieldsCompatible
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void AreFieldsCompatible_SameType_ReturnsTrue()
+ {
+ var compilation = StubCompilation.WithIdenticalTypeVariants();
+ var fooRequest = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.FooRequest")!;
+
+ Assert.True(DaprClientAnalyzer.AreFieldsCompatible(fooRequest, fooRequest));
+ }
+
+ [Fact]
+ public void AreFieldsCompatible_SameFieldNames_ReturnsTrue()
+ {
+ var compilation = StubCompilation.WithCompatibleDifferentTypes();
+ var newer = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.BarRequest")!;
+ var older = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.BarRequestAlpha1")!;
+
+ Assert.True(DaprClientAnalyzer.AreFieldsCompatible(newer, older));
+ }
+
+ [Fact]
+ public void AreFieldsCompatible_MissingFieldInNewer_ReturnsFalse()
+ {
+ var compilation = StubCompilation.WithIncompatibleAlphaVariants();
+ var newer = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.BazRequestAlpha2")!;
+ var older = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.BazRequestAlpha1")!;
+
+ Assert.False(DaprClientAnalyzer.AreFieldsCompatible(newer, older));
+ }
+
+ [Fact]
+ public void AreFieldsCompatible_EmptyOlderType_ReturnsTrue()
+ {
+ // An older type with no user-visible properties maps trivially to any newer type.
+ var compilation = StubCompilation.WithIncompatibleAlphaVariants();
+ // PlughResponseAlpha1 is empty — use it as the "older" side
+ var compilation2 = StubCompilation.WithIncompatibleFieldTypes();
+ var emptyType = compilation2.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.PlughResponseAlpha1")!;
+ var anyType = compilation2.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.PlughResponseAlpha2")!;
+
+ Assert.True(DaprClientAnalyzer.AreFieldsCompatible(anyType, emptyType));
+ }
+
+ [Fact]
+ public void AreFieldsCompatible_SameFieldName_DifferentFieldType_ReturnsFalse()
+ {
+ // PlughRequestAlpha1.Value is string; PlughRequestAlpha2.Value is int — type mismatch.
+ var compilation = StubCompilation.WithIncompatibleFieldTypes();
+ var newer = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.PlughRequestAlpha2")!;
+ var older = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.PlughRequestAlpha1")!;
+
+ Assert.False(DaprClientAnalyzer.AreFieldsCompatible(newer, older));
+ }
+
+ // -------------------------------------------------------------------------
+ // ParseMaturityLevel – via AnalyzeCompilation
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void AnalyzeCompilation_AutoCompatible_BetaVariant_HasBetaFallback()
+ {
+ var compilation = StubCompilation.WithBetaVariants();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal("Garply", group.BaseName);
+ Assert.Equal(MethodClassification.AutoCompatible, group.Classification);
+ Assert.Single(group.Fallbacks);
+ Assert.Equal(MaturityLevel.Stable, group.MostRecent.Level);
+ Assert.Equal(MaturityLevel.Beta, group.Fallbacks[0].Level);
+ Assert.Equal(1, group.Fallbacks[0].LevelNumber);
+ Assert.Equal("GarplyBeta1Async", group.Fallbacks[0].CSharpMethodName);
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_AutoCompatible_RCVariant_HasRCFallback()
+ {
+ var compilation = StubCompilation.WithRCVariants();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal("Waldo", group.BaseName);
+ Assert.Equal(MethodClassification.AutoCompatible, group.Classification);
+ Assert.Single(group.Fallbacks);
+ Assert.Equal(MaturityLevel.Stable, group.MostRecent.Level);
+ Assert.Equal(MaturityLevel.ReleaseCandidate, group.Fallbacks[0].Level);
+ Assert.Equal(1, group.Fallbacks[0].LevelNumber);
+ Assert.Equal("WaldoRC1Async", group.Fallbacks[0].CSharpMethodName);
+ }
+
+ // -------------------------------------------------------------------------
+ // Multiple fallbacks ordering
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void AnalyzeCompilation_MultipleFallbacks_OrderedByDescendingLevelNumber()
+ {
+ var compilation = StubCompilation.WithMultipleFallbacks();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal("Grault", group.BaseName);
+ Assert.Equal(MethodClassification.AutoCompatible, group.Classification);
+ Assert.Equal(2, group.Fallbacks.Count);
+ // Alpha2 (higher number) must come before Alpha1
+ Assert.Equal(2, group.Fallbacks[0].LevelNumber);
+ Assert.Equal(1, group.Fallbacks[1].LevelNumber);
+ Assert.Equal("GraultAlpha2Async", group.Fallbacks[0].CSharpMethodName);
+ Assert.Equal("GraultAlpha1Async", group.Fallbacks[1].CSharpMethodName);
+ }
+
+ // -------------------------------------------------------------------------
+ // IsAsyncUnaryWithCallOptions – indirect via AnalyzeCompilation
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void AnalyzeCompilation_ExcludesMethodsWithoutAsyncSuffix()
+ {
+ // All methods in this stub fail at least one IsAsyncUnaryWithCallOptions check.
+ var compilation = StubCompilation.WithOnlyNonMatchingMethods();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.Null(groups);
+ }
+
+ // -------------------------------------------------------------------------
+ // TypeNamesCompatible – indirect via AreFieldsCompatible / AnalyzeCompilation
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void AnalyzeCompilation_SchemaDivergent_WhenSameFieldNameDifferentType()
+ {
+ // PlughAlpha1 has string Value, PlughAlpha2 has int Value — same name, different type.
+ var compilation = StubCompilation.WithIncompatibleFieldTypes();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal(MethodClassification.SchemaDivergent, group.Classification);
+ }
+
+ [Fact]
+ public void AreFieldsCompatible_SameCollectionType_DifferentGenericArgs_ReturnsFalse()
+ {
+ // TroxRequestAlpha2.Entries is RepeatedField; TroxRequestAlpha1.Entries is RepeatedField.
+ // TypeNamesCompatible must recurse into the type argument and find "ItemA" != "ItemB".
+ var compilation = StubCompilation.WithIncompatibleGenericArgTypes();
+ var newer = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.TroxRequestAlpha2")!;
+ var older = compilation.GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.TroxRequestAlpha1")!;
+
+ Assert.False(DaprClientAnalyzer.AreFieldsCompatible(newer, older));
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_SchemaDivergent_WhenSameCollectionTypeDifferentGenericArgs()
+ {
+ var compilation = StubCompilation.WithIncompatibleGenericArgTypes();
+ var groups = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.NotNull(groups);
+ var group = Assert.Single(groups);
+ Assert.Equal(MethodClassification.SchemaDivergent, group.Classification);
+ }
+
+ // -------------------------------------------------------------------------
+ // IsAsyncUnaryWithCallOptions – each rejection branch tested independently
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void AnalyzeCompilation_ExcludesMethod_WhenNoAsyncSuffix()
+ {
+ var compilation = StubCompilation.Create(
+ daprClientMethods: "public global::Grpc.Core.AsyncUnaryCall NopGet(NopRequest r, global::Grpc.Core.CallOptions o) { return null; }",
+ extraTypes: "public class NopRequest {} public class NopResponse {}");
+
+ Assert.Null(DaprClientAnalyzer.AnalyzeCompilation(compilation));
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_ExcludesMethod_WhenSingleParameter()
+ {
+ var compilation = StubCompilation.Create(
+ daprClientMethods: "public global::Grpc.Core.AsyncUnaryCall NopAsync(global::Grpc.Core.CallOptions o) { return null; }",
+ extraTypes: "public class NopResponse {}");
+
+ Assert.Null(DaprClientAnalyzer.AnalyzeCompilation(compilation));
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_ExcludesMethod_WhenSecondParamIsNotCallOptions()
+ {
+ var compilation = StubCompilation.Create(
+ daprClientMethods: "public global::Grpc.Core.AsyncUnaryCall NopAsync(NopRequest r, string s) { return null; }",
+ extraTypes: "public class NopRequest {} public class NopResponse {}");
+
+ Assert.Null(DaprClientAnalyzer.AnalyzeCompilation(compilation));
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_ExcludesMethod_WhenReturnTypeIsNotAsyncUnaryCall()
+ {
+ var compilation = StubCompilation.Create(
+ daprClientMethods: "public global::System.Threading.Tasks.Task NopAsync(NopRequest r, global::Grpc.Core.CallOptions o) { return null; }",
+ extraTypes: "public class NopRequest {} public class NopResponse {}");
+
+ Assert.Null(DaprClientAnalyzer.AnalyzeCompilation(compilation));
+ }
+
+ // -------------------------------------------------------------------------
+ // ParseVariant – null return when type args are not INamedTypeSymbol
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void AnalyzeCompilation_ExcludesMethod_WhenResponseTypeIsArray()
+ {
+ // int[] is IArrayTypeSymbol, not INamedTypeSymbol — ParseVariant returns null.
+ var compilation = StubCompilation.Create(
+ daprClientMethods: "public global::Grpc.Core.AsyncUnaryCall PlumeAsync(PlumeRequest r, global::Grpc.Core.CallOptions o) { return null; }",
+ extraTypes: "public class PlumeRequest {}");
+
+ Assert.Null(DaprClientAnalyzer.AnalyzeCompilation(compilation));
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_ExcludesMethod_WhenRequestTypeIsArray()
+ {
+ // int[] as the first parameter type is IArrayTypeSymbol — ParseVariant returns null.
+ var compilation = StubCompilation.Create(
+ daprClientMethods: "public global::Grpc.Core.AsyncUnaryCall PlumeAsync(int[] r, global::Grpc.Core.CallOptions o) { return null; }",
+ extraTypes: "public class PlumeResponse {}");
+
+ Assert.Null(DaprClientAnalyzer.AnalyzeCompilation(compilation));
+ }
+
+ // -------------------------------------------------------------------------
+ // FindDaprClientType – indirect via AnalyzeCompilation
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void AnalyzeCompilation_ReturnsNull_WhenNoSourceTrees()
+ {
+ // A compilation with no syntax trees has no DaprClient type — FindDaprClientType returns null.
+ var compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create(
+ assemblyName: "Empty",
+ references: [Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
+ options: new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions(
+ Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));
+
+ var result = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_ReturnsNull_WhenDaprClientNestedClassAbsent()
+ {
+ // The outer Dapr class exists in the expected namespace but has no DaprClient nested class.
+ // FindDaprClientType reaches the namespace-walk fallback and finds no member named DaprClient.
+ const string source = """
+ namespace Grpc.Core { public struct CallOptions {} public class AsyncUnaryCall {} }
+ namespace Dapr.Client.Autogen.Grpc.v1 { public static class Dapr { /* no DaprClient */ } }
+ """;
+
+ var compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create(
+ assemblyName: "NoDaprClient",
+ syntaxTrees: [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(source, cancellationToken: TestContext.Current.CancellationToken)],
+ references: [Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
+ options: new Microsoft.CodeAnalysis.CSharp.CSharpCompilationOptions(
+ Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary));
+
+ Assert.Null(DaprClientAnalyzer.AnalyzeCompilation(compilation));
+ }
+
+ [Fact]
+ public void AnalyzeCompilation_ReturnsNull_WhenDaprClientHasNoMatchingMethods()
+ {
+ // DaprClient type exists but has no async-unary methods → variants empty → null.
+ var compilation = StubCompilation.Create(daprClientMethods: "");
+ var result = DaprClientAnalyzer.AnalyzeCompilation(compilation);
+
+ Assert.Null(result);
+ }
+}
diff --git a/test/Dapr.Common.Generators.Tests/Dapr.Common.Generators.Tests.csproj b/test/Dapr.Common.Generators.Tests/Dapr.Common.Generators.Tests.csproj
new file mode 100644
index 000000000..268f82b71
--- /dev/null
+++ b/test/Dapr.Common.Generators.Tests/Dapr.Common.Generators.Tests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
diff --git a/test/Dapr.Common.Generators.Tests/DaprVersionAwareGeneratorTests.cs b/test/Dapr.Common.Generators.Tests/DaprVersionAwareGeneratorTests.cs
new file mode 100644
index 000000000..b84c43aaf
--- /dev/null
+++ b/test/Dapr.Common.Generators.Tests/DaprVersionAwareGeneratorTests.cs
@@ -0,0 +1,159 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using Dapr.Common.Generators.Emission;
+using Dapr.Common.Generators.Tests.Helpers;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace Dapr.Common.Generators.Tests;
+
+public sealed class DaprVersionAwareGeneratorTests
+{
+ // -------------------------------------------------------------------------
+ // Opt-in flag behaviour
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Generator_WithFlagTrue_EmitsTwoSourceFiles()
+ {
+ var result = RunGenerator("true", StubCompilation.WithSingleStableVariant());
+
+ Assert.Equal(2, result.GeneratedTrees.Length);
+ }
+
+ [Fact]
+ public void Generator_WithFlagFalse_EmitsNothing()
+ {
+ var result = RunGenerator("false", StubCompilation.WithSingleStableVariant());
+
+ Assert.Empty(result.GeneratedTrees);
+ }
+
+ [Fact]
+ public void Generator_WithFlagMissing_EmitsNothing()
+ {
+ var result = RunGenerator(flagValue: null, StubCompilation.WithSingleStableVariant());
+
+ Assert.Empty(result.GeneratedTrees);
+ }
+
+ [Fact]
+ public void Generator_WithFlagTrueUppercase_EmitsTwoSourceFiles()
+ {
+ // The flag check uses OrdinalIgnoreCase, so "TRUE" must also trigger emission.
+ var result = RunGenerator("TRUE", StubCompilation.WithSingleStableVariant());
+
+ Assert.Equal(2, result.GeneratedTrees.Length);
+ }
+
+ // -------------------------------------------------------------------------
+ // Output file names
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Generator_WithFlagTrue_OutputFileNamesMatchEmitterConstants()
+ {
+ var result = RunGenerator("true", StubCompilation.WithSingleStableVariant());
+
+ var hints = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .Select(s => s.HintName)
+ .ToList();
+
+ Assert.Contains($"{WrapperCodeEmitter.InterfaceName}.g.cs", hints);
+ Assert.Contains($"{WrapperCodeEmitter.ClassName}.g.cs", hints);
+ }
+
+ // -------------------------------------------------------------------------
+ // Empty / missing DaprClient
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Generator_WithFlagTrue_EmptyDaprClient_EmitsNothing()
+ {
+ // DaprClient type exists but has no async-unary methods → groups is null → no output.
+ var compilation = StubCompilation.Create(daprClientMethods: "");
+ var result = RunGenerator("true", compilation);
+
+ Assert.Empty(result.GeneratedTrees);
+ }
+
+ [Fact]
+ public void Generator_WithFlagTrue_NoDaprClientType_EmitsNothing()
+ {
+ // A bare compilation without any DaprClient type at all → no output.
+ var compilation = CSharpCompilation.Create(
+ assemblyName: "Empty",
+ references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ var result = RunGenerator("true", compilation);
+
+ Assert.Empty(result.GeneratedTrees);
+ }
+
+ // -------------------------------------------------------------------------
+ // Source content sanity checks
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Generator_WithFlagTrue_InterfaceSourceContainsInterfaceName()
+ {
+ var result = RunGenerator("true", StubCompilation.WithSingleStableVariant());
+
+ var interfaceSource = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .First(s => s.HintName == $"{WrapperCodeEmitter.InterfaceName}.g.cs")
+ .SourceText
+ .ToString();
+
+ Assert.Contains(WrapperCodeEmitter.InterfaceName, interfaceSource);
+ }
+
+ [Fact]
+ public void Generator_WithFlagTrue_ClassSourceContainsClassName()
+ {
+ var result = RunGenerator("true", StubCompilation.WithSingleStableVariant());
+
+ var classSource = result.Results
+ .SelectMany(r => r.GeneratedSources)
+ .First(s => s.HintName == $"{WrapperCodeEmitter.ClassName}.g.cs")
+ .SourceText
+ .ToString();
+
+ Assert.Contains(WrapperCodeEmitter.ClassName, classSource);
+ }
+
+ // -------------------------------------------------------------------------
+ // Helper
+ // -------------------------------------------------------------------------
+
+ private static GeneratorDriverRunResult RunGenerator(string? flagValue, Compilation compilation)
+ {
+ var generator = new DaprVersionAwareGenerator();
+ var options = flagValue is not null
+ ? new MockAnalyzerConfigOptionsProvider(new Dictionary
+ {
+ ["build_property.IsDaprSdkProject"] = flagValue
+ })
+ : new MockAnalyzerConfigOptionsProvider();
+
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(
+ generators: [generator.AsSourceGenerator()],
+ optionsProvider: options);
+
+ driver = driver.RunGenerators(compilation);
+ return driver.GetRunResult();
+ }
+}
diff --git a/test/Dapr.Common.Generators.Tests/Emission/WrapperCodeEmitterTests.cs b/test/Dapr.Common.Generators.Tests/Emission/WrapperCodeEmitterTests.cs
new file mode 100644
index 000000000..4c93f4a1e
--- /dev/null
+++ b/test/Dapr.Common.Generators.Tests/Emission/WrapperCodeEmitterTests.cs
@@ -0,0 +1,580 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using Dapr.Common.Generators.Analysis;
+using Dapr.Common.Generators.Emission;
+using Dapr.Common.Generators.Models;
+using Dapr.Common.Generators.Tests.Helpers;
+
+namespace Dapr.Common.Generators.Tests.Emission;
+
+public sealed class WrapperCodeEmitterTests
+{
+ // -------------------------------------------------------------------------
+ // Interface emission
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitInterface_ContainsInterfaceName()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant());
+ var source = WrapperCodeEmitter.EmitInterface(groups!);
+
+ Assert.Contains($"interface {WrapperCodeEmitter.InterfaceName}", source);
+ }
+
+ [Fact]
+ public void EmitInterface_ContainsMethodSignature_ForEachGroup()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant());
+ var source = WrapperCodeEmitter.EmitInterface(groups!);
+
+ Assert.Contains("QuxAsync(", source);
+ }
+
+ [Fact]
+ public void EmitInterface_UsesFullyQualifiedTypes()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant());
+ var source = WrapperCodeEmitter.EmitInterface(groups!);
+
+ // FQN prefix must appear for the request / response types
+ Assert.Contains("global::", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // Class emission – PassThrough
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_PassThrough_UsesExpressionBody()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ // Expression-body delegate to _inner, no async keyword
+ Assert.Contains("=> _inner.QuxAsync(request, options).ResponseAsync", source);
+ Assert.DoesNotContain("async", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // Class emission – AutoCompatible (same types)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_AutoCompatible_IdenticalTypes_ContainsCapabilityChecks()
+ {
+ var groups = Analyze(StubCompilation.WithIdenticalTypeVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ // Both variants must be checked
+ Assert.Contains("SupportsMethodAsync(\"dapr.proto.runtime.v1.Dapr/Foo\"", source);
+ Assert.Contains("SupportsMethodAsync(\"dapr.proto.runtime.v1.Dapr/FooAlpha1\"", source);
+ }
+
+ [Fact]
+ public void EmitClass_AutoCompatible_IdenticalTypes_IsAsync()
+ {
+ var groups = Analyze(StubCompilation.WithIdenticalTypeVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("async global::System.Threading.Tasks.Task", source);
+ }
+
+ [Fact]
+ public void EmitClass_AutoCompatible_IdenticalTypes_ThrowsFeatureNotAvailable()
+ {
+ var groups = Analyze(StubCompilation.WithIdenticalTypeVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("DaprFeatureNotAvailableException", source);
+ Assert.Contains("\"Foo\"", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // Class emission – SchemaDivergent
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_SchemaDivergent_ThrowsNotSupportedException_ForFallback()
+ {
+ var groups = Analyze(StubCompilation.WithIncompatibleAlphaVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("NotSupportedException", source);
+ Assert.Contains("DaprFeatureNotAvailableException", source);
+ }
+
+ [Fact]
+ public void EmitClass_SchemaDivergent_MostRecentVariantIsCheckedFirst()
+ {
+ var groups = Analyze(StubCompilation.WithIncompatibleAlphaVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ // The capability-check strings should appear in order: Alpha2 before Alpha1.
+ // For SchemaDivergent the fallback variant is not called (NotSupportedException is thrown),
+ // so we look at the SupportsMethodAsync capability-check strings.
+ var alpha2Pos = source.IndexOf("Dapr/BazAlpha2\"", StringComparison.Ordinal);
+ var alpha1Pos = source.IndexOf("Dapr/BazAlpha1\"", StringComparison.Ordinal);
+
+ Assert.True(alpha2Pos >= 0, "SupportsMethodAsync check for BazAlpha2 should be present");
+ Assert.True(alpha1Pos >= 0, "SupportsMethodAsync check for BazAlpha1 should be present");
+ Assert.True(alpha2Pos < alpha1Pos, "Alpha2 (most recent) capability check should appear before Alpha1");
+ }
+
+ // -------------------------------------------------------------------------
+ // Class emission – Unimplemented fallback guard
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_AutoCompatible_MostRecentVariant_ContainsUnimplementedCatch()
+ {
+ var groups = Analyze(StubCompilation.WithIdenticalTypeVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ // The most-recent variant should be wrapped in a try-catch for StatusCode.Unimplemented
+ // so that a runtime which defines but doesn't implement the method falls through to Alpha1.
+ Assert.Contains("StatusCode.Unimplemented", source);
+ Assert.Contains("catch (global::Grpc.Core.RpcException __implEx)", source);
+ }
+
+ [Fact]
+ public void EmitClass_SchemaDivergent_MostRecentVariant_ContainsUnimplementedCatch()
+ {
+ var groups = Analyze(StubCompilation.WithIncompatibleAlphaVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("StatusCode.Unimplemented", source);
+ Assert.Contains("catch (global::Grpc.Core.RpcException __implEx)", source);
+ }
+
+ [Fact]
+ public void EmitClass_PassThrough_DoesNotContainUnimplementedCatch()
+ {
+ // PassThrough methods have only one variant — no fallback, no UNIMPLEMENTED guard needed.
+ var groups = Analyze(StubCompilation.WithSingleStableVariant());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.DoesNotContain("StatusCode.Unimplemented", source);
+ }
+
+ [Fact]
+ public void EmitClass_AutoCompatible_MostRecentVariant_ContainsUnknownProxyErrorCatch()
+ {
+ // Older Dapr runtimes return StatusCode.Unknown with a proxy-routing error message when
+ // they receive a gRPC method they don't recognise. This must also trigger fallback.
+ var groups = Analyze(StubCompilation.WithIdenticalTypeVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("StatusCode.Unknown", source);
+ Assert.Contains("dapr-callee-app-id or dapr-app-id not found", source);
+ }
+
+ [Fact]
+ public void EmitClass_SchemaDivergent_MostRecentVariant_ContainsUnknownProxyErrorCatch()
+ {
+ // Same proxy-routing fallback must also be present for SchemaDivergent methods.
+ var groups = Analyze(StubCompilation.WithIncompatibleAlphaVariants());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("StatusCode.Unknown", source);
+ Assert.Contains("dapr-callee-app-id or dapr-app-id not found", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // Class emission – obsolete-warning suppression
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_ContainsPragmaDisableObsoleteWarning()
+ {
+ // The generated class file must suppress CS0618 because it intentionally calls
+ // [Obsolete]-tagged alpha/beta stubs as fallback targets for older runtimes.
+ var groups = Analyze(StubCompilation.WithObsoleteAlphaVariant());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("#pragma warning disable CS0612, CS0618", source);
+ }
+
+ [Fact]
+ public void EmitClass_ObsoleteAlpha_GeneratesAutoCompatibleFallback()
+ {
+ // When the Alpha1 variant is [Obsolete], the class must still emit the full
+ // capability-check + try/catch + fallback chain (not a simple PassThrough).
+ var groups = Analyze(StubCompilation.WithObsoleteAlphaVariant());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("SupportsMethodAsync(\"dapr.proto.runtime.v1.Dapr/Corf\"", source);
+ Assert.Contains("SupportsMethodAsync(\"dapr.proto.runtime.v1.Dapr/CorfAlpha1\"", source);
+ Assert.Contains("CorfAlpha1Async", source);
+ Assert.Contains("async global::System.Threading.Tasks.Task", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // Class emission – constructor and fields
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_ContainsConstructorWithInnerAndCapabilities()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains($"public {WrapperCodeEmitter.ClassName}(", source);
+ Assert.Contains("IDaprRuntimeCapabilities", source);
+ Assert.Contains("DaprClient inner", source);
+ }
+
+ [Fact]
+ public void EmitClass_IsPartial()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant());
+ var source = WrapperCodeEmitter.EmitClass(groups!);
+
+ Assert.Contains("partial class", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // Emit() – top-level tuple
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void Emit_ReturnsTuple_BothSourcesNonEmpty()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var (interfaceSource, classSource) = WrapperCodeEmitter.Emit(groups);
+
+ Assert.NotEmpty(interfaceSource);
+ Assert.NotEmpty(classSource);
+ }
+
+ [Fact]
+ public void Emit_InterfaceSourceMatchesEmitInterface()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var (interfaceSource, _) = WrapperCodeEmitter.Emit(groups);
+
+ Assert.Equal(WrapperCodeEmitter.EmitInterface(groups), interfaceSource);
+ }
+
+ [Fact]
+ public void Emit_ClassSourceMatchesEmitClass()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var (_, classSource) = WrapperCodeEmitter.Emit(groups);
+
+ Assert.Equal(WrapperCodeEmitter.EmitClass(groups), classSource);
+ }
+
+ // -------------------------------------------------------------------------
+ // File header / namespace
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitInterface_ContainsAutoGeneratedComment()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var source = WrapperCodeEmitter.EmitInterface(groups);
+
+ Assert.Contains("// ", source);
+ }
+
+ [Fact]
+ public void EmitClass_ContainsAutoGeneratedComment()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("// ", source);
+ }
+
+ [Fact]
+ public void EmitClass_ContainsNullableEnable()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("#nullable enable", source);
+ }
+
+ [Fact]
+ public void EmitClass_ContainsNamespace()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("namespace Dapr.Common;", source);
+ }
+
+ [Fact]
+ public void EmitInterface_ContainsNamespace()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var source = WrapperCodeEmitter.EmitInterface(groups);
+
+ Assert.Contains("namespace Dapr.Common;", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // Class fields and constructor
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_ContainsInnerField()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("DaprClient _inner", source);
+ }
+
+ [Fact]
+ public void EmitClass_ContainsCapabilitiesField()
+ {
+ var groups = Analyze(StubCompilation.WithSingleStableVariant())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("IDaprRuntimeCapabilities _capabilities", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // AutoCompatible – request/response type conversion (different types)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_AutoCompatible_DifferentTypes_EmitsNewFallbackRequestObject()
+ {
+ // BarRequest → BarRequestAlpha1 conversion should create a new instance.
+ var groups = Analyze(StubCompilation.WithCompatibleDifferentTypes())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("new global::Dapr.Client.Autogen.Grpc.v1.BarRequestAlpha1()", source);
+ }
+
+ [Fact]
+ public void EmitClass_AutoCompatible_DifferentTypes_EmitsScalarPropertyCopyForRequest()
+ {
+ // The "Name" field from BarRequest should be copied to __fallbackRequest.Name.
+ var groups = Analyze(StubCompilation.WithCompatibleDifferentTypes())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("__fallbackRequest.Name = request.Name;", source);
+ }
+
+ [Fact]
+ public void EmitClass_AutoCompatible_DifferentTypes_EmitsNewResponseObject()
+ {
+ // BarResponseAlpha1 → BarResponse conversion should create a new BarResponse instance.
+ var groups = Analyze(StubCompilation.WithCompatibleDifferentTypes())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("new global::Dapr.Client.Autogen.Grpc.v1.BarResponse()", source);
+ }
+
+ [Fact]
+ public void EmitClass_AutoCompatible_DifferentTypes_EmitsScalarPropertyCopyForResponse()
+ {
+ // The "Result" field from BarResponseAlpha1 should be copied to __convertedResponse.Result.
+ var groups = Analyze(StubCompilation.WithCompatibleDifferentTypes())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("__convertedResponse.Result = __fallbackResponse.Result;", source);
+ }
+
+ [Fact]
+ public void EmitClass_AutoCompatible_DifferentTypes_UsesFullFallbackBlock()
+ {
+ // When types differ the compact one-liner form must NOT be used.
+ var groups = Analyze(StubCompilation.WithCompatibleDifferentTypes())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ // Compact form would be "return await _inner.BarAlpha1Async(request, options)..."
+ Assert.DoesNotContain("BarAlpha1Async(request, options)", source);
+ // Instead the fallback request variable is used
+ Assert.Contains("BarAlpha1Async(__fallbackRequest, options)", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // EmitRequestConversion – same-type inner short-circuit
+ // (sameRequest=true, sameResponse=false → else block runs, but request is not converted)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_SameRequestDifferentResponse_EmitsFallbackRequestEqualsRequest()
+ {
+ // When mostRecent and fallback share the same request type, EmitRequestConversion
+ // must emit the short-circuit form `var __fallbackRequest = request;` rather than
+ // creating a new object.
+ var groups = Analyze(StubCompilation.WithSameRequestDifferentResponse())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("var __fallbackRequest = request;", source);
+ }
+
+ [Fact]
+ public void EmitClass_SameRequestDifferentResponse_DoesNotCreateNewRequestObject()
+ {
+ var groups = Analyze(StubCompilation.WithSameRequestDifferentResponse())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ // No `new XyzRequest*()` should appear — the original `request` is reused.
+ Assert.DoesNotContain("new global::Dapr.Client.Autogen.Grpc.v1.XyzRequest", source);
+ }
+
+ [Fact]
+ public void EmitClass_SameRequestDifferentResponse_EmitsNewResponseObjectAndPropertyCopy()
+ {
+ // The response types differ, so EmitResponseConversion must create a new XyzResponse
+ // and copy the Result property.
+ var groups = Analyze(StubCompilation.WithSameRequestDifferentResponse())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("new global::Dapr.Client.Autogen.Grpc.v1.XyzResponse()", source);
+ Assert.Contains("__convertedResponse.Result = __fallbackResponse.Result;", source);
+ Assert.Contains("return __convertedResponse;", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // EmitResponseConversion – same-type inner short-circuit
+ // (sameRequest=false, sameResponse=true → else block runs, but response is not converted)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_DifferentRequestSameResponse_EmitsNewFallbackRequestObject()
+ {
+ // Request types differ, so EmitRequestConversion must create a new AbcRequestAlpha1.
+ var groups = Analyze(StubCompilation.WithDifferentRequestSameResponse())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("new global::Dapr.Client.Autogen.Grpc.v1.AbcRequestAlpha1()", source);
+ Assert.Contains("__fallbackRequest.Name = request.Name;", source);
+ }
+
+ [Fact]
+ public void EmitClass_DifferentRequestSameResponse_EmitsReturnFallbackResponse()
+ {
+ // When mostRecent and fallback share the same response type, EmitResponseConversion
+ // must emit the short-circuit `return __fallbackResponse;` rather than a conversion.
+ var groups = Analyze(StubCompilation.WithDifferentRequestSameResponse())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("return __fallbackResponse;", source);
+ }
+
+ [Fact]
+ public void EmitClass_DifferentRequestSameResponse_DoesNotCreateNewResponseObject()
+ {
+ var groups = Analyze(StubCompilation.WithDifferentRequestSameResponse())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ // No `new AbcResponse()` should appear — the fallback response is returned directly.
+ Assert.DoesNotContain("new global::Dapr.Client.Autogen.Grpc.v1.AbcResponse()", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // AutoCompatible – same types (compact one-liner)
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_AutoCompatible_SameTypes_UsesCompactOneLiner()
+ {
+ // Identical types → no new object, no property copy; uses request directly.
+ var groups = Analyze(StubCompilation.WithIdenticalTypeVariants())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ // The compact form passes request directly (no __fallbackRequest)
+ Assert.Contains("FooAlpha1Async(request, options).ResponseAsync", source);
+ Assert.DoesNotContain("__fallbackRequest", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // EmitPropertyCopy – collection fields
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_WithCollectionFields_EmitsAddRangeForRepeatedField()
+ {
+ var groups = Analyze(StubCompilation.WithCollectionFields())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("__fallbackRequest.Tags.AddRange(request.Tags);", source);
+ }
+
+ [Fact]
+ public void EmitClass_WithCollectionFields_EmitsForeachForMapField()
+ {
+ var groups = Analyze(StubCompilation.WithCollectionFields())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("foreach (var __kvp in request.Labels)", source);
+ Assert.Contains("__fallbackRequest.Labels[__kvp.Key] = __kvp.Value;", source);
+ }
+
+ [Fact]
+ public void EmitClass_WithCollectionFields_EmitsScalarAssignmentForWritableField()
+ {
+ var groups = Analyze(StubCompilation.WithCollectionFields())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("__fallbackRequest.Name = request.Name;", source);
+ }
+
+ [Fact]
+ public void EmitClass_WithCollectionFields_SkipsReadOnlyNonCollectionField()
+ {
+ // ReadOnlyCount { get; } is neither RepeatedField nor MapField, and has no setter.
+ // EmitPropertyCopy must skip it — no assignment to ReadOnlyCount should appear.
+ var groups = Analyze(StubCompilation.WithCollectionFields())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.DoesNotContain("ReadOnlyCount = ", source);
+ Assert.DoesNotContain(".ReadOnlyCount =", source);
+ }
+
+ // -------------------------------------------------------------------------
+ // Multiple fallbacks
+ // -------------------------------------------------------------------------
+
+ [Fact]
+ public void EmitClass_MultipleFallbacks_EmitsAllCapabilityChecks()
+ {
+ var groups = Analyze(StubCompilation.WithMultipleFallbacks())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ Assert.Contains("SupportsMethodAsync(\"dapr.proto.runtime.v1.Dapr/Grault\"", source);
+ Assert.Contains("SupportsMethodAsync(\"dapr.proto.runtime.v1.Dapr/GraultAlpha2\"", source);
+ Assert.Contains("SupportsMethodAsync(\"dapr.proto.runtime.v1.Dapr/GraultAlpha1\"", source);
+ }
+
+ [Fact]
+ public void EmitClass_MultipleFallbacks_MostRecentCheckedFirst()
+ {
+ var groups = Analyze(StubCompilation.WithMultipleFallbacks())!;
+ var source = WrapperCodeEmitter.EmitClass(groups);
+
+ // The stable "Grault" check must appear before Alpha2, which must appear before Alpha1.
+ var stablePos = source.IndexOf("Dapr/Grault\"", StringComparison.Ordinal);
+ var alpha2Pos = source.IndexOf("Dapr/GraultAlpha2\"", StringComparison.Ordinal);
+ var alpha1Pos = source.IndexOf("Dapr/GraultAlpha1\"", StringComparison.Ordinal);
+
+ Assert.True(stablePos < alpha2Pos, "Stable check should appear before Alpha2 check");
+ Assert.True(alpha2Pos < alpha1Pos, "Alpha2 check should appear before Alpha1 check");
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private static IReadOnlyList? Analyze(Microsoft.CodeAnalysis.Compilation compilation)
+ => DaprClientAnalyzer.AnalyzeCompilation(compilation);
+}
diff --git a/test/Dapr.Common.Generators.Tests/GlobalUsings.cs b/test/Dapr.Common.Generators.Tests/GlobalUsings.cs
new file mode 100644
index 000000000..f78fdb527
--- /dev/null
+++ b/test/Dapr.Common.Generators.Tests/GlobalUsings.cs
@@ -0,0 +1,14 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+global using Xunit;
diff --git a/test/Dapr.Common.Generators.Tests/Helpers/MockAnalyzerConfigOptionsProvider.cs b/test/Dapr.Common.Generators.Tests/Helpers/MockAnalyzerConfigOptionsProvider.cs
new file mode 100644
index 000000000..15b1644de
--- /dev/null
+++ b/test/Dapr.Common.Generators.Tests/Helpers/MockAnalyzerConfigOptionsProvider.cs
@@ -0,0 +1,43 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Dapr.Common.Generators.Tests.Helpers;
+
+///
+/// Minimal in-memory implementation of for use in
+/// source generator unit tests.
+///
+internal sealed class MockAnalyzerConfigOptions(Dictionary? values = null) : AnalyzerConfigOptions
+{
+ private readonly Dictionary _values = values ?? [];
+
+ public override bool TryGetValue(string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value)
+ => _values.TryGetValue(key, out value);
+}
+
+///
+/// Minimal in-memory implementation of for use in
+/// source generator unit tests. Supplies the same options for every tree and file.
+///
+internal sealed class MockAnalyzerConfigOptionsProvider(Dictionary? globalValues = null)
+ : AnalyzerConfigOptionsProvider
+{
+ private readonly AnalyzerConfigOptions _globalOptions = new MockAnalyzerConfigOptions(globalValues);
+
+ public override AnalyzerConfigOptions GlobalOptions => _globalOptions;
+ public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _globalOptions;
+ public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => _globalOptions;
+}
diff --git a/test/Dapr.Common.Generators.Tests/Helpers/StubCompilation.cs b/test/Dapr.Common.Generators.Tests/Helpers/StubCompilation.cs
new file mode 100644
index 000000000..94c81fbfd
--- /dev/null
+++ b/test/Dapr.Common.Generators.Tests/Helpers/StubCompilation.cs
@@ -0,0 +1,373 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace Dapr.Common.Generators.Tests.Helpers;
+
+///
+/// Creates minimal in-memory objects whose type structure
+/// mirrors the Dapr gRPC proto-generated code, allowing unit tests to exercise the
+/// analyser and emitter without referencing external assemblies.
+///
+///
+/// Important: method stubs that are placed inside namespace Dapr.Client.Autogen.Grpc.v1
+/// MUST use global::Grpc.Core.CallOptions / global::Grpc.Core.AsyncUnaryCall<T>.
+/// Without global::, the C# compiler resolves Grpc relative to the enclosing
+/// namespace tree and lands on Dapr.Client.Autogen.Grpc.Core instead of the top-level
+/// Grpc.Core stub, causing type-equality checks in the analyser to fail.
+///
+internal static class StubCompilation
+{
+ ///
+ /// Minimal Grpc.Core stubs: CallOptions and AsyncUnaryCall<T>.
+ /// Kept deliberately simple — the analyzer only checks type identity.
+ ///
+ private const string GrpcCoreStubs = """
+ namespace Grpc.Core
+ {
+ public struct CallOptions { }
+ public class AsyncUnaryCall { }
+ }
+ """;
+
+ ///
+ /// Creates a compilation whose DaprClient contains the supplied method source.
+ /// Extra types are emitted at file scope (outside the Dapr namespace) so that
+ /// their simple names are unambiguous everywhere.
+ ///
+ public static Compilation Create(string daprClientMethods, string? extraTypes = null)
+ {
+ // Extra types are placed INSIDE the Dapr namespace so that
+ // GetTypeByMetadataName("Dapr.Client.Autogen.Grpc.v1.Foo") resolves them.
+ // Method stubs use global:: to bypass the Grpc sub-namespace collision.
+ var outerClass = $$"""
+ namespace Dapr.Client.Autogen.Grpc.v1
+ {
+ {{extraTypes ?? string.Empty}}
+
+ public static class Dapr
+ {
+ public class DaprClient
+ {
+ {{daprClientMethods}}
+ }
+ }
+ }
+ """;
+
+ var trees = new[]
+ {
+ CSharpSyntaxTree.ParseText(GrpcCoreStubs),
+ CSharpSyntaxTree.ParseText(outerClass),
+ };
+
+ return CSharpCompilation.Create(
+ assemblyName: "StubAssembly",
+ syntaxTrees: trees,
+ references: new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) },
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+
+ // ------------------------------------------------------------------
+ // Pre-built stub sets for common test scenarios
+ // NOTE: Method stubs MUST use global:: to avoid the namespace resolution
+ // issue described in the class remarks above.
+ // ------------------------------------------------------------------
+
+ ///
+ /// Stable + Alpha1 variants that share identical request/response types.
+ /// Expected classification: AutoCompatible.
+ ///
+ public static Compilation WithIdenticalTypeVariants() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall FooAsync(FooRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall FooAlpha1Async(FooRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class FooRequest { }
+ public class FooResponse { }
+ """);
+
+ ///
+ /// Stable + Alpha1 variants with structurally compatible (but differently-named) types.
+ /// Both request types have a writable Name string property.
+ /// Expected classification: AutoCompatible.
+ ///
+ public static Compilation WithCompatibleDifferentTypes() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall BarAsync(BarRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall BarAlpha1Async(BarRequestAlpha1 r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class BarRequest { public string Name { get; set; } }
+ public class BarResponse { public string Result { get; set; } }
+ public class BarRequestAlpha1 { public string Name { get; set; } }
+ public class BarResponseAlpha1 { public string Result { get; set; } }
+ """);
+
+ ///
+ /// Two Alpha variants whose request/response types are schema-incompatible.
+ /// Expected classification: SchemaDivergent.
+ ///
+ public static Compilation WithIncompatibleAlphaVariants() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall BazAlpha2Async(BazRequestAlpha2 r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall BazAlpha1Async(BazRequestAlpha1 r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class BazRequestAlpha1 { public string SimpleField { get; set; } }
+ public class BazResponseAlpha1 { public string SimpleResult { get; set; } }
+ public class BazRequestAlpha2 { public int TotallyDifferentField { get; set; } }
+ public class BazResponseAlpha2 { public int TotallyDifferentResult { get; set; } }
+ """);
+
+ ///
+ /// Stable variant + [Obsolete]-tagged Alpha1 variant with identical types — mirrors the
+ /// real Dapr gRPC stubs where alpha methods are marked deprecated once the stable API lands.
+ /// Expected classification: AutoCompatible (obsolete is included as a fallback).
+ ///
+ public static Compilation WithObsoleteAlphaVariant() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall CorfAsync(CorfRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ [global::System.ObsoleteAttribute]
+ public global::Grpc.Core.AsyncUnaryCall CorfAlpha1Async(CorfRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class CorfRequest { }
+ public class CorfResponse { }
+ """);
+
+ /// Single stable variant with no prior Alpha. Expected: PassThrough.
+ public static Compilation WithSingleStableVariant() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall QuxAsync(QuxRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class QuxRequest { }
+ public class QuxResponse { }
+ """);
+
+ // ------------------------------------------------------------------
+ // Stubs for EmitRequestConversion / EmitResponseConversion inner paths
+ // ------------------------------------------------------------------
+
+ ///
+ /// Stable + Alpha1 where both share the SAME request type but use DIFFERENT response types.
+ /// This forces the else-block in EmitAutoCompatibleMethod while making the EmitRequestConversion
+ /// inner same-type check emit var __fallbackRequest = request; rather than a new object,
+ /// and triggers the full EmitResponseConversion (new object + property copy).
+ /// Expected classification: AutoCompatible.
+ ///
+ public static Compilation WithSameRequestDifferentResponse() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall XyzAsync(XyzRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall XyzAlpha1Async(XyzRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class XyzRequest { public string Name { get; set; } }
+ public class XyzResponse { public string Result { get; set; } }
+ public class XyzResponseAlpha1 { public string Result { get; set; } }
+ """);
+
+ ///
+ /// Stable + Alpha1 where variants use DIFFERENT request types but share the SAME response type.
+ /// This forces the else-block in EmitAutoCompatibleMethod while triggering the full
+ /// EmitRequestConversion (new object + property copy) and making the EmitResponseConversion
+ /// inner same-type check emit return __fallbackResponse;.
+ /// Expected classification: AutoCompatible.
+ ///
+ public static Compilation WithDifferentRequestSameResponse() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall AbcAsync(AbcRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall AbcAlpha1Async(AbcRequestAlpha1 r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class AbcRequest { public string Name { get; set; } }
+ public class AbcRequestAlpha1 { public string Name { get; set; } }
+ public class AbcResponse { public string Result { get; set; } }
+ """);
+
+ // ------------------------------------------------------------------
+ // Stubs for TypeNamesCompatible recursive generic-arg check
+ // ------------------------------------------------------------------
+
+ ///
+ /// Two Alpha variants where both request types have the same field name ("Entries") and the
+ /// same outer collection type ("RepeatedField") but different generic type arguments.
+ /// TypeNamesCompatible must recurse into the type arguments and return false, causing
+ /// AreFieldsCompatible → false → SchemaDivergent.
+ ///
+ public static Compilation WithIncompatibleGenericArgTypes() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall TroxAlpha2Async(TroxRequestAlpha2 r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall TroxAlpha1Async(TroxRequestAlpha1 r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class RepeatedField { }
+ public class ItemA { }
+ public class ItemB { }
+ public class TroxRequestAlpha2 { public RepeatedField Entries { get; } }
+ public class TroxResponseAlpha2 { }
+ public class TroxRequestAlpha1 { public RepeatedField Entries { get; } }
+ public class TroxResponseAlpha1 { }
+ """);
+
+ ///
+ /// Stable + Beta1 variants with identical types.
+ /// Expected classification: AutoCompatible; fallback at Beta level.
+ ///
+ public static Compilation WithBetaVariants() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall GarplyAsync(GarplyRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall GarplyBeta1Async(GarplyRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class GarplyRequest { }
+ public class GarplyResponse { }
+ """);
+
+ ///
+ /// Stable + RC1 variants with identical types.
+ /// Expected classification: AutoCompatible; fallback at ReleaseCandidate level.
+ ///
+ public static Compilation WithRCVariants() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall WaldoAsync(WaldoRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall WaldoRC1Async(WaldoRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class WaldoRequest { }
+ public class WaldoResponse { }
+ """);
+
+ ///
+ /// Stable + Alpha2 + Alpha1 variants with identical types.
+ /// Expected classification: AutoCompatible; two fallbacks ordered Alpha2 then Alpha1.
+ ///
+ public static Compilation WithMultipleFallbacks() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall GraultAsync(GraultRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall GraultAlpha2Async(GraultRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall GraultAlpha1Async(GraultRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class GraultRequest { }
+ public class GraultResponse { }
+ """);
+
+ ///
+ /// Two Alpha variants where both variants have the same field name but different field types
+ /// (string vs int). Expected classification: SchemaDivergent.
+ /// Tests TypeNamesCompatible returning false.
+ ///
+ public static Compilation WithIncompatibleFieldTypes() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall PlughAlpha2Async(PlughRequestAlpha2 r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall PlughAlpha1Async(PlughRequestAlpha1 r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class PlughRequestAlpha1 { public string Value { get; set; } }
+ public class PlughResponseAlpha1 { }
+ public class PlughRequestAlpha2 { public int Value { get; set; } }
+ public class PlughResponseAlpha2 { }
+ """);
+
+ ///
+ /// DaprClient containing only methods that do NOT satisfy IsAsyncUnaryWithCallOptions:
+ /// no "Async" suffix, wrong parameter count, wrong return type.
+ /// Expected: AnalyzeCompilation returns null (no valid variants found).
+ ///
+ public static Compilation WithOnlyNonMatchingMethods() => Create(
+ daprClientMethods: """
+ public global::Grpc.Core.AsyncUnaryCall NopNoSuffix(NopRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ public global::Grpc.Core.AsyncUnaryCall NopOneParamAsync(NopRequest r) { return null; }
+ public global::System.Threading.Tasks.Task NopWrongReturnAsync(NopRequest r, global::Grpc.Core.CallOptions o) { return null; }
+ """,
+ extraTypes: """
+ public class NopRequest { }
+ public class NopResponse { }
+ """);
+
+ ///
+ /// Stable + Alpha1 variants where request/response types differ but are compatible,
+ /// and the older type has RepeatedField, MapField, read-only scalar, and writable scalar fields.
+ /// Used to exercise all branches of EmitPropertyCopy.
+ ///
+ public static Compilation WithCollectionFields()
+ {
+ // Stub RepeatedField and MapField in a separate namespace
+ const string collectionStubs = """
+ namespace Proto.Collections
+ {
+ public class RepeatedField
+ {
+ public void AddRange(global::System.Collections.Generic.IEnumerable values) { }
+ }
+ public class MapField
+ {
+ public TValue this[TKey key] { get { return default!; } set { } }
+ }
+ }
+ """;
+
+ const string daprClientSource = """
+ namespace Dapr.Client.Autogen.Grpc.v1
+ {
+ public class CorgeRequest
+ {
+ public string Name { get; set; } = string.Empty;
+ public global::Proto.Collections.RepeatedField Tags { get; } = new();
+ public global::Proto.Collections.MapField Labels { get; } = new();
+ public int ReadOnlyCount { get; }
+ public string Extra { get; set; } = string.Empty;
+ }
+ public class CorgeResponse { public string Result { get; set; } = string.Empty; }
+
+ public class CorgeRequestAlpha1
+ {
+ public string Name { get; set; } = string.Empty;
+ public global::Proto.Collections.RepeatedField Tags { get; } = new();
+ public global::Proto.Collections.MapField Labels { get; } = new();
+ public int ReadOnlyCount { get; }
+ }
+ public class CorgeResponseAlpha1 { public string Result { get; set; } = string.Empty; }
+
+ public static class Dapr
+ {
+ public class DaprClient
+ {
+ public global::Grpc.Core.AsyncUnaryCall CorgeAsync(
+ CorgeRequest r, global::Grpc.Core.CallOptions o) { return null!; }
+ public global::Grpc.Core.AsyncUnaryCall CorgeAlpha1Async(
+ CorgeRequestAlpha1 r, global::Grpc.Core.CallOptions o) { return null!; }
+ }
+ }
+ }
+ """;
+
+ var trees = new[]
+ {
+ CSharpSyntaxTree.ParseText(GrpcCoreStubs),
+ CSharpSyntaxTree.ParseText(collectionStubs),
+ CSharpSyntaxTree.ParseText(daprClientSource),
+ };
+
+ return CSharpCompilation.Create(
+ assemblyName: "StubAssembly",
+ syntaxTrees: trees,
+ references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+}
diff --git a/test/Dapr.Common.Test/Extensions/DaprClientBuilderExtensionsTests.cs b/test/Dapr.Common.Test/Extensions/DaprClientBuilderExtensionsTests.cs
index 91659df77..8d460fb6d 100644
--- a/test/Dapr.Common.Test/Extensions/DaprClientBuilderExtensionsTests.cs
+++ b/test/Dapr.Common.Test/Extensions/DaprClientBuilderExtensionsTests.cs
@@ -159,12 +159,13 @@ public override DaprTestClient Build()
}
}
- private abstract class DaprTestClient(Autogenerated.Dapr.DaprClient client, HttpClient httpClient, string? daprApiToken = null) : IDaprClient
+ private abstract class DaprTestClient(Autogenerated.Dapr.DaprClient client, HttpClient httpClient, IDaprRuntimeCapabilities runtimeCapabilities, string? daprApiToken = null) : IDaprClient
{
internal readonly HttpClient HttpClient = httpClient;
internal readonly string? DaprApiToken = daprApiToken;
+ internal readonly IDaprRuntimeCapabilities RuntimeCapabilities = runtimeCapabilities;
internal Autogenerated.Dapr.DaprClient Client { get; } = client;
-
+
public void Dispose()
{
// TODO release managed resources here
@@ -174,5 +175,6 @@ public void Dispose()
private sealed class DaprTestGrpcClient(
Autogenerated.Dapr.DaprClient client,
HttpClient httpClient,
- string? daprApiToken = null) : DaprTestClient(client, httpClient, daprApiToken);
+ IDaprRuntimeCapabilities runtimeCapabilities,
+ string? daprApiToken = null) : DaprTestClient(client, httpClient, runtimeCapabilities, daprApiToken);
}
diff --git a/test/Dapr.Cryptography.Test/Encryption/DaprEncryptionGrpcClientTests.cs b/test/Dapr.Cryptography.Test/Encryption/DaprEncryptionGrpcClientTests.cs
index 9a2352b55..75037aebb 100644
--- a/test/Dapr.Cryptography.Test/Encryption/DaprEncryptionGrpcClientTests.cs
+++ b/test/Dapr.Cryptography.Test/Encryption/DaprEncryptionGrpcClientTests.cs
@@ -17,6 +17,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
+using Dapr.Common;
using Dapr.Cryptography.Encryption;
using Dapr.Cryptography.Encryption.Models;
using Google.Protobuf;
@@ -31,8 +32,9 @@ public class DaprEncryptionGrpcClientTests
private static DaprEncryptionGrpcClient CreateClient(Mock? mockDaprClient = null)
{
mockDaprClient ??= new Mock();
+ var runtimeCapabilities = new Mock();
var httpClient = new HttpClient();
- return new DaprEncryptionGrpcClient(mockDaprClient.Object, httpClient);
+ return new DaprEncryptionGrpcClient(mockDaprClient.Object, httpClient, runtimeCapabilities.Object);
}
private static AsyncDuplexStreamingCall CreateEncryptCall(
diff --git a/test/Dapr.DistributedLock.Test/DaprDistributedLockGrpcClientTest.cs b/test/Dapr.DistributedLock.Test/DaprDistributedLockGrpcClientTest.cs
index 8db9a58e7..56b86602a 100644
--- a/test/Dapr.DistributedLock.Test/DaprDistributedLockGrpcClientTest.cs
+++ b/test/Dapr.DistributedLock.Test/DaprDistributedLockGrpcClientTest.cs
@@ -25,7 +25,8 @@ public class DaprDistributedLockGrpcClientTest
private static DaprDistributedLockGrpcClient CreateClient()
{
var grpcClient = Mock.Of();
- return new DaprDistributedLockGrpcClient(grpcClient, new HttpClient(), string.Empty);
+ var runtimeCapabilities = Mock.Of();
+ return new DaprDistributedLockGrpcClient(grpcClient, new HttpClient(), runtimeCapabilities, string.Empty);
}
#region TryLockAsync argument validation
diff --git a/test/Dapr.IntegrationTest.Jobs/JobVersionCompatibilityTests.cs b/test/Dapr.IntegrationTest.Jobs/JobVersionCompatibilityTests.cs
new file mode 100644
index 000000000..fb471cd97
--- /dev/null
+++ b/test/Dapr.IntegrationTest.Jobs/JobVersionCompatibilityTests.cs
@@ -0,0 +1,536 @@
+// ------------------------------------------------------------------------
+// Copyright 2025 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+// ------------------------------------------------------------------------
+
+using System.Text;
+using Dapr.Jobs;
+using Dapr.Jobs.Extensions;
+using Dapr.Jobs.Models;
+using Dapr.Jobs.Models.Responses;
+using Dapr.Testcontainers.Common;
+using Dapr.Testcontainers.Harnesses;
+using Dapr.Testcontainers.Xunit.Attributes;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Dapr.IntegrationTest.Jobs;
+
+///
+/// Validates that the Jobs SDK operates correctly across Dapr runtime versions that
+/// surface the Jobs gRPC API at different maturity levels:
+///
+/// 1.16 / 1.17 — ScheduleJobAlpha1 / GetJobAlpha1 / DeleteJobAlpha1 (Alpha1 suffix)
+/// 1.18+ — ScheduleJob / GetJob / DeleteJob (Stable, no suffix)
+///
+/// The version-aware client generated by DaprVersionAwareGenerator performs a gRPC
+/// reflection check at call time and routes to the highest-maturity method the
+/// connected runtime supports, falling back to the Alpha1 variants when the stable
+/// methods are absent. These tests exercise that routing by running against a live
+/// sidecar and asserting functional correctness; a test tagged [MinimumDaprRuntimeFact("1.16")]
+/// exercises the Alpha1 fallback path on older runtimes while the same operations
+/// transparently use the Stable path when run against 1.18+.
+///
+public sealed class JobVersionCompatibilityTests
+{
+ // -----------------------------------------------------------------------
+ // Alpha1 path (Dapr 1.16 – 1.17): the version-aware client falls back to
+ // ScheduleJobAlpha1 / GetJobAlpha1 / DeleteJobAlpha1 because the stable
+ // methods are not yet exposed by the runtime.
+ // On 1.18+ these same tests run via the Stable gRPC methods, proving the
+ // transparent upgrade path works correctly.
+ // -----------------------------------------------------------------------
+
+ [MinimumDaprRuntimeFact("1.16")]
+ public async Task AlphaRuntime_ScheduleThenGetJob_RoundTripSucceeds()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"compat-get-{Guid.NewGuid():N}";
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ var payload = "version-compat-payload"u8.ToArray();
+ var schedule = DaprJobSchedule.FromDuration(TimeSpan.FromHours(1));
+
+ await client.ScheduleJobAsync(jobName, schedule, payload, repeats: 5, overwrite: true,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var details = await client.GetJobAsync(jobName, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(details);
+ Assert.Equal(5, details.RepeatCount);
+ Assert.NotNull(details.Payload);
+ Assert.Equal(Encoding.UTF8.GetString(payload), Encoding.UTF8.GetString(details.Payload));
+
+ await client.DeleteJobAsync(jobName, TestContext.Current.CancellationToken);
+ }
+
+ [MinimumDaprRuntimeFact("1.16")]
+ public async Task AlphaRuntime_DeleteJob_JobNoLongerExists()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"compat-delete-{Guid.NewGuid():N}";
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ await client.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromHours(1)),
+ overwrite: true, cancellationToken: TestContext.Current.CancellationToken);
+
+ var beforeDelete = await client.GetJobAsync(jobName, TestContext.Current.CancellationToken);
+ Assert.NotNull(beforeDelete);
+
+ await client.DeleteJobAsync(jobName, TestContext.Current.CancellationToken);
+
+ await Assert.ThrowsAsync(() =>
+ client.GetJobAsync(jobName, TestContext.Current.CancellationToken));
+ }
+
+ [MinimumDaprRuntimeFact("1.16")]
+ public async Task AlphaRuntime_ScheduleAndReceiveJob_PayloadDelivered()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"compat-recv-{Guid.NewGuid():N}";
+
+ var tcs = new TaskCompletionSource<(string jobName, byte[] payload)>(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .ConfigureApp(app =>
+ {
+ app.MapDaprScheduledJobHandler((string incomingJobName, ReadOnlyMemory payload,
+ ILogger? logger, CancellationToken _) =>
+ {
+ logger?.LogInformation("Compat test received job {Job}", incomingJobName);
+ tcs.TrySetResult((incomingJobName, payload.ToArray()));
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ var payload = "compat-hello"u8.ToArray();
+ await client.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)),
+ payload, repeats: 1, overwrite: true,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(jobName, received.jobName);
+ Assert.Equal(Encoding.UTF8.GetString(payload), Encoding.UTF8.GetString(received.payload));
+ }
+
+ // -----------------------------------------------------------------------
+ // Stable path (Dapr 1.18+): the version-aware client detects that the
+ // Stable gRPC methods (ScheduleJob / GetJob / DeleteJob) are present and
+ // uses them directly without falling back to the Alpha1 variants.
+ // -----------------------------------------------------------------------
+
+ [MinimumDaprRuntimeFact("1.18")]
+ public async Task StableRuntime_ScheduleThenGetJob_RoundTripSucceeds()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"stable-get-{Guid.NewGuid():N}";
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ var payload = "stable-payload"u8.ToArray();
+ var schedule = DaprJobSchedule.FromDuration(TimeSpan.FromHours(1));
+
+ await client.ScheduleJobAsync(jobName, schedule, payload, repeats: 3, overwrite: true,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var details = await client.GetJobAsync(jobName, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(details);
+ Assert.Equal(3, details.RepeatCount);
+ Assert.NotNull(details.Payload);
+ Assert.Equal(Encoding.UTF8.GetString(payload), Encoding.UTF8.GetString(details.Payload));
+
+ await client.DeleteJobAsync(jobName, TestContext.Current.CancellationToken);
+ }
+
+ [MinimumDaprRuntimeFact("1.18")]
+ public async Task StableRuntime_DeleteJob_JobNoLongerExists()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"stable-delete-{Guid.NewGuid():N}";
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ await client.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromHours(1)),
+ overwrite: true, cancellationToken: TestContext.Current.CancellationToken);
+
+ var beforeDelete = await client.GetJobAsync(jobName, TestContext.Current.CancellationToken);
+ Assert.NotNull(beforeDelete);
+
+ await client.DeleteJobAsync(jobName, TestContext.Current.CancellationToken);
+
+ await Assert.ThrowsAsync(() =>
+ client.GetJobAsync(jobName, TestContext.Current.CancellationToken));
+ }
+
+ [MinimumDaprRuntimeFact("1.18")]
+ public async Task StableRuntime_ScheduleAndReceiveJob_PayloadDelivered()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"stable-recv-{Guid.NewGuid():N}";
+
+ var tcs = new TaskCompletionSource<(string jobName, byte[] payload)>(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .ConfigureApp(app =>
+ {
+ app.MapDaprScheduledJobHandler((string incomingJobName, ReadOnlyMemory payload,
+ ILogger? logger, CancellationToken _) =>
+ {
+ logger?.LogInformation("Stable compat test received job {Job}", incomingJobName);
+ tcs.TrySetResult((incomingJobName, payload.ToArray()));
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ var payload = "stable-hello"u8.ToArray();
+ await client.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)),
+ payload, repeats: 1, overwrite: true,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(jobName, received.jobName);
+ Assert.Equal(Encoding.UTF8.GetString(payload), Encoding.UTF8.GetString(received.payload));
+ }
+
+ [MinimumDaprRuntimeFact("1.18")]
+ public async Task StableRuntime_OverwriteJob_UpdatesTakeEffect()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"stable-overwrite-{Guid.NewGuid():N}";
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ var originalPayload = "original"u8.ToArray();
+ await client.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromHours(1)),
+ originalPayload, repeats: 2, overwrite: true,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var original = await client.GetJobAsync(jobName, TestContext.Current.CancellationToken);
+ Assert.Equal(2, original.RepeatCount);
+
+ var updatedPayload = "updated"u8.ToArray();
+ await client.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromMinutes(30)),
+ updatedPayload, repeats: 7, overwrite: true,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var updated = await client.GetJobAsync(jobName, TestContext.Current.CancellationToken);
+ Assert.Equal(7, updated.RepeatCount);
+ Assert.NotNull(updated.Payload);
+ Assert.Equal(Encoding.UTF8.GetString(updatedPayload), Encoding.UTF8.GetString(updated.Payload));
+
+ await client.DeleteJobAsync(jobName, TestContext.Current.CancellationToken);
+ }
+
+ [MinimumDaprRuntimeFact("1.18")]
+ public async Task StableRuntime_ScheduleJobWithDropFailurePolicy_PolicyPersistedAndJobDroppedAfterExecution()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"stable-drop-{Guid.NewGuid():N}";
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .ConfigureApp(app =>
+ {
+ app.MapDaprScheduledJobHandler((string incomingJobName, ReadOnlyMemory _,
+ ILogger? logger, CancellationToken _) =>
+ {
+ logger?.LogInformation("Stable drop-policy test received job {Job}", incomingJobName);
+ tcs.TrySetResult(incomingJobName);
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ await client.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)),
+ failurePolicyOptions: new JobFailurePolicyDropOptions(), repeats: 1, overwrite: true,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(jobName, received);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ client.GetJobAsync(jobName, TestContext.Current.CancellationToken));
+ Assert.NotNull(ex.InnerException);
+ Assert.Contains("job not found", ex.InnerException.Message);
+ }
+
+ [MinimumDaprRuntimeFact("1.18")]
+ public async Task StableRuntime_ScheduleJobWithConstantFailurePolicy_PolicyPersistedCorrectly()
+ {
+ var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component");
+ var jobName = $"stable-constant-{Guid.NewGuid():N}";
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ await using var environment = await DaprTestEnvironment.CreateWithPooledNetworkAsync(
+ cancellationToken: TestContext.Current.CancellationToken);
+ await environment.StartAsync(TestContext.Current.CancellationToken);
+
+ var harness = new DaprHarnessBuilder(componentsDir)
+ .WithEnvironment(environment)
+ .BuildJobs();
+ await using var testApp = await DaprHarnessBuilder.ForHarness(harness)
+ .ConfigureServices(builder =>
+ {
+ builder.Services.AddDaprJobsClient(configure: (sp, clientBuilder) =>
+ {
+ var config = sp.GetRequiredService();
+ var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"];
+ var httpEndpoint = config["DAPR_HTTP_ENDPOINT"];
+ if (!string.IsNullOrEmpty(grpcEndpoint))
+ clientBuilder.UseGrpcEndpoint(grpcEndpoint);
+ if (!string.IsNullOrEmpty(httpEndpoint))
+ clientBuilder.UseHttpEndpoint(httpEndpoint);
+ });
+ })
+ .ConfigureApp(app =>
+ {
+ app.MapDaprScheduledJobHandler((string incomingJobName, ReadOnlyMemory _,
+ ILogger? logger, CancellationToken _) =>
+ {
+ logger?.LogInformation("Stable constant-policy test received job {Job}", incomingJobName);
+ tcs.TrySetResult(incomingJobName);
+ });
+ })
+ .BuildAndStartAsync();
+
+ using var scope = testApp.CreateScope();
+ var client = scope.ServiceProvider.GetRequiredService();
+
+ const int maxRetries = 2;
+ var constantPolicy = new JobFailurePolicyConstantOptions(TimeSpan.FromSeconds(5))
+ {
+ MaxRetries = maxRetries
+ };
+
+ await client.ScheduleJobAsync(jobName, DaprJobSchedule.FromDuration(TimeSpan.FromSeconds(2)),
+ failurePolicyOptions: constantPolicy, repeats: 10, overwrite: true,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ var received = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(jobName, received);
+
+ var details = await client.GetJobAsync(jobName, TestContext.Current.CancellationToken);
+ Assert.NotNull(details.FailurePolicy);
+ Assert.Equal(JobFailurePolicy.Constant, details.FailurePolicy.Type);
+ if (details.FailurePolicy is ConfiguredConstantFailurePolicy policy)
+ {
+ Assert.True(policy.HasMaxRetries);
+ Assert.Equal(maxRetries, policy.MaxRetries);
+ Assert.Equal(TimeSpan.FromSeconds(5), policy.Duration);
+ }
+ else
+ {
+ Assert.Fail("Expected a ConfiguredConstantFailurePolicy.");
+ }
+
+ await client.DeleteJobAsync(jobName, TestContext.Current.CancellationToken);
+ }
+}
diff --git a/test/Dapr.Messaging.Test/PublishSubscribe/DaprPublishSubscribeGrpcClientTests.cs b/test/Dapr.Messaging.Test/PublishSubscribe/DaprPublishSubscribeGrpcClientTests.cs
index 2bf813ae4..e1f5aace6 100644
--- a/test/Dapr.Messaging.Test/PublishSubscribe/DaprPublishSubscribeGrpcClientTests.cs
+++ b/test/Dapr.Messaging.Test/PublishSubscribe/DaprPublishSubscribeGrpcClientTests.cs
@@ -66,7 +66,7 @@ private static TopicMessageHandler SuccessHandler() =>
private static DaprPublishSubscribeGrpcClient CreateGrpcClient(Mock mockDaprClient,
string? apiToken = null) =>
- new(mockDaprClient.Object, new HttpClient(), apiToken);
+ new(mockDaprClient.Object, new HttpClient(), Mock.Of(), apiToken);
// -------------------------------------------------------------------------
// SubscribeAsync tests
@@ -173,7 +173,7 @@ public void Dispose_DisposesUnderlyingHttpClient()
{
var httpClient = new HttpClient();
var client = new DaprPublishSubscribeGrpcClient(
- BuildMockDaprClient().Object, httpClient, daprApiToken: null);
+ BuildMockDaprClient().Object, httpClient, Mock.Of(), daprApiToken: null);
client.Dispose();
diff --git a/test/Shared/TestClient.cs b/test/Shared/TestClient.cs
index c84fe4c9e..e1bc5cc7c 100644
--- a/test/Shared/TestClient.cs
+++ b/test/Shared/TestClient.cs
@@ -27,6 +27,7 @@
using Dapr.Actors;
#endif
using Dapr.Client;
+using Dapr.Common;
using Google.Protobuf;
using Grpc.Net.Client;
@@ -62,10 +63,27 @@ public static TestClient CreateForDaprClient(Action(builder.Build(), handler);
}
+ ///
+ /// Stub implementation of that reports every
+ /// method and service as available. Used in unit tests to bypass the gRPC reflection
+ /// call that would otherwise make.
+ ///
+ private sealed class AlwaysAvailableRuntimeCapabilities : IDaprRuntimeCapabilities
+ {
+ public Task SupportsMethodAsync(string fullyQualifiedMethodName, CancellationToken cancellationToken = default)
+ => Task.FromResult(true);
+
+ public Task SupportsServiceAsync(string serviceName, CancellationToken cancellationToken = default)
+ => Task.FromResult(true);
+ }
+
private static async Task WithTimeout(Task task, TimeSpan timeout, string message)
{
var tcs = new TaskCompletionSource