Skip to content

Commit 42d84c1

Browse files
fix: remove reflection-based JSON serialization from policy libraries (#81)
1 parent 119f52b commit 42d84c1

19 files changed

Lines changed: 190 additions & 59 deletions

.gitattributes

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/.gitattributes text eol=lf
2+
*.cs text eol=lf
3+
*.csproj text eol=lf

policies/dotnet/Devolutions.Now.Policy.Api/BrokerJson.cs

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System.Text.Json;
2+
using System.Text.Json.Nodes;
23
using System.Text.Json.Serialization;
4+
using System.Text.Json.Serialization.Metadata;
35

46
namespace Devolutions.Now.Policy.Api;
57

@@ -17,14 +19,79 @@ public static class BrokerJson
1719
/// (via explicit <c>[JsonPropertyName]</c> attributes), PascalCase enum values, and
1820
/// null optionals omitted (mirroring the Rust <c>skip_serializing_if = "Option::is_none"</c>).
1921
/// </summary>
20-
public static readonly JsonSerializerOptions Options = new()
22+
public static readonly JsonSerializerOptions Options = new(BrokerJsonSerializerContext.Default.Options)
2123
{
22-
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
23-
WriteIndented = false,
2424
};
2525

2626
public static readonly JsonSerializerOptions PrettyOptions = new(Options)
2727
{
2828
WriteIndented = true,
2929
};
30-
}
30+
31+
public static string Serialize<T>(T value) =>
32+
JsonSerializer.Serialize(value, TypeInfo<T>());
33+
34+
public static T? Deserialize<T>(string json) =>
35+
JsonSerializer.Deserialize(json, TypeInfo<T>());
36+
37+
public static T? DeserializeStrict<T>(string json) =>
38+
JsonSerializer.Deserialize(json, StrictTypeInfo<T>());
39+
40+
private static JsonTypeInfo<T> TypeInfo<T>() =>
41+
typeof(T) == typeof(PackageRequest) ? Cast<T>(BrokerJsonSerializerContext.Default.PackageRequest) :
42+
typeof(T) == typeof(StatusRequest) ? Cast<T>(BrokerJsonSerializerContext.Default.StatusRequest) :
43+
typeof(T) == typeof(HealthResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.HealthResponse) :
44+
typeof(T) == typeof(CapabilitiesResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.CapabilitiesResponse) :
45+
typeof(T) == typeof(EvaluationResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.EvaluationResponse) :
46+
typeof(T) == typeof(ExecutionResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.ExecutionResponse) :
47+
typeof(T) == typeof(StatusResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.StatusResponse) :
48+
typeof(T) == typeof(ErrorResponse) ? Cast<T>(BrokerJsonSerializerContext.Default.ErrorResponse) :
49+
throw new NotSupportedException($"Broker JSON serialization for {typeof(T).FullName} is not source-generated.");
50+
51+
private static JsonTypeInfo<T> StrictTypeInfo<T>() =>
52+
typeof(T) == typeof(PackageRequest) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.PackageRequest) :
53+
typeof(T) == typeof(StatusRequest) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.StatusRequest) :
54+
typeof(T) == typeof(HealthResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.HealthResponse) :
55+
typeof(T) == typeof(CapabilitiesResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.CapabilitiesResponse) :
56+
typeof(T) == typeof(EvaluationResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.EvaluationResponse) :
57+
typeof(T) == typeof(ExecutionResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.ExecutionResponse) :
58+
typeof(T) == typeof(StatusResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.StatusResponse) :
59+
typeof(T) == typeof(ErrorResponse) ? Cast<T>(BrokerJsonStrictSerializerContext.Default.ErrorResponse) :
60+
throw new NotSupportedException($"Strict broker JSON deserialization for {typeof(T).FullName} is not source-generated.");
61+
62+
private static JsonTypeInfo<T> Cast<T>(JsonTypeInfo jsonTypeInfo) =>
63+
(JsonTypeInfo<T>)jsonTypeInfo;
64+
}
65+
66+
[JsonSourceGenerationOptions(
67+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
68+
WriteIndented = false)]
69+
[JsonSerializable(typeof(PackageRequest))]
70+
[JsonSerializable(typeof(StatusRequest))]
71+
[JsonSerializable(typeof(HealthResponse))]
72+
[JsonSerializable(typeof(CapabilitiesResponse))]
73+
[JsonSerializable(typeof(EvaluationResponse))]
74+
[JsonSerializable(typeof(ExecutionResponse))]
75+
[JsonSerializable(typeof(StatusResponse))]
76+
[JsonSerializable(typeof(ErrorResponse))]
77+
[JsonSerializable(typeof(JsonNode))]
78+
[JsonSerializable(typeof(JsonObject))]
79+
[JsonSerializable(typeof(JsonArray))]
80+
internal sealed partial class BrokerJsonSerializerContext : JsonSerializerContext;
81+
82+
[JsonSourceGenerationOptions(
83+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
84+
WriteIndented = false,
85+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
86+
[JsonSerializable(typeof(PackageRequest))]
87+
[JsonSerializable(typeof(StatusRequest))]
88+
[JsonSerializable(typeof(HealthResponse))]
89+
[JsonSerializable(typeof(CapabilitiesResponse))]
90+
[JsonSerializable(typeof(EvaluationResponse))]
91+
[JsonSerializable(typeof(ExecutionResponse))]
92+
[JsonSerializable(typeof(StatusResponse))]
93+
[JsonSerializable(typeof(ErrorResponse))]
94+
[JsonSerializable(typeof(JsonNode))]
95+
[JsonSerializable(typeof(JsonObject))]
96+
[JsonSerializable(typeof(JsonArray))]
97+
internal sealed partial class BrokerJsonStrictSerializerContext : JsonSerializerContext;

policies/dotnet/Devolutions.Now.Policy.Api/Devolutions.Now.Policy.Api.csproj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
<RootNamespace>Devolutions.Now.Policy.Api</RootNamespace>
88
<AssemblyName>Devolutions.Now.Policy.Api</AssemblyName>
99
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
10+
<IsAotCompatible>true</IsAotCompatible>
11+
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
1012
</PropertyGroup>
1113

1214
<PropertyGroup>
@@ -30,4 +32,4 @@
3032
<None Include="README.md" Pack="true" PackagePath="\" />
3133
</ItemGroup>
3234

33-
</Project>
35+
</Project>

policies/dotnet/Devolutions.Now.Policy.Api/MetaModels.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ public sealed class HealthResponse
88
private const string Kind = BrokerApi.HealthResponseKind;
99
private string _responseKind = Kind;
1010

11-
[JsonInclude]
1211
[JsonPropertyName("ResponseKind")]
1312
[JsonRequired]
1413
public string ResponseKind
1514
{
1615
get => _responseKind;
17-
private set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
16+
set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
1817
}
1918

2019
[JsonPropertyName("ResponseVersion")]
@@ -37,13 +36,12 @@ public sealed class CapabilitiesResponse
3736
private const string Kind = BrokerApi.CapabilitiesResponseKind;
3837
private string _responseKind = Kind;
3938

40-
[JsonInclude]
4139
[JsonPropertyName("ResponseKind")]
4240
[JsonRequired]
4341
public string ResponseKind
4442
{
4543
get => _responseKind;
46-
private set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
44+
set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
4745
}
4846

4947
[JsonPropertyName("ResponseVersion")]
@@ -98,13 +96,12 @@ public sealed class ErrorResponse
9896
private const string Kind = BrokerApi.ErrorResponseKind;
9997
private string _responseKind = Kind;
10098

101-
[JsonInclude]
10299
[JsonPropertyName("ResponseKind")]
103100
[JsonRequired]
104101
public string ResponseKind
105102
{
106103
get => _responseKind;
107-
private set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
104+
set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
108105
}
109106

110107
[JsonPropertyName("ResponseVersion")]

policies/dotnet/Devolutions.Now.Policy.Api/RequestModels.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ public sealed class PackageRequest
88
private const string Kind = BrokerApi.PackageRequestKind;
99
private string _requestKind = Kind;
1010

11-
[JsonInclude]
1211
[JsonPropertyName("RequestKind")]
1312
[JsonRequired]
1413
public string RequestKind
1514
{
1615
get => _requestKind;
17-
private set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind));
16+
set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind));
1817
}
1918

2019
[JsonPropertyName("RequestVersion")]

policies/dotnet/Devolutions.Now.Policy.Api/ResponseModels.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ public sealed class EvaluationResponse
88
private const string Kind = BrokerApi.EvaluationResponseKind;
99
private string _responseKind = Kind;
1010

11-
[JsonInclude]
1211
[JsonPropertyName("ResponseKind")]
1312
[JsonRequired]
1413
public string ResponseKind
1514
{
1615
get => _responseKind;
17-
private set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
16+
set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
1817
}
1918

2019
[JsonPropertyName("ResponseVersion")]
@@ -54,13 +53,12 @@ public sealed class ExecutionResponse
5453
private const string Kind = BrokerApi.ExecutionResponseKind;
5554
private string _responseKind = Kind;
5655

57-
[JsonInclude]
5856
[JsonPropertyName("ResponseKind")]
5957
[JsonRequired]
6058
public string ResponseKind
6159
{
6260
get => _responseKind;
63-
private set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
61+
set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
6462
}
6563

6664
[JsonPropertyName("ResponseVersion")]

policies/dotnet/Devolutions.Now.Policy.Api/StatusModels.cs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@ public sealed class StatusRequest
99
private const string Kind = BrokerApi.StatusRequestKind;
1010
private string _requestKind = Kind;
1111

12-
[JsonInclude]
1312
[JsonPropertyName("RequestKind")]
1413
[JsonRequired]
1514
public string RequestKind
1615
{
1716
get => _requestKind;
18-
private set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind));
17+
set => _requestKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(RequestKind));
1918
}
2019

2120
[JsonPropertyName("RequestVersion")]
@@ -34,13 +33,12 @@ public sealed class StatusResponse
3433
private const string Kind = BrokerApi.StatusResponseKind;
3534
private string _responseKind = Kind;
3635

37-
[JsonInclude]
3836
[JsonPropertyName("ResponseKind")]
3937
[JsonRequired]
4038
public string ResponseKind
4139
{
4240
get => _responseKind;
43-
private set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
41+
set => _responseKind = BrokerApi.ValidateMessageKind(value, Kind, nameof(ResponseKind));
4442
}
4543

4644
[JsonPropertyName("ResponseVersion")]

policies/dotnet/Devolutions.Now.Policy.Client.Tests/BrokerClientTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ public void DefaultPipeName_uses_api_constant()
1818
Assert.Equal(BrokerApi.DefaultPipeName, BrokerClient.DefaultPipeName);
1919
}
2020

21+
[Fact]
22+
public void Tests_run_with_reflection_json_disabled()
23+
{
24+
Assert.False(JsonSerializer.IsReflectionEnabledByDefault);
25+
}
26+
2127
[Fact]
2228
public async Task Evaluate_populates_client_context_and_missing_metadata_before_sending()
2329
{

policies/dotnet/Devolutions.Now.Policy.Client.Tests/Devolutions.Now.Policy.Client.Tests.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<ImplicitUsings>enable</ImplicitUsings>
77
<IsPackable>false</IsPackable>
88
<RootNamespace>Devolutions.Now.Policy.Client.Tests</RootNamespace>
9+
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
910
</PropertyGroup>
1011

1112
<ItemGroup>
@@ -22,4 +23,4 @@
2223
<ProjectReference Include="..\Devolutions.Now.Policy.Client\Devolutions.Now.Policy.Client.csproj" />
2324
</ItemGroup>
2425

25-
</Project>
26+
</Project>

policies/dotnet/Devolutions.Now.Policy.Client.Tests/DtoRoundTripTests.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
using System.Text.Json;
2-
31
using NJsonSchema;
42

53
using Xunit;
@@ -9,8 +7,8 @@ namespace Devolutions.Now.Policy.Client.Tests;
97
/// <summary>
108
/// Parity tests: the hand-written C# DTOs must consume the exact sample documents the
119
/// Rust crate uses, and re-serialize to output that still validates against the same
12-
/// schemas. Uses <see cref="TestData.Strict"/> so a sample field missing from a DTO fails
13-
/// the test (DTO completeness), mirroring the Rust `deny_unknown_fields` contract.
10+
/// schemas. Uses source-generated strict metadata so a sample field missing from a DTO
11+
/// fails the test (DTO completeness), mirroring the Rust `deny_unknown_fields` contract.
1412
/// </summary>
1513
public class DtoRoundTripTests
1614
{
@@ -54,11 +52,11 @@ private static async Task AssertRoundTrip<T>(string samplePath, JsonSchema schem
5452
var original = await File.ReadAllTextAsync(samplePath);
5553

5654
// 1. Deserialize the canonical sample into the DTO (strict: every field must map).
57-
var dto = JsonSerializer.Deserialize<T>(original, TestData.Strict);
55+
var dto = BrokerJson.DeserializeStrict<T>(original);
5856
Assert.NotNull(dto);
5957

6058
// 2. Re-serialize and validate the output against the same schema.
61-
var reserialized = JsonSerializer.Serialize(dto, BrokerJson.Options);
59+
var reserialized = BrokerJson.Serialize(dto);
6260
var errors = schema.Validate(reserialized);
6361

6462
Assert.True(

0 commit comments

Comments
 (0)