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