Repository: dapr/dotnet-sdk (csharp)

Issue:
GetBulkStateAsync should have typed overload that performs bulk deserialization
## Describe the feature
When calling `DaprClient.GetStateAsync<T>` one can specify a type parameter to have the SDK deserialize the response to. This is nice as it prevents the user from having to maintain their own serialization/deserialization options somewhere and rely on whatever the SDK is configured to use.

However, when using `DaprClient.GetBulkStateAsync`, this does not accept a type parameter, so one ultimately retrieves an `IReadOnlyList<BulkStateItem>` wherein the `BulkStateItem` contains ETag, Key and Value strings. This leaves deserialization to the user (who again, may have relied on Dapr to perform the initial serialization and thus may not be clued into precisely how it's serialized).

I would propose that for historical reasons, the existing approach be maintained, but that an overload be added to both `GetBulkStateAsync` and `BulkStateItem` that each accept a type parameter and which deserialize the returned result from the former as `IReadOnlyList<BulkStateItem<T>>` wherein the `BulkStateItem` contains a string property for both Key and ETag, but a type T value, wherein deserialization has already been performed, like that of `GetStateAsync`.

## Release Note
A type parameter overload has been added to `BulkStateItem` so items retrieved from state using `GetBulkStateItem<T>` will now have their values deserialize to T instead of exclusively returning a serailized string.

<!-- RELEASE NOTE: **ADD** New feature in Dapr. -->
<!-- RELEASE NOTE: **FIX** Bug in runtime. -->
<!-- RELEASE NOTE: **UPDATE** Runtime dependency. -->

RELEASE NOTE:
**ADD** A type parameter overload has been added to `BulkStateItem` so the value of items retrieved from state using `GetBulkStateItem<T>` will now deserialize to T instead of exclusively returning a serialized string.

Pull request:
Adds overload to BulkStateItem and GetBulkStateAsync to perform SDK-based deserialization of returned values instead of strictly returning serialized strings.

Signed-off-by: Whit Waldo <whit.waldo@innovian.net>

# Description
Explained in more detail at #1172 , but `GetBulkItemAsync` returns a `BulkStateItem` with a Key, ETag and Value wherein each is typed as a string and the user is expected to deserialize the Value. Because the state might have been originally added one by one, relying on Dapr and its serialization options to serialize everything, this can cause downstream issues deserializing values not originally serialized by the developer.

As such, just as `GetStateAsync` allows a type parameter to deserialize each returned value, I've added the same overload to `GetBulkItemAsync` to deserialize using the same mechanism as `GetStateAsync` to deserialize each of the returned items as part of an overloaded and generically-typed `BulkItemState`.

## Issue reference

As it's a simple addition, I didn't think it really merited a lot of discussion as the functionality is already available for single items (and this just adds the same for multiple items). 

Please reference the issue this PR will close: #1172

## Checklist

Please make sure you've completed the relevant tasks for this PR, out of the following list:

* [X] Code compiles correctly
* [X] Created/updated tests
* [X] Extended the documentation - PR created at [https://github.com/dapr/docs/pull/3843](https://github.com/dapr/docs/pull/3843)

I'll work on finding relevant documentation and updating that right now.

Patch:
diff --git a/src/Dapr.Client/BulkStateItem.cs b/src/Dapr.Client/BulkStateItem.cs
index fb717e1a8..5b30ddf21 100644
--- a/src/Dapr.Client/BulkStateItem.cs
+++ b/src/Dapr.Client/BulkStateItem.cs
@@ -49,4 +49,42 @@ public BulkStateItem(string key, string value, string etag)
         /// </summary>
         public string ETag { get; }
     }
+
+    /// <summary>
+    /// Represents a state object returned from a bulk get state operation where the value has
+    /// been deserialized to the specified type.
+    /// </summary>
+    public readonly struct BulkStateItem<TValue>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="BulkStateItem"/> class.
+        /// </summary>
+        /// <param name="key">The state key.</param>
+        /// <param name="value">The typed value.</param>
+        /// <param name="etag">The ETag.</param>
+        /// <remarks>
+        /// Application code should not need to create instances of <see cref="BulkStateItem" />.
+        /// </remarks>
+        public BulkStateItem(string key, TValue value, string etag)
+        {
+            this.Key = key;
+            this.Value = value;
+            this.ETag = etag;
+        }
+
+        /// <summary>
+        /// Gets the state key.
+        /// </summary>
+        public string Key { get; }
+
+        /// <summary>
+        /// Gets the deserialized value of the indicated type.
+        /// </summary>
+        public TValue Value { get; }
+
+        /// <summary>
+        /// Get the ETag.
+        /// </summary>
+        public string ETag { get; }
+    }
 }
diff --git a/src/Dapr.Client/DaprClient.cs b/src/Dapr.Client/DaprClient.cs
index 7f094804f..21777105b 100644
--- a/src/Dapr.Client/DaprClient.cs
+++ b/src/Dapr.Client/DaprClient.cs
@@ -717,6 +717,20 @@ public abstract Task<TResponse> InvokeMethodGrpcAsync<TRequest, TResponse>(
         /// <returns>A <see cref="Task{IReadOnlyList}" /> that will return the list of values when the operation has completed.</returns>
         public abstract Task<IReadOnlyList<BulkStateItem>> GetBulkStateAsync(string storeName, IReadOnlyList<string> keys, int? parallelism, IReadOnlyDictionary<string, string> metadata = default, CancellationToken cancellationToken = default);
 
+        /// <summary>
+        /// Gets a list of deserialized values associated with the <paramref name="keys" /> from the Dapr state store. This overload should be used
+        /// if you expect the values of all the retrieved items to match the shape of the indicated <typeparam name="TValue"/>. If you expect that
+        /// the values may differ in type from one another, do not specify the type parameter and instead use the original <see cref="GetBulkStateAsync"/> method
+        /// so the serialized string values will be returned instead.
+        /// </summary>
+        /// <param name="storeName">The name of state store to read from.</param>
+        /// <param name="keys">The list of keys to get values for.</param>
+        /// <param name="parallelism">The number of concurrent get operations the Dapr runtime will issue to the state store. a value equal to or smaller than 0 means max parallelism.</param>
+        /// <param name="metadata">A collection of metadata key-value pairs that will be provided to the state store. The valid metadata keys and values are determined by the type of state store used.</param>
+        /// <param name="cancellationToken">A <see cref="CancellationToken" /> that can be used to cancel the operation.</param>
+        /// <returns>A <see cref="Task{IReadOnlyList}" /> that will return the list of deserialized values when the operation has completed.</returns>
+        public abstract Task<IReadOnlyList<BulkStateItem<TValue>>> GetBulkStateAsync<TValue>(string storeName, IReadOnlyList<string> keys, int? parallelism, IReadOnlyDictionary<string, string> metadata = default, CancellationToken cancellationToken = default);
+        
         /// <summary>
         /// Saves a list of <paramref name="items" /> to the Dapr state store.
         /// </summary>
diff --git a/src/Dapr.Client/DaprClientGrpc.cs b/src/Dapr.Client/DaprClientGrpc.cs
index 9c99b9eee..f856b87e6 100644
--- a/src/Dapr.Client/DaprClientGrpc.cs
+++ b/src/Dapr.Client/DaprClientGrpc.cs
@@ -601,7 +601,50 @@ public override async Task<TResponse> InvokeMethodGrpcAsync<TRequest, TResponse>
 
         #region State Apis
 
+        /// <inheritdoc />
         public override async Task<IReadOnlyList<BulkStateItem>> GetBulkStateAsync(string storeName, IReadOnlyList<string> keys, int? parallelism, IReadOnlyDictionary<string, string> metadata = default, CancellationToken cancellationToken = default)
+        {
+            var rawBulkState = await GetBulkStateRawAsync(storeName, keys, parallelism, metadata, cancellationToken);
+
+            var bulkResponse = new List<BulkStateItem>();
+            foreach (var item in rawBulkState)
+            {
+                bulkResponse.Add(new BulkStateItem(item.Key, item.Value.ToStringUtf8(), item.Etag));
+            }
+
+            return bulkResponse;
+        }
+        
+        /// <inheritdoc/> 
+        public override async Task<IReadOnlyList<BulkStateItem<TValue>>> GetBulkStateAsync<TValue>(
+            string storeName,
+            IReadOnlyList<string> keys, 
+            int? parallelism, 
+            IReadOnlyDictionary<string, string> metadata = default,
+            CancellationToken cancellationToken = default)
+        {
+            var rawBulkState = await GetBulkStateRawAsync(storeName, keys, parallelism, metadata, cancellationToken);
+
+            var bulkResponse = new List<BulkStateItem<TValue>>();
+            foreach (var item in rawBulkState)
+            {
+                var deserializedValue = TypeConverters.FromJsonByteString<TValue>(item.Value, this.JsonSerializerOptions);
+                bulkResponse.Add(new BulkStateItem<TValue>(item.Key, deserializedValue, item.Etag));
+            }
+
+            return bulkResponse;
+        }
+
+        /// <summary>
+        /// Retrieves the bulk state data, but rather than deserializing the values, leaves the specific handling
+        /// to the public callers of this method to avoid duplicate deserialization.
+        /// </summary>
+        private async Task<IReadOnlyList<(string Key, string Etag, ByteString Value)>> GetBulkStateRawAsync(
+            string storeName,
+            IReadOnlyList<string> keys, 
+            int? parallelism, 
+            IReadOnlyDictionary<string, string> metadata = default,
+            CancellationToken cancellationToken = default)
         {
             ArgumentVerifier.ThrowIfNullOrEmpty(storeName, nameof(storeName));
             if (keys.Count == 0)
@@ -609,7 +652,7 @@ public override async Task<IReadOnlyList<BulkStateItem>> GetBulkStateAsync(strin
 
             var envelope = new Autogenerated.GetBulkStateRequest()
             {
-                StoreName = storeName,
+                StoreName = storeName, 
                 Parallelism = parallelism ?? default
             };
 
@@ -632,18 +675,20 @@ public override async Task<IReadOnlyList<BulkStateItem>> GetBulkStateAsync(strin
             }
             catch (RpcException ex)
             {
-                throw new DaprException("State operation failed: the Dapr endpoint indicated a failure. See InnerException for details.", ex);
+                throw new DaprException(
+                    "State operation failed: the Dapr endpoint indicated a failure. See InnerException for details.",
+                    ex);
             }
 
-            var bulkResponse = new List<BulkStateItem>();
+            var bulkResponse = new List<(string Key, string Etag, ByteString Value)>();
             foreach (var item in response.Items)
             {
-                bulkResponse.Add(new BulkStateItem(item.Key, item.Data.ToStringUtf8(), item.Etag));
+                bulkResponse.Add((item.Key, item.Etag, item.Data));
             }
 
             return bulkResponse;
         }
-
+        
         /// <inheritdoc/>
         public override async Task<TValue> GetStateAsync<TValue>(
             string storeName,


Test patch:
diff --git a/test/Dapr.Client.Test/StateApiTest.cs b/test/Dapr.Client.Test/StateApiTest.cs
index cfa664663..2595fb006 100644
--- a/test/Dapr.Client.Test/StateApiTest.cs
+++ b/test/Dapr.Client.Test/StateApiTest.cs
@@ -75,6 +75,30 @@ public async Task GetBulkStateAsync_CanReadState()
             state.Should().HaveCount(1);
         }
 
+        [Fact]
+        public async Task GetBulkStateAsync_CanReadDeserializedState()
+        {
+            await using var client = TestClient.CreateForDaprClient();
+
+            var key = "test";
+            var request = await client.CaptureGrpcRequestAsync(async daprClient =>
+            {
+                return await daprClient.GetBulkStateAsync<Widget>("testStore", new List<string>() {key}, null);
+            });
+
+            // Create Response & Respond
+            const string size = "small";
+            const string color = "yellow";
+            var data = new Widget() {Size = size, Color = color};
+            var envelope = MakeGetBulkStateResponse<Widget>(key, data);
+            var state = await request.CompleteWithMessageAsync(envelope);
+
+            // Get response and validate
+            state.Should().HaveCount(1);
+            state[0].Value.Size.Should().Match(size);
+            state[0].Value.Color.Should().Match(color);
+        }
+        
         [Fact]
         public async Task GetBulkStateAsync_WrapsRpcException()
         {