Skip to content

Commit 5d238df

Browse files
committed
fix: preserve typed JsonElement values in Orleans JSON storage
1 parent f4639f9 commit 5d238df

5 files changed

Lines changed: 174 additions & 2 deletions

File tree

‎Directory.Build.props‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
<RepositoryUrl>https://github.com/managedcode/Communication</RepositoryUrl>
2828
<PackageProjectUrl>https://github.com/managedcode/Communication</PackageProjectUrl>
2929
<Product>Managed Code - Communication</Product>
30-
<Version>10.3.1</Version>
31-
<PackageVersion>10.3.1</PackageVersion>
30+
<Version>10.3.2</Version>
31+
<PackageVersion>10.3.2</PackageVersion>
3232

3333
</PropertyGroup>
3434
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System;
2+
using System.Text.Json;
3+
using Newtonsoft.Json;
4+
using Newtonsoft.Json.Serialization;
5+
6+
namespace ManagedCode.Communication.Orleans.Converters;
7+
8+
/// <summary>Preserves typed JSON DOM values in native Newtonsoft Orleans storage.</summary>
9+
public sealed class CommunicationJsonElementContractResolver : IContractResolver, IValueProvider
10+
{
11+
private const string JsonPropertyName = "json";
12+
private const string InvalidJsonElementMessage = "A stored JsonElement requires a valid JSON payload.";
13+
private readonly IContractResolver _inner;
14+
private readonly JsonObjectContract _elementContract;
15+
16+
/// <summary>Creates a resolver which preserves the configured contracts for all other types.</summary>
17+
public CommunicationJsonElementContractResolver(IContractResolver inner)
18+
{
19+
ArgumentNullException.ThrowIfNull(inner);
20+
_inner = inner;
21+
_elementContract = new JsonObjectContract(typeof(JsonElement))
22+
{
23+
OverrideCreator = CreateElement
24+
};
25+
_elementContract.Properties.Add(new Newtonsoft.Json.Serialization.JsonProperty
26+
{
27+
PropertyName = JsonPropertyName,
28+
PropertyType = typeof(string),
29+
Readable = true,
30+
Writable = false,
31+
ValueProvider = this,
32+
Required = Required.Always
33+
});
34+
_elementContract.CreatorParameters.Add(new Newtonsoft.Json.Serialization.JsonProperty
35+
{
36+
PropertyName = JsonPropertyName,
37+
PropertyType = typeof(string),
38+
Writable = true,
39+
Required = Required.Always
40+
});
41+
}
42+
43+
/// <inheritdoc />
44+
public JsonContract ResolveContract(Type type) => type == typeof(JsonElement)
45+
? _elementContract : _inner.ResolveContract(type);
46+
47+
/// <inheritdoc />
48+
object? IValueProvider.GetValue(object target) => System.Text.Json.JsonSerializer.Serialize((JsonElement)target);
49+
50+
/// <inheritdoc />
51+
void IValueProvider.SetValue(object target, object? value) => throw new NotSupportedException(InvalidJsonElementMessage);
52+
53+
private static object CreateElement(object?[] arguments)
54+
{
55+
if (arguments is not [string json])
56+
{
57+
throw new JsonSerializationException(InvalidJsonElementMessage);
58+
}
59+
try
60+
{
61+
return System.Text.Json.JsonSerializer.Deserialize<JsonElement>(json);
62+
}
63+
catch (System.Text.Json.JsonException exception)
64+
{
65+
throw new JsonSerializationException(InvalidJsonElementMessage, exception);
66+
}
67+
}
68+
}

‎ManagedCode.Communication.Orleans/Extensions/OrleansJsonStorageExtensions.cs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
using System;
22
using System.Linq;
3+
using System.Text.Json;
34
using ManagedCode.Communication.Orleans.Converters;
45
using Microsoft.Extensions.DependencyInjection;
6+
using Newtonsoft.Json.Serialization;
57
using Orleans.Serialization;
8+
using Orleans.Serialization.Configuration;
69

710
namespace ManagedCode.Communication.Orleans.Extensions;
811

@@ -13,8 +16,14 @@ public static class OrleansJsonStorageExtensions
1316
public static IServiceCollection AddCommunicationOrleansJsonStorage(this IServiceCollection services)
1417
{
1518
ArgumentNullException.ThrowIfNull(services);
19+
services.Configure<TypeManifestOptions>(options => options.AddAllowedType(typeof(JsonElement)));
1620
services.Configure<OrleansJsonSerializerOptions>(options =>
1721
{
22+
if (options.JsonSerializerSettings.ContractResolver is not CommunicationJsonElementContractResolver)
23+
{
24+
options.JsonSerializerSettings.ContractResolver = new CommunicationJsonElementContractResolver(
25+
options.JsonSerializerSettings.ContractResolver ?? new DefaultContractResolver());
26+
}
1827
if (!options.JsonSerializerSettings.Converters.OfType<CommunicationResultJsonConverter>().Any())
1928
{
2029
options.JsonSerializerSettings.Converters.Insert(0, new CommunicationResultJsonConverter());

‎ManagedCode.Communication.Tests/Orleans/OrleansJsonStorageSerializationTests.cs‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Linq;
34
using System.Net;
5+
using System.Security.Cryptography;
46
using System.Text;
7+
using System.Text.Json;
58
using ManagedCode.Communication.Orleans.Converters;
69
using ManagedCode.Communication.Orleans.Extensions;
710
using ManagedCode.Communication.Orleans.Surrogates;
811
using ManagedCode.Communication.Tests.Orleans.Fixtures;
912
using Microsoft.Extensions.DependencyInjection;
1013
using Microsoft.Extensions.Options;
1114
using Newtonsoft.Json;
15+
using Newtonsoft.Json.Serialization;
1216
using Orleans.Serialization;
1317
using Orleans.Storage;
1418
using Orleans.TestingHost;
@@ -114,6 +118,83 @@ public void StandaloneRegistrationMustBeIdempotentAndPreserveNativeJson()
114118
serializer.Deserialize<Result<int>>(serializer.Serialize(Result<int>.Succeed(42))).IsSuccess.ShouldBeTrue();
115119
}
116120

121+
private const string NestedJson = "{\"value\":[17,{\"label\":\"雪\",\"decimal\":1.00}],\"enabled\":true}";
122+
private const string JsonString = "\"text\"";
123+
private const string JsonNumber = "1.00";
124+
private const string JsonNull = "null";
125+
private const string JsonBoolean = "true";
126+
private const string ElementKey = "element";
127+
private const string InvalidElementJson = "{\"json\":\"{\"}";
128+
private const string MissingElementJson = "{}";
129+
private const string NullElementJson = "{\"json\":null}";
130+
131+
[Test]
132+
[Arguments(NestedJson)]
133+
[Arguments(JsonString)]
134+
[Arguments(JsonNumber)]
135+
[Arguments(JsonNull)]
136+
[Arguments(JsonBoolean)]
137+
public void JsonElementsMustRetainTypedAndObjectValuedStorageFingerprints(string json)
138+
{
139+
System.Text.Json.JsonElement expected;
140+
using (var document = JsonDocument.Parse(json))
141+
{
142+
expected = document.RootElement.Clone();
143+
}
144+
var payload = new JsonElementStoredState
145+
{
146+
Element = expected,
147+
Values = new Dictionary<string, object?> { [ElementKey] = expected },
148+
Update = Result<object>.Succeed(expected)
149+
};
150+
var serializer = Serializer();
151+
var restored = serializer.Deserialize<JsonElementStoredState>(serializer.Serialize(payload))!;
152+
var dictionaryValue = restored.Values[ElementKey].ShouldBeOfType<JsonElement>();
153+
var resultValue = restored.Update.Value.ShouldBeOfType<JsonElement>();
154+
restored.Update.IsSuccess.ShouldBeTrue();
155+
foreach (var actual in new[] { restored.Element, dictionaryValue, resultValue })
156+
{
157+
actual.ValueKind.ShouldBe(expected.ValueKind);
158+
Fingerprint(actual).ShouldBe(Fingerprint(expected));
159+
actual.GetRawText().ShouldBe(System.Text.Json.JsonSerializer.Serialize(expected));
160+
}
161+
var result = Result<JsonElementStoredState>.Succeed(payload);
162+
var restoredResult = serializer.Deserialize<Result<JsonElementStoredState>>(serializer.Serialize(result));
163+
restoredResult.IsSuccess.ShouldBeTrue();
164+
Fingerprint(restoredResult.Value.Element).ShouldBe(Fingerprint(expected));
165+
}
166+
167+
[Test]
168+
[Arguments(InvalidElementJson)]
169+
[Arguments(MissingElementJson)]
170+
[Arguments(NullElementJson)]
171+
public void MalformedStoredJsonElementsMustFailClosed(string json) =>
172+
Should.Throw<JsonSerializationException>(() => Serializer().Deserialize<JsonElement>(new BinaryData(json)));
173+
174+
[Test]
175+
public void JsonElementRegistrationMustPreserveConfiguredContractsAndRemainIdempotent()
176+
{
177+
var configured = new CamelCasePropertyNamesContractResolver();
178+
var services = new ServiceCollection();
179+
services.Configure<OrleansJsonSerializerOptions>(options => options.JsonSerializerSettings.ContractResolver = configured);
180+
using var provider = services.AddCommunicationOrleansJsonStorage().AddCommunicationOrleansJsonStorage().BuildServiceProvider();
181+
var settings = provider.GetRequiredService<IOptions<OrleansJsonSerializerOptions>>().Value;
182+
var resolver = settings.JsonSerializerSettings.ContractResolver.ShouldBeOfType<CommunicationJsonElementContractResolver>();
183+
resolver.ResolveContract(typeof(StoredState)).ShouldBeSameAs(configured.ResolveContract(typeof(StoredState)));
184+
settings.JsonSerializerSettings.Converters.OfType<CommunicationResultJsonConverter>().Count().ShouldBe(1);
185+
}
186+
187+
private static string Fingerprint(JsonElement element) => Convert.ToHexString(
188+
SHA256.HashData(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(element)));
189+
190+
[global::Orleans.GenerateSerializer]
191+
internal sealed class JsonElementStoredState
192+
{
193+
[global::Orleans.Id(0)] public JsonElement Element { get; init; }
194+
[global::Orleans.Id(1)] public Dictionary<string, object?> Values { get; init; } = new();
195+
[global::Orleans.Id(2)] public Result<object> Update { get; init; }
196+
}
197+
117198
[global::Orleans.GenerateSerializer]
118199
internal sealed class StoredState
119200
{

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,20 @@ is accepted when default-value suppression omitted `IsSuccess: false`; missing,
583583
flags are rejected rather than inventing an operation outcome. Typed payloads retain
584584
the native Orleans serializer and its type allow-list. Deploy the adapter to every host that reads this JSON state.
585585

586+
The registration also preserves typed `System.Text.Json.JsonElement` values in native Newtonsoft storage,
587+
including elements boxed as `object` in dictionaries and Result payloads. It uses the configured
588+
Newtonsoft type metadata plus a JSON payload reconstructed by System.Text.Json; other types keep
589+
the configured contract resolver. Missing or malformed stored JSON fails closed. Previously stored
590+
Undefined elements cannot recover their lost content. Register the adapter on every reader and writer.
591+
592+
```csharp
593+
using var document = JsonDocument.Parse("{\"count\":3}");
594+
var result = Result<object>.Succeed(document.RootElement.Clone());
595+
var restored = serializer.Deserialize<Result<object>>(serializer.Serialize(result));
596+
var count = ((JsonElement)restored.Value!).GetProperty("count").GetInt32(); // 3
597+
```
598+
599+
586600
`UseOrleansCommunication()` registers only serializers and grain-call filters. Enable Orleans-backed command idempotency and
587601
cluster-wide rate limiting explicitly with `UseOrleansCommandExecution()`. The silo must configure grain storage named
588602
`commandStore`; `ManagedCode.Orleans.RateLimiting` continues to own the distributed algorithms and durable leases. Identity,

0 commit comments

Comments
 (0)