Skip to content

Commit 326670d

Browse files
committed
Make MsappSerialization public and immutable Json options
- fix DefaultIgnoreCondition so 'required' properties with default values (e.g. bool=false) are properly round-tripped without being omitted from the serialized JSON
1 parent 210dc99 commit 326670d

7 files changed

Lines changed: 289 additions & 22 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text.Json;
5+
using Microsoft.PowerPlatform.PowerApps.Persistence.MsApp.Models;
6+
using Microsoft.PowerPlatform.PowerApps.Persistence.MsApp.Serialization;
7+
8+
namespace Persistence.Tests.MsApp.Serialization;
9+
10+
[TestClass]
11+
public class MsappSerializationTests : TestBase
12+
{
13+
private static readonly JsonSerializerOptions Options = MsappSerialization.PackedJsonSerializeOptions;
14+
15+
/// <summary>
16+
/// Verifies that a PackedJson with LoadFromYaml=true survives a serialize/deserialize round-trip.
17+
/// </summary>
18+
[TestMethod]
19+
public void PackedJson_RoundTrip_LoadFromYaml_True()
20+
{
21+
var original = new PackedJson
22+
{
23+
PackedStructureVersion = PackedJson.CurrentPackedStructureVersion,
24+
LoadConfiguration = new() { LoadFromYaml = true },
25+
};
26+
27+
var json = JsonSerializer.Serialize(original, Options);
28+
var deserialized = JsonSerializer.Deserialize<PackedJson>(json, Options);
29+
30+
deserialized.Should().NotBeNull();
31+
deserialized!.LoadConfiguration.LoadFromYaml.Should().BeTrue();
32+
deserialized.PackedStructureVersion.Should().Be(PackedJson.CurrentPackedStructureVersion);
33+
}
34+
35+
/// <summary>
36+
/// Verifies that a PackedJson with LoadFromYaml=false survives a serialize/deserialize round-trip.
37+
/// With WhenWritingDefault, the 'false' value is the default for bool and will be omitted during
38+
/// serialization. Because LoadFromYaml is marked 'required', deserialization then fails unless
39+
/// the serializer options are corrected.
40+
/// </summary>
41+
[TestMethod]
42+
public void PackedJson_RoundTrip_LoadFromYaml_False()
43+
{
44+
var original = new PackedJson
45+
{
46+
PackedStructureVersion = PackedJson.CurrentPackedStructureVersion,
47+
LoadConfiguration = new() { LoadFromYaml = false },
48+
};
49+
50+
var json = JsonSerializer.Serialize(original, Options);
51+
52+
// The serialized JSON must contain the LoadFromYaml property, even when false,
53+
// because it is a 'required' property on deserialization.
54+
json.Should().Contain("LoadFromYaml", "required bool properties must not be omitted when their value is the default (false)");
55+
56+
var deserialized = JsonSerializer.Deserialize<PackedJson>(json, Options);
57+
58+
deserialized.Should().NotBeNull();
59+
deserialized!.LoadConfiguration.LoadFromYaml.Should().BeFalse();
60+
deserialized.PackedStructureVersion.Should().Be(PackedJson.CurrentPackedStructureVersion);
61+
}
62+
63+
/// <summary>
64+
/// Round-trips a fully-populated PackedJson (all optional fields set) to ensure nothing is lost.
65+
/// </summary>
66+
[TestMethod]
67+
public void PackedJson_RoundTrip_FullyPopulated()
68+
{
69+
var utcNow = new DateTime(2024, 6, 15, 12, 0, 0, DateTimeKind.Utc);
70+
var original = new PackedJson
71+
{
72+
PackedStructureVersion = PackedJson.CurrentPackedStructureVersion,
73+
LastPackedDateTimeUtc = utcNow,
74+
PackingClient = new PackedJsonPackingClient
75+
{
76+
Name = "TestClient",
77+
Version = "1.2.3",
78+
},
79+
LoadConfiguration = new() { LoadFromYaml = true },
80+
};
81+
82+
var json = JsonSerializer.Serialize(original, Options);
83+
var deserialized = JsonSerializer.Deserialize<PackedJson>(json, Options);
84+
85+
deserialized.Should().NotBeNull();
86+
deserialized!.PackedStructureVersion.Should().Be(PackedJson.CurrentPackedStructureVersion);
87+
deserialized.LastPackedDateTimeUtc.Should().Be(utcNow);
88+
deserialized.PackingClient.Should().NotBeNull();
89+
deserialized.PackingClient!.Name.Should().Be("TestClient");
90+
deserialized.PackingClient.Version.Should().Be("1.2.3");
91+
deserialized.LoadConfiguration.LoadFromYaml.Should().BeTrue();
92+
}
93+
94+
/// <summary>
95+
/// Verifies that a PackedJson with LoadFromYaml=false and a PackingClient survives round-trip.
96+
/// </summary>
97+
[TestMethod]
98+
public void PackedJson_RoundTrip_LoadFromYaml_False_WithPackingClient()
99+
{
100+
var original = new PackedJson
101+
{
102+
PackedStructureVersion = PackedJson.CurrentPackedStructureVersion,
103+
PackingClient = new PackedJsonPackingClient { Name = "MyCli", Version = "0.0.1" },
104+
LoadConfiguration = new() { LoadFromYaml = false },
105+
};
106+
107+
var json = JsonSerializer.Serialize(original, Options);
108+
json.Should().Contain("LoadFromYaml", "required bool must be present in JSON even when false");
109+
110+
var deserialized = JsonSerializer.Deserialize<PackedJson>(json, Options);
111+
deserialized.Should().NotBeNull();
112+
deserialized!.LoadConfiguration.LoadFromYaml.Should().BeFalse();
113+
deserialized.PackingClient!.Name.Should().Be("MyCli");
114+
}
115+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Collections.Immutable;
5+
using System.Text.Json;
6+
using Microsoft.PowerPlatform.PowerApps.Persistence.MsappPacking.Models;
7+
using Microsoft.PowerPlatform.PowerApps.Persistence.MsappPacking.Serialization;
8+
9+
namespace Persistence.Tests.MsappPacking.Serialization;
10+
11+
[TestClass]
12+
public class MsaprSerializationTests : TestBase
13+
{
14+
/// <summary>
15+
/// Verifies that a minimal MsaprHeaderJson survives a serialize/deserialize round-trip.
16+
/// </summary>
17+
[TestMethod]
18+
public void MsaprHeaderJson_RoundTrip_Minimal()
19+
{
20+
var original = new MsaprHeaderJson
21+
{
22+
MsaprStructureVersion = MsaprHeaderJson.CurrentMsaprStructureVersion,
23+
UnpackedConfiguration = new()
24+
{
25+
ContentTypes = [],
26+
},
27+
};
28+
29+
var json = JsonSerializer.Serialize(original, MsaprSerialization.DefaultJsonSerializeOptions);
30+
var deserialized = JsonSerializer.Deserialize<MsaprHeaderJson>(json, MsaprSerialization.DefaultJsonSerializeOptions);
31+
32+
deserialized.Should().NotBeNull();
33+
deserialized!.MsaprStructureVersion.Should().Be(MsaprHeaderJson.CurrentMsaprStructureVersion);
34+
deserialized.UnpackedConfiguration.Should().NotBeNull();
35+
deserialized.UnpackedConfiguration.ContentTypes.Should().BeEmpty();
36+
}
37+
38+
/// <summary>
39+
/// Verifies that a fully-populated MsaprHeaderJson (all fields set) survives a serialize/deserialize round-trip.
40+
/// </summary>
41+
[TestMethod]
42+
public void MsaprHeaderJson_RoundTrip_FullyPopulated()
43+
{
44+
var original = new MsaprHeaderJson
45+
{
46+
MsaprStructureVersion = MsaprHeaderJson.CurrentMsaprStructureVersion,
47+
UnpackedConfiguration = new()
48+
{
49+
ContentTypes = ["Yaml", "Assets"],
50+
},
51+
};
52+
53+
var json = JsonSerializer.Serialize(original, MsaprSerialization.DefaultJsonSerializeOptions);
54+
var deserialized = JsonSerializer.Deserialize<MsaprHeaderJson>(json, MsaprSerialization.DefaultJsonSerializeOptions);
55+
56+
deserialized.Should().NotBeNull();
57+
deserialized!.MsaprStructureVersion.Should().Be(MsaprHeaderJson.CurrentMsaprStructureVersion);
58+
deserialized.UnpackedConfiguration.ContentTypes.Should().BeEquivalentTo(["Yaml", "Assets"]);
59+
}
60+
61+
/// <summary>
62+
/// Verifies that unknown/additional properties in the JSON are ignored (forward-compatible deserialization).
63+
/// </summary>
64+
[TestMethod]
65+
public void MsaprHeaderJson_RoundTrip_IgnoresUnknownProperties()
66+
{
67+
var jsonWithExtraFields = """
68+
{
69+
"MsaprStructureVersion": "0.1",
70+
"UnpackedConfiguration": {
71+
"ContentTypes": ["Yaml"],
72+
"FutureProperty": "some value"
73+
},
74+
"AnotherFutureTopLevelProperty": 42
75+
}
76+
""";
77+
78+
var deserialized = JsonSerializer.Deserialize<MsaprHeaderJson>(jsonWithExtraFields, MsaprSerialization.DefaultJsonSerializeOptions);
79+
80+
deserialized.Should().NotBeNull();
81+
deserialized!.MsaprStructureVersion.Should().Be(new Version(0, 1));
82+
deserialized.UnpackedConfiguration.ContentTypes.Should().BeEquivalentTo(["Yaml"]);
83+
84+
// And we should see the unknown properties still captured:
85+
deserialized.AdditionalProperties.Should().NotBeNull()
86+
.And.Subject.Keys.Should().BeEquivalentTo(["AnotherFutureTopLevelProperty"]);
87+
deserialized.UnpackedConfiguration.AdditionalProperties.Should().NotBeNull()
88+
.And.Subject.Keys.Should().BeEquivalentTo(["FutureProperty"]);
89+
90+
// Re-serializing should produce JSON node-equivalent to the original input.
91+
var reserialized = JsonSerializer.Serialize(deserialized, MsaprSerialization.DefaultJsonSerializeOptions);
92+
JsonShouldBeEquivalentTo(reserialized, jsonWithExtraFields);
93+
}
94+
}

src/Persistence.Tests/TestBase.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
using System.Globalization;
54
using Microsoft.Extensions.DependencyInjection;
65
using Microsoft.PowerPlatform.PowerApps.Persistence.MsApp;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Globalization;
8+
using System.Text.Json;
9+
using System.Text.Json.Nodes;
710

811
namespace Persistence.Tests;
912

@@ -12,4 +15,24 @@ public abstract class TestBase : VSTestBase
1215
protected TestBase()
1316
{
1417
}
18+
19+
/// <summary>
20+
/// Asserts that two JSON strings are structurally equivalent by comparing their <see cref="JsonNode"/> representations.
21+
/// </summary>
22+
protected static void JsonShouldBeEquivalentTo(string actualJson, string expectedJson)
23+
{
24+
// While we're detecting equality correct here, the failure message isn't particularly useful, but can be improved in the future.
25+
JsonNode.DeepEquals(JsonNode.Parse(actualJson), JsonNode.Parse(expectedJson))
26+
.Should().BeTrue($"actual JSON should be node-equivalent to expected JSON.\nActual:\n{actualJson}\nExpected:\n{expectedJson}");
27+
}
28+
29+
/// <summary>
30+
/// Utility to create a <see cref="JsonElement"/> from a JSON string, which can be useful for constructing test inputs for models that use <see cref="JsonElement"/> properties.
31+
/// </summary>
32+
public static JsonElement ToJsonElement([StringSyntax(StringSyntaxAttribute.Json)] string json)
33+
{
34+
using var doc = JsonDocument.Parse(json);
35+
// We need to Clone so the element outlives 'doc' being disposed
36+
return doc.RootElement.Clone();
37+
}
1538
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Text.Json;
5+
6+
namespace Microsoft.PowerPlatform.PowerApps.Persistence.Extensions;
7+
8+
public static class JsonExtensions
9+
{
10+
/// <summary>
11+
/// A fluent way of making a <see cref="JsonSerializerOptions"/> instance immutable.
12+
/// Especially useful for shared static instances.
13+
/// </summary>
14+
public static JsonSerializerOptions ToReadOnly(this JsonSerializerOptions options)
15+
{
16+
options.MakeReadOnly(populateMissingResolver: true);
17+
return options;
18+
}
19+
}

src/Persistence/MsApp/Serialization/MsappSerialization.cs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ namespace Microsoft.PowerPlatform.PowerApps.Persistence.MsApp.Serialization;
1717
/// <summary>
1818
/// Shared constants used for .msapp serialization and deserialization.
1919
/// </summary>
20-
internal static class MsappSerialization
20+
public static class MsappSerialization
2121
{
2222
/// <summary>
2323
/// This should match the options used in DocumentServer for deserializing msapp json files.
2424
/// See: JsonDocumentSerializer.SerializerOptions in DocumentServer.Core.
2525
/// </summary>
26-
private static readonly JsonSerializerOptions DefaultSharedJsonSerializeOptions = new()
26+
private static readonly JsonSerializerOptions DefaultSharedJsonSerializeOptions = new JsonSerializerOptions()
2727
{
2828
PropertyNameCaseInsensitive = true,
2929
AllowTrailingCommas = true,
@@ -38,32 +38,43 @@ internal static class MsappSerialization
3838
// But this may have impact on other code which depends on this property.
3939
new JsonDateTimeAssumesUtcConverter(),
4040
},
41-
};
41+
}.ToReadOnly();
4242

43-
public static readonly JsonSerializerOptions DocumentJsonSerializeOptions = new(DefaultSharedJsonSerializeOptions);
43+
internal static readonly JsonSerializerOptions DocumentJsonSerializeOptions = new JsonSerializerOptions(DefaultSharedJsonSerializeOptions)
44+
.ToReadOnly();
4445

4546
/// <summary>
4647
/// This should match the options used in DocumentServer for deserializing msapp json files.
4748
/// See: JsonDocumentSerializer.SerializerOptions in DocumentServer.Core.
4849
/// </summary>
49-
public readonly static JsonSerializerOptions HeaderJsonSerializeOptions = new(DefaultSharedJsonSerializeOptions)
50+
public readonly static JsonSerializerOptions HeaderJsonSerializeOptions = new JsonSerializerOptions(DefaultSharedJsonSerializeOptions)
5051
{
52+
// Note: The docsvr doesn't indent the Header.json file.
5153
WriteIndented = false,
52-
};
54+
}.ToReadOnly();
5355

5456
/// <summary>
5557
/// Serialization options used for the 'packed.json' file in the msapp archive.
5658
/// </summary>
57-
public static readonly JsonSerializerOptions PackedJsonSerializeOptions = new()
59+
public static readonly JsonSerializerOptions PackedJsonSerializeOptions = new JsonSerializerOptions()
5860
{
5961
// Note: We explicitly don't derive from the default, since this is a net-new file which is fully owned by this library.
60-
PropertyNameCaseInsensitive = true,
61-
AllowTrailingCommas = true,
62-
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
63-
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
6462
Converters =
6563
{
6664
new JsonDateTimeUtcConverter(),
6765
},
68-
};
66+
67+
// Use WhenWritingNull so that non-nullable value types (e.g. bool) are always written,
68+
// which is required for round-tripping 'required' properties whose value equals the type default (e.g. LoadFromYaml=false).
69+
// WhenWritingDefault would silently omit those properties, causing deserialization failures.
70+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
71+
WriteIndented = true,
72+
73+
// Deserialization only options:
74+
PropertyNameCaseInsensitive = true,
75+
AllowTrailingCommas = true,
76+
// In order to ensure forward-compatible deserialization, we ignore unknown members
77+
// Any object model that wants to also survive round-tripping, must use JsonExtensionData to capture those unknown members.
78+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
79+
}.ToReadOnly();
6980
}

src/Persistence/MsappPacking/Models/MsaprHeaderJson.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public sealed record MsaprHeaderJson
2525
/// In order to support forward-compatible deserialization, we alllow arbitrary additional properties.
2626
/// </summary>
2727
[JsonExtensionData]
28-
public ImmutableDictionary<string, JsonElement>? AdditionalProperties { get; init; }
28+
public IDictionary<string, JsonElement>? AdditionalProperties { get; init; }
2929
}
3030

3131
public sealed record MsaprHeaderJsonUnpackedConfiguration
@@ -39,5 +39,5 @@ public sealed record MsaprHeaderJsonUnpackedConfiguration
3939
/// In order to support forward-compatible deserialization, we alllow arbitrary additional properties.
4040
/// </summary>
4141
[JsonExtensionData]
42-
public ImmutableDictionary<string, JsonElement>? AdditionalProperties { get; init; }
42+
public IDictionary<string, JsonElement>? AdditionalProperties { get; init; }
4343
}

src/Persistence/MsappPacking/Serialization/MsaprSerialization.cs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,27 @@ namespace Microsoft.PowerPlatform.PowerApps.Persistence.MsappPacking.Serializati
1717
/// <summary>
1818
/// Shared constants used for .msapr serialization and deserialization.
1919
/// </summary>
20-
internal static class MsaprSerialization
20+
public static class MsaprSerialization
2121
{
22-
public static readonly JsonSerializerOptions DefaultJsonSerializeOptions = new()
22+
public static readonly JsonSerializerOptions DefaultJsonSerializeOptions = new JsonSerializerOptions()
2323
{
24-
WriteIndented = true,
25-
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
26-
2724
Converters =
2825
{
29-
// TODO: ensure we save date-times in UTC round-tripable format
26+
// If we ever need to save DateTime values, we should do so using the following converter to ensure correct serialization as UTC time:
27+
//new JsonDateTimeUtcConverter(),
3028
},
3129

30+
// Use WhenWritingNull so that non-nullable value types (e.g. bool) are always written,
31+
// which is required for round-tripping 'required' properties whose value equals the type default (e.g. LoadFromYaml=false).
32+
// WhenWritingDefault would silently omit those properties, causing deserialization failures.
33+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
34+
WriteIndented = true,
35+
3236
// Deserialization only options:
3337
PropertyNameCaseInsensitive = true,
3438
AllowTrailingCommas = true,
3539
// In order to ensure forward-compatible deserialization, we ignore unknown members
40+
// Any object model that wants to also survive round-tripping, must use JsonExtensionData to capture those unknown members.
3641
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
37-
};
42+
}.ToReadOnly();
3843
}

0 commit comments

Comments
 (0)