From 16dd5327d4895b6d63477a792a5d33e79c7ebb4d Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Wed, 10 Apr 2024 20:21:24 +0200 Subject: [PATCH] v14: Refactor and enhance `System.Text.Json` converters (#15960) * Update JsonUdiConverter to support Udi, GuidUdi and StringUdi types * Require boolean (like) value and rename to JsonFuzzyBooleanConverter * Add read/write only JsonConverters and align naming * Rename SystemTextJsonSerializer to DefaultJsonSerializer * Rename SystemTextConfigurationEditorJsonSerializer to DefaultConfigurationEditorJsonSerializer * Add JsonUdiRangeConverter * Rename JsonFuzzyBooleanConverter back to JsonBooleanConverter * Fix value type check in JsonObjectConverter * Revert class names * Updated tests * Post fix after merge. --------- Co-authored-by: Bjarke Berg --- .../ConfigureUmbracoBackofficeJsonOptions.cs | 2 +- .../IConfigurationEditorJsonSerializer.cs | 6 +- .../Serialization/IJsonSerializer.cs | 19 +++ .../AutoInterningStringConverter.cs | 26 ---- ...ngKeyCaseInsensitiveDictionaryConverter.cs | 49 -------- .../CaseInsensitiveDictionaryConverter.cs | 24 ---- .../ForceUtcDateTimeConverter.cs | 20 --- .../Serialization/JsonBoolConverter.cs | 36 ------ .../Serialization/JsonBooleanConverter.cs | 62 ++++++++++ ...JsonDictionaryStringIgnoreCaseConverter.cs | 47 +++++++ ...ctionaryStringInternIgnoreCaseConverter.cs | 47 +++++++ .../Serialization/JsonGuidUdiConverter.cs | 20 --- .../Serialization/JsonObjectConverter.cs | 32 ++--- .../JsonStringInternConverter.cs | 15 +++ .../Serialization/JsonUdiConverter.cs | 25 ++-- .../Serialization/JsonUdiRangeConverter.cs | 21 ++++ .../JsonUniversalDateTimeConverter.cs | 17 +++ .../Serialization/ReadOnlyJsonConverter.cs | 17 +++ ...emTextConfigurationEditorJsonSerializer.cs | 36 +++--- .../Serialization/SystemTextJsonSerializer.cs | 30 +++-- .../Serialization/WriteOnlyJsonConverter.cs | 17 +++ .../DataSource/ContentCacheDataModel.cs | 12 +- .../DataSource/CultureVariation.cs | 4 +- ...ngKeyCaseInsensitiveDictionaryFormatter.cs | 32 ----- ...ctionaryStringInternIgnoreCaseFormatter.cs | 27 ++++ .../DataSource/PropertyData.cs | 4 +- .../RichTextPropertyEditorTests.cs | 2 +- .../Services/ContentEditingServiceTests.cs | 2 +- .../Services/ContentValidationServiceTests.cs | 2 +- .../OutputExpansionStrategyTestBase.cs | 2 +- .../Umbraco.Core/Deploy/ArtifactBaseTests.cs | 1 - .../AutoInterningStringConverterTests.cs | 54 -------- .../JsonStringInternConverterTests.cs | 117 ++++++++++++++++++ 33 files changed, 495 insertions(+), 332 deletions(-) delete mode 100644 src/Umbraco.Infrastructure/Serialization/AutoInterningStringConverter.cs delete mode 100644 src/Umbraco.Infrastructure/Serialization/AutoInterningStringKeyCaseInsensitiveDictionaryConverter.cs delete mode 100644 src/Umbraco.Infrastructure/Serialization/CaseInsensitiveDictionaryConverter.cs delete mode 100644 src/Umbraco.Infrastructure/Serialization/ForceUtcDateTimeConverter.cs delete mode 100644 src/Umbraco.Infrastructure/Serialization/JsonBoolConverter.cs create mode 100644 src/Umbraco.Infrastructure/Serialization/JsonBooleanConverter.cs create mode 100644 src/Umbraco.Infrastructure/Serialization/JsonDictionaryStringIgnoreCaseConverter.cs create mode 100644 src/Umbraco.Infrastructure/Serialization/JsonDictionaryStringInternIgnoreCaseConverter.cs delete mode 100644 src/Umbraco.Infrastructure/Serialization/JsonGuidUdiConverter.cs create mode 100644 src/Umbraco.Infrastructure/Serialization/JsonStringInternConverter.cs create mode 100644 src/Umbraco.Infrastructure/Serialization/JsonUdiRangeConverter.cs create mode 100644 src/Umbraco.Infrastructure/Serialization/JsonUniversalDateTimeConverter.cs create mode 100644 src/Umbraco.Infrastructure/Serialization/ReadOnlyJsonConverter.cs create mode 100644 src/Umbraco.Infrastructure/Serialization/WriteOnlyJsonConverter.cs delete mode 100644 src/Umbraco.PublishedCache.NuCache/DataSource/MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter.cs create mode 100644 src/Umbraco.PublishedCache.NuCache/DataSource/MessagePackDictionaryStringInternIgnoreCaseFormatter.cs delete mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Serialization/AutoInterningStringConverterTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Serialization/JsonStringInternConverterTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Serialization/ConfigureUmbracoBackofficeJsonOptions.cs b/src/Umbraco.Cms.Api.Management/Serialization/ConfigureUmbracoBackofficeJsonOptions.cs index 3a17550b5b..8f668b268d 100644 --- a/src/Umbraco.Cms.Api.Management/Serialization/ConfigureUmbracoBackofficeJsonOptions.cs +++ b/src/Umbraco.Cms.Api.Management/Serialization/ConfigureUmbracoBackofficeJsonOptions.cs @@ -32,7 +32,7 @@ public class ConfigureUmbracoBackofficeJsonOptions : IConfigureNamedOptions +/// Provides functionality to serialize objects or value types to JSON and to deserialize JSON into objects or value types, used for data type configuration. +/// public interface IConfigurationEditorJsonSerializer : IJsonSerializer -{ -} +{ } diff --git a/src/Umbraco.Core/Serialization/IJsonSerializer.cs b/src/Umbraco.Core/Serialization/IJsonSerializer.cs index 86cf54abb5..ba05652262 100644 --- a/src/Umbraco.Core/Serialization/IJsonSerializer.cs +++ b/src/Umbraco.Core/Serialization/IJsonSerializer.cs @@ -1,8 +1,27 @@ namespace Umbraco.Cms.Core.Serialization; +/// +/// Provides functionality to serialize objects or value types to JSON and to deserialize JSON into objects or value types. +/// public interface IJsonSerializer { + /// + /// Converts the specified into a JSON string. + /// + /// The input. + /// + /// A JSON string representation of the value. + /// string Serialize(object? input); + + /// + /// Parses the text representing a single JSON value into an instance of the type specified by a generic type parameter. + /// + /// The target type of the JSON value. + /// The JSON input to parse. + /// + /// A representation of the JSON value. + /// T? Deserialize(string input); } diff --git a/src/Umbraco.Infrastructure/Serialization/AutoInterningStringConverter.cs b/src/Umbraco.Infrastructure/Serialization/AutoInterningStringConverter.cs deleted file mode 100644 index 8f174c2139..0000000000 --- a/src/Umbraco.Infrastructure/Serialization/AutoInterningStringConverter.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Umbraco.Cms.Infrastructure.Serialization; - -public class AutoInterningStringConverter : JsonConverter -{ - // This is a hacky workaround to creating a "read only converter", since System.Text.Json doesn't support it. - // Taken from https://github.com/dotnet/runtime/issues/46372#issuecomment-1660515178 - private readonly JsonConverter _fallbackConverter = (JsonConverter)JsonSerializerOptions.Default.GetConverter(typeof(string)); - - public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - reader.TokenType switch - { - JsonTokenType.Null => null, - JsonTokenType.String => - // It's safe to ignore nullability here, because according to the docs: - // Returns null when TokenType is JsonTokenType.Null - // https://learn.microsoft.com/en-us/dotnet/api/system.text.json.utf8jsonreader.getstring?view=net-8.0#remarks - string.Intern(reader.GetString()!), - _ => throw new InvalidOperationException($"{nameof(AutoInterningStringConverter)} only supports strings."), - }; - - public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) - => _fallbackConverter.Write(writer, value, options); -} diff --git a/src/Umbraco.Infrastructure/Serialization/AutoInterningStringKeyCaseInsensitiveDictionaryConverter.cs b/src/Umbraco.Infrastructure/Serialization/AutoInterningStringKeyCaseInsensitiveDictionaryConverter.cs deleted file mode 100644 index 68c4e3f70b..0000000000 --- a/src/Umbraco.Infrastructure/Serialization/AutoInterningStringKeyCaseInsensitiveDictionaryConverter.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Umbraco.Cms.Infrastructure.Serialization; - -public class AutoInterningStringKeyCaseInsensitiveDictionaryConverter : JsonConverter> -{ - // This is a hacky workaround to creating a "read only converter", since System.Text.Json doesn't support it. - // Taken from https://github.com/dotnet/runtime/issues/46372#issuecomment-1660515178 - private readonly JsonConverter> _fallbackConverter = (JsonConverter>)JsonSerializerOptions.Default.GetConverter(typeof(IDictionary)); - - /// - public override bool CanConvert(Type typeToConvert) => typeof(IDictionary).IsAssignableFrom(typeToConvert); - - public override Dictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartObject) - { - return null; - } - - var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); - while (reader.Read()) - { - switch (reader.TokenType) - { - case JsonTokenType.PropertyName: - var key = string.Intern(reader.GetString()!); - - if (reader.Read() is false) - { - throw new JsonException(); - } - - TValue? value = JsonSerializer.Deserialize(ref reader, options); - dictionary[key] = value!; - break; - case JsonTokenType.Comment: - break; - case JsonTokenType.EndObject: - return dictionary; - } - } - - return null; - } - - public override void Write(Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options) => _fallbackConverter.Write(writer, value, options); -} diff --git a/src/Umbraco.Infrastructure/Serialization/CaseInsensitiveDictionaryConverter.cs b/src/Umbraco.Infrastructure/Serialization/CaseInsensitiveDictionaryConverter.cs deleted file mode 100644 index 5dd7e5ea79..0000000000 --- a/src/Umbraco.Infrastructure/Serialization/CaseInsensitiveDictionaryConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Umbraco.Cms.Infrastructure.Serialization; - -public class CaseInsensitiveDictionaryConverter : JsonConverter> -{ - // This is a hacky workaround to creating a "read only converter", since System.Text.Json doesn't support it. - // Taken from https://github.com/dotnet/runtime/issues/46372#issuecomment-1660515178 - private readonly JsonConverter> _fallbackConverter = (JsonConverter>)JsonSerializerOptions.Default.GetConverter(typeof(IDictionary)); - - public override IDictionary? Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options) - { - IDictionary? defaultDictionary = JsonSerializer.Deserialize>(ref reader, options); - return defaultDictionary is null - ? null - : new Dictionary(defaultDictionary, StringComparer.OrdinalIgnoreCase); - } - - public override void Write(Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options) => _fallbackConverter.Write(writer, value, options); -} diff --git a/src/Umbraco.Infrastructure/Serialization/ForceUtcDateTimeConverter.cs b/src/Umbraco.Infrastructure/Serialization/ForceUtcDateTimeConverter.cs deleted file mode 100644 index 5b6aadbd07..0000000000 --- a/src/Umbraco.Infrastructure/Serialization/ForceUtcDateTimeConverter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Umbraco.Cms.Infrastructure.Serialization; - -/// -/// In order to match the existing behaviour, and that os messagepack, we need to ensure that DateTimes are always read as UTC. -/// This is not the case by default for System.Text.Json, see: https://github.com/dotnet/runtime/issues/1566 -/// -public class ForceUtcDateTimeConverter : JsonConverter -{ - private readonly JsonConverter _fallBackConverter = (JsonConverter)JsonSerializerOptions.Default.GetConverter(typeof(DateTime)); - - public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - => reader.GetDateTime().ToUniversalTime(); - - // The existing behaviour is fine for writing, it's only reading that's an issue. - public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) - => _fallBackConverter.Write(writer, value, options); -} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonBoolConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonBoolConverter.cs deleted file mode 100644 index 0dbac1cbc5..0000000000 --- a/src/Umbraco.Infrastructure/Serialization/JsonBoolConverter.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Umbraco.Cms.Infrastructure.Serialization; - -public class JsonBoolConverter : JsonConverter -{ - public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) => - writer.WriteBooleanValue(value); - - public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - reader.TokenType switch - { - JsonTokenType.True => true, - JsonTokenType.False => false, - JsonTokenType.String => ParseString(ref reader), - JsonTokenType.Number => reader.TryGetInt64(out long l) ? Convert.ToBoolean(l) : reader.TryGetDouble(out double d) ? Convert.ToBoolean(d) : false, - _ => throw new JsonException(), - }; - - private bool ParseString(ref Utf8JsonReader reader) - { - var value = reader.GetString(); - if (bool.TryParse(value, out var b)) - { - return b; - } - - if (int.TryParse(value, out var i)) - { - return Convert.ToBoolean(i); - } - - throw new JsonException(); - } -} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonBooleanConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonBooleanConverter.cs new file mode 100644 index 0000000000..04d832af61 --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/JsonBooleanConverter.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +/// Converts a boolean value to or from JSON, always converting a boolean like value (like 1 or 0) to a boolean. +/// +public sealed class JsonBooleanConverter : JsonConverter +{ + /// + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType switch + { + JsonTokenType.String => ParseString(ref reader), + JsonTokenType.Number => ParseNumber(ref reader), + JsonTokenType.True => true, + JsonTokenType.False => false, + _ => throw new JsonException(), + }; + + /// + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + => writer.WriteBooleanValue(value); + + private static bool ParseString(ref Utf8JsonReader reader) + { + var value = reader.GetString(); + + if (bool.TryParse(value, out var boolValue)) + { + return boolValue; + } + + if (long.TryParse(value, out var longValue)) + { + return Convert.ToBoolean(longValue); + } + + if (double.TryParse(value, out double doubleValue)) + { + return Convert.ToBoolean(doubleValue); + } + + throw new JsonException(); + } + + private static bool ParseNumber(ref Utf8JsonReader reader) + { + if (reader.TryGetInt64(out long longValue)) + { + return Convert.ToBoolean(longValue); + } + + if (reader.TryGetDouble(out double doubleValue)) + { + return Convert.ToBoolean(doubleValue); + } + + throw new JsonException(); + } +} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonDictionaryStringIgnoreCaseConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonDictionaryStringIgnoreCaseConverter.cs new file mode 100644 index 0000000000..8d34cdb3cf --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/JsonDictionaryStringIgnoreCaseConverter.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +/// Converts a dictionary with a string key to or from JSON, using the comparer. +/// +/// The type of the dictionary value. +public sealed class JsonDictionaryStringIgnoreCaseConverter : ReadOnlyJsonConverter> +{ + /// + public override Dictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(); + } + + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return dictionary; + } + + // Get key + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException(); + } + + string propertyName = reader.GetString() ?? throw new JsonException(); + + // Get value + reader.Read(); + TValue? value = JsonSerializer.Deserialize(ref reader, options); + if (value is not null) + { + dictionary[propertyName] = value; + } + } + + throw new JsonException(); + } +} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonDictionaryStringInternIgnoreCaseConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonDictionaryStringInternIgnoreCaseConverter.cs new file mode 100644 index 0000000000..1e42cf7a21 --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/JsonDictionaryStringInternIgnoreCaseConverter.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +/// Converts a dictionary with a string key to or from JSON, using the comparer and interning the string key when reading. +/// +/// The type of the dictionary value. +public sealed class JsonDictionaryStringInternIgnoreCaseConverter : ReadOnlyJsonConverter> +{ + /// + public override Dictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(); + } + + var dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return dictionary; + } + + // Get key + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException(); + } + + string propertyName = reader.GetString() ?? throw new JsonException(); + + // Get value + reader.Read(); + TValue? value = JsonSerializer.Deserialize(ref reader, options); + if (value is not null) + { + dictionary[string.Intern(propertyName)] = value; + } + } + + throw new JsonException(); + } +} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonGuidUdiConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonGuidUdiConverter.cs deleted file mode 100644 index 243b710335..0000000000 --- a/src/Umbraco.Infrastructure/Serialization/JsonGuidUdiConverter.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Umbraco.Cms.Core; -using Umbraco.Extensions; - -namespace Umbraco.Cms.Infrastructure.Serialization; - -public class JsonGuidUdiConverter : JsonConverter -{ - public override GuidUdi? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var stringValue = reader.GetString(); - return stringValue.IsNullOrWhiteSpace() == false && UdiParser.TryParse(stringValue, out Udi? udi) && udi is GuidUdi guidUdi - ? guidUdi - : null; - } - - public override void Write(Utf8JsonWriter writer, GuidUdi value, JsonSerializerOptions options) - => writer.WriteStringValue(value.ToString()); -} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonObjectConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonObjectConverter.cs index 2ec118dc97..e1c0f5950d 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonObjectConverter.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonObjectConverter.cs @@ -1,43 +1,43 @@ -using System.Collections; +using System.Collections; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace Umbraco.Cms.Infrastructure.Serialization; -public class JsonObjectConverter : JsonConverter +/// +/// Converts an object to or from JSON. +/// +public sealed class JsonObjectConverter : JsonConverter { - public override object? Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options) => - ParseObject(ref reader); + /// + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => ParseObject(ref reader); - public override void Write( - Utf8JsonWriter writer, - object objectToWrite, - JsonSerializerOptions options) + /// + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) { - if (objectToWrite is null) + if (value is null) { return; } - // If an object is equals "new object()", Json.Serialize would recurse forever and cause a stack overflow + // If the value equals an empty object, Json.Serialize would recurse forever and cause a stack overflow // We have no good way of checking if its an empty object // which is why we try to check if the object has any properties, and thus will be empty. - if (objectToWrite.GetType().Name is "Object" && !objectToWrite.GetType().GetProperties().Any()) + Type inputType = value.GetType(); + if (inputType == typeof(object) && inputType.GetProperties().Length == 0) { writer.WriteStartObject(); writer.WriteEndObject(); } else { - JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options); + JsonSerializer.Serialize(writer, value, inputType, options); } } - private object? ParseObject(ref Utf8JsonReader reader) + private static object? ParseObject(ref Utf8JsonReader reader) { if (reader.TokenType == JsonTokenType.StartArray) { diff --git a/src/Umbraco.Infrastructure/Serialization/JsonStringInternConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonStringInternConverter.cs new file mode 100644 index 0000000000..df6aa454b6 --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/JsonStringInternConverter.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +/// Converts a string to or from JSON, interning the string when reading. +/// +public sealed class JsonStringInternConverter : ReadOnlyJsonConverter +{ + /// + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetString() is string value + ? string.Intern(value) + : null; +} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonUdiConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonUdiConverter.cs index e229828b51..0528f5e983 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonUdiConverter.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonUdiConverter.cs @@ -1,20 +1,25 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using Umbraco.Cms.Core; -using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Serialization; -public class JsonUdiConverter : JsonConverter +/// +/// Converts an to or from JSON. +/// +public sealed class JsonUdiConverter : JsonConverter { - public override Udi? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - var stringValue = reader.GetString(); - return stringValue.IsNullOrWhiteSpace() == false && UdiParser.TryParse(stringValue, out Udi? udi) - ? udi - : null; - } + /// + public override bool CanConvert(Type typeToConvert) + => typeof(Udi).IsAssignableFrom(typeToConvert); + /// + public override Udi? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetString() is string value + ? UdiParser.Parse(value) + : null; + + /// public override void Write(Utf8JsonWriter writer, Udi value, JsonSerializerOptions options) => writer.WriteStringValue(value.ToString()); } diff --git a/src/Umbraco.Infrastructure/Serialization/JsonUdiRangeConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonUdiRangeConverter.cs new file mode 100644 index 0000000000..7025c1fee4 --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/JsonUdiRangeConverter.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +/// Converts an to or from JSON. +/// +public sealed class JsonUdiRangeConverter : JsonConverter +{ + /// + public override UdiRange? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetString() is string value + ? UdiRange.Parse(value) + : null; + + /// + public override void Write(Utf8JsonWriter writer, UdiRange value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString()); +} diff --git a/src/Umbraco.Infrastructure/Serialization/JsonUniversalDateTimeConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonUniversalDateTimeConverter.cs new file mode 100644 index 0000000000..45ae9fb02e --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/JsonUniversalDateTimeConverter.cs @@ -0,0 +1,17 @@ +using System.Text.Json; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +/// Converts a DateTime value to or from JSON, always converting the value to Coordinated Universal Time (UTC) when reading. +/// +/// +/// In order to match the existing behaviour, and that of MessagePack, we need to ensure that DateTimes are always read as UTC. +/// This is not the case by default for System.Text.Json, see: https://github.com/dotnet/runtime/issues/1566. +/// +public sealed class JsonUniversalDateTimeConverter : ReadOnlyJsonConverter +{ + /// + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetDateTime().ToUniversalTime(); +} diff --git a/src/Umbraco.Infrastructure/Serialization/ReadOnlyJsonConverter.cs b/src/Umbraco.Infrastructure/Serialization/ReadOnlyJsonConverter.cs new file mode 100644 index 0000000000..861e953a22 --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/ReadOnlyJsonConverter.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +/// Converts an object or value from JSON. +/// +/// The type of object or value handled by the converter. +public abstract class ReadOnlyJsonConverter : JsonConverter +{ + private readonly JsonConverter _fallbackConverter = (JsonConverter)JsonSerializerOptions.Default.GetConverter(typeof(T)); + + /// + public sealed override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + => _fallbackConverter.Write(writer, value, options); +} diff --git a/src/Umbraco.Infrastructure/Serialization/SystemTextConfigurationEditorJsonSerializer.cs b/src/Umbraco.Infrastructure/Serialization/SystemTextConfigurationEditorJsonSerializer.cs index 102b6a72fa..944d769be1 100644 --- a/src/Umbraco.Infrastructure/Serialization/SystemTextConfigurationEditorJsonSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/SystemTextConfigurationEditorJsonSerializer.cs @@ -1,36 +1,38 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using Umbraco.Cms.Core.Serialization; namespace Umbraco.Cms.Infrastructure.Serialization; -// FIXME: clean up all config editor serializers when we can migrate fully to System.Text.Json -// - move this implementation to ConfigurationEditorJsonSerializer (delete the old implementation) -// - use this implementation as the registered singleton (delete ContextualConfigurationEditorJsonSerializer) -// - reuse the JsonObjectConverter implementation from management API (delete the local implementation - pending V12 branch update) - -public class SystemTextConfigurationEditorJsonSerializer : IConfigurationEditorJsonSerializer +/// +public sealed class SystemTextConfigurationEditorJsonSerializer : IConfigurationEditorJsonSerializer { - private JsonSerializerOptions _jsonSerializerOptions; + private readonly JsonSerializerOptions _jsonSerializerOptions; + /// + /// Initializes a new instance of the class. + /// public SystemTextConfigurationEditorJsonSerializer() - { - _jsonSerializerOptions = new JsonSerializerOptions + => _jsonSerializerOptions = new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // in some cases, configs aren't camel cased in the DB, so we have to resort to case insensitive // property name resolving when creating configuration objects (deserializing DB configs) PropertyNameCaseInsensitive = true, - NumberHandling = JsonNumberHandling.AllowReadingFromString + NumberHandling = JsonNumberHandling.AllowReadingFromString, + Converters = + { + new JsonStringEnumConverter(), + new JsonObjectConverter(), + new JsonUdiConverter(), + new JsonUdiRangeConverter(), + new JsonBooleanConverter() + } }; - _jsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - _jsonSerializerOptions.Converters.Add(new JsonObjectConverter()); - _jsonSerializerOptions.Converters.Add(new JsonUdiConverter()); - _jsonSerializerOptions.Converters.Add(new JsonGuidUdiConverter()); - _jsonSerializerOptions.Converters.Add(new JsonBoolConverter()); - } + /// public string Serialize(object? input) => JsonSerializer.Serialize(input, _jsonSerializerOptions); + /// public T? Deserialize(string input) => JsonSerializer.Deserialize(input, _jsonSerializerOptions); } diff --git a/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializer.cs b/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializer.cs index 90b72e2db3..c713783e0d 100644 --- a/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializer.cs @@ -1,24 +1,34 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; using Umbraco.Cms.Core.Serialization; namespace Umbraco.Cms.Infrastructure.Serialization; -public class SystemTextJsonSerializer : IJsonSerializer +/// +public sealed class SystemTextJsonSerializer : IJsonSerializer { private readonly JsonSerializerOptions _jsonSerializerOptions; + /// + /// Initializes a new instance of the class. + /// public SystemTextJsonSerializer() - { - _jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - _jsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); - _jsonSerializerOptions.Converters.Add(new JsonUdiConverter()); - _jsonSerializerOptions.Converters.Add(new JsonGuidUdiConverter()); - // we may need to add JsonObjectConverter at some point, but for the time being things work fine without - // _jsonSerializerOptions.Converters.Add(new JsonObjectConverter()); - } + => _jsonSerializerOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = + { + new JsonStringEnumConverter(), + new JsonUdiConverter(), + new JsonUdiRangeConverter(), + // We may need to add JsonObjectConverter at some point, but for the time being things work fine without + //new JsonObjectConverter() + } + }; + /// public string Serialize(object? input) => JsonSerializer.Serialize(input, _jsonSerializerOptions); + /// public T? Deserialize(string input) => JsonSerializer.Deserialize(input, _jsonSerializerOptions); } diff --git a/src/Umbraco.Infrastructure/Serialization/WriteOnlyJsonConverter.cs b/src/Umbraco.Infrastructure/Serialization/WriteOnlyJsonConverter.cs new file mode 100644 index 0000000000..ff341db136 --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/WriteOnlyJsonConverter.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +/// Converts an object or value to JSON. +/// +/// The type of object or value handled by the converter. +public abstract class WriteOnlyJsonConverter : JsonConverter +{ + private readonly JsonConverter _fallbackConverter = (JsonConverter)JsonSerializerOptions.Default.GetConverter(typeof(T)); + + /// + public sealed override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => _fallbackConverter.Read(ref reader, typeToConvert, options); +} diff --git a/src/Umbraco.PublishedCache.NuCache/DataSource/ContentCacheDataModel.cs b/src/Umbraco.PublishedCache.NuCache/DataSource/ContentCacheDataModel.cs index b85510b5c9..5964a7aa8f 100644 --- a/src/Umbraco.PublishedCache.NuCache/DataSource/ContentCacheDataModel.cs +++ b/src/Umbraco.PublishedCache.NuCache/DataSource/ContentCacheDataModel.cs @@ -15,14 +15,14 @@ public class ContentCacheDataModel // dont serialize empty properties [DataMember(Order = 0)] [JsonPropertyName("pd")] - [JsonConverter(typeof(AutoInterningStringKeyCaseInsensitiveDictionaryConverter))] - [MessagePackFormatter(typeof(MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter))] + [JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter))] + [MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter))] public Dictionary? PropertyData { get; set; } [DataMember(Order = 1)] [JsonPropertyName("cd")] - [JsonConverter(typeof(AutoInterningStringKeyCaseInsensitiveDictionaryConverter))] - [MessagePackFormatter(typeof(MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter))] + [JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter))] + [MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter))] public Dictionary? CultureData { get; set; } [DataMember(Order = 2)] @@ -32,12 +32,12 @@ public class ContentCacheDataModel // Legacy properties used to deserialize existing nucache db entries [IgnoreDataMember] [JsonPropertyName("properties")] - [JsonConverter(typeof(CaseInsensitiveDictionaryConverter))] + [JsonConverter(typeof(JsonDictionaryStringIgnoreCaseConverter))] private Dictionary LegacyPropertyData { set => PropertyData = value; } [IgnoreDataMember] [JsonPropertyName("cultureData")] - [JsonConverter(typeof(CaseInsensitiveDictionaryConverter))] + [JsonConverter(typeof(JsonDictionaryStringIgnoreCaseConverter))] private Dictionary LegacyCultureData { set => CultureData = value; } [IgnoreDataMember] diff --git a/src/Umbraco.PublishedCache.NuCache/DataSource/CultureVariation.cs b/src/Umbraco.PublishedCache.NuCache/DataSource/CultureVariation.cs index 8819aa5605..ccc3799d3f 100644 --- a/src/Umbraco.PublishedCache.NuCache/DataSource/CultureVariation.cs +++ b/src/Umbraco.PublishedCache.NuCache/DataSource/CultureVariation.cs @@ -20,7 +20,7 @@ public class CultureVariation [DataMember(Order = 2)] [JsonPropertyName("dt")] - [JsonConverter(typeof(ForceUtcDateTimeConverter))] + [JsonConverter(typeof(JsonUniversalDateTimeConverter))] public DateTime Date { get; set; } [DataMember(Order = 3)] @@ -38,7 +38,7 @@ public class CultureVariation [IgnoreDataMember] [JsonPropertyName("date")] - [JsonConverter(typeof(ForceUtcDateTimeConverter))] + [JsonConverter(typeof(JsonUniversalDateTimeConverter))] private DateTime LegacyDate { set => Date = value; } [IgnoreDataMember] diff --git a/src/Umbraco.PublishedCache.NuCache/DataSource/MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter.cs b/src/Umbraco.PublishedCache.NuCache/DataSource/MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter.cs deleted file mode 100644 index 7e2c453cfe..0000000000 --- a/src/Umbraco.PublishedCache.NuCache/DataSource/MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter.cs +++ /dev/null @@ -1,32 +0,0 @@ -using MessagePack; -using MessagePack.Formatters; - -namespace Umbraco.Cms.Infrastructure.PublishedCache.DataSource; - -/// -/// A MessagePack formatter (deserializer) for a string key dictionary that uses -/// for the key string comparison. -/// -/// The type of the value. -/// -public sealed class MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter : - DictionaryFormatterBase, Dictionary.Enumerator, - Dictionary> -{ - protected override void Add(Dictionary collection, int index, string key, TValue value, - MessagePackSerializerOptions options) - { - string.Intern(key); - collection.Add(key, value); - } - - protected override Dictionary Complete(Dictionary intermediateCollection) => - intermediateCollection; - - protected override Dictionary.Enumerator GetSourceEnumerator(Dictionary source) => - source.GetEnumerator(); - - protected override Dictionary Create(int count, MessagePackSerializerOptions options) => - new(count, StringComparer.OrdinalIgnoreCase); -} diff --git a/src/Umbraco.PublishedCache.NuCache/DataSource/MessagePackDictionaryStringInternIgnoreCaseFormatter.cs b/src/Umbraco.PublishedCache.NuCache/DataSource/MessagePackDictionaryStringInternIgnoreCaseFormatter.cs new file mode 100644 index 0000000000..6b24962792 --- /dev/null +++ b/src/Umbraco.PublishedCache.NuCache/DataSource/MessagePackDictionaryStringInternIgnoreCaseFormatter.cs @@ -0,0 +1,27 @@ +using MessagePack; +using MessagePack.Formatters; + +namespace Umbraco.Cms.Infrastructure.PublishedCache.DataSource; + +/// +/// A MessagePack formatter (deserializer) for a string key dictionary that uses for the key string comparison and interns the string. +/// +/// The type of the value. +public sealed class MessagePackDictionaryStringInternIgnoreCaseFormatter : DictionaryFormatterBase, Dictionary.Enumerator, Dictionary> +{ + /// + protected override void Add(Dictionary collection, int index, string key, TValue value, MessagePackSerializerOptions options) + => collection.Add(string.Intern(key), value); + + /// + protected override Dictionary Complete(Dictionary intermediateCollection) + => intermediateCollection; + + /// + protected override Dictionary.Enumerator GetSourceEnumerator(Dictionary source) + => source.GetEnumerator(); + + /// + protected override Dictionary Create(int count, MessagePackSerializerOptions options) + => new(count, StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Umbraco.PublishedCache.NuCache/DataSource/PropertyData.cs b/src/Umbraco.PublishedCache.NuCache/DataSource/PropertyData.cs index 4de0476c78..fde41dd90c 100644 --- a/src/Umbraco.PublishedCache.NuCache/DataSource/PropertyData.cs +++ b/src/Umbraco.PublishedCache.NuCache/DataSource/PropertyData.cs @@ -12,7 +12,7 @@ public class PropertyData private string? _segment; [DataMember(Order = 0)] - [JsonConverter(typeof(AutoInterningStringConverter))] + [JsonConverter(typeof(JsonStringInternConverter))] [DefaultValue("")] [JsonPropertyName("c")] public string? Culture @@ -24,7 +24,7 @@ public class PropertyData } [DataMember(Order = 1)] - [JsonConverter(typeof(AutoInterningStringConverter))] + [JsonConverter(typeof(JsonStringInternConverter))] [DefaultValue("")] [JsonPropertyName("s")] public string? Segment diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs index 32c8e4dae8..c2a2088041 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditorTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs index d3c07050cb..68daf63c8d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs @@ -1,4 +1,4 @@ -using NUnit.Framework; +using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs index 45aee41cdc..e1c9516b5e 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs index 06d8aad378..2f1b05abf7 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs @@ -1,4 +1,4 @@ -using Moq; +using Moq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Deploy/ArtifactBaseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Deploy/ArtifactBaseTests.cs index 0aaf887fd8..fddae4a60c 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Deploy/ArtifactBaseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Deploy/ArtifactBaseTests.cs @@ -23,7 +23,6 @@ public class ArtifactBaseTests Converters = { new JsonUdiConverter(), - new JsonGuidUdiConverter() } }); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Serialization/AutoInterningStringConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Serialization/AutoInterningStringConverterTests.cs deleted file mode 100644 index ccfed38b7e..0000000000 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Serialization/AutoInterningStringConverterTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using NUnit.Framework; -using Umbraco.Cms.Infrastructure.Serialization; - -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Serialization; - -[TestFixture] -public class AutoInterningStringConverterTests -{ - [Test] - public void Intern_Property_String() - { - var str1 = "Hello"; - var obj = new Test { Name = str1 + Guid.NewGuid() }; - - // ensure the raw value is not interned - Assert.IsNull(string.IsInterned(obj.Name)); - - var serialized = JsonSerializer.Serialize(obj); - obj = JsonSerializer.Deserialize(serialized); - - Assert.IsNotNull(string.IsInterned(obj.Name)); - } - - [Test] - public void Intern_Property_Dictionary() - { - var str1 = "key"; - var obj = new Test - { - Values = new Dictionary { [str1 + Guid.NewGuid()] = 0, [str1 + Guid.NewGuid()] = 1 }, - }; - - // ensure the raw value is not interned - Assert.IsNull(string.IsInterned(obj.Values.Keys.First())); - Assert.IsNull(string.IsInterned(obj.Values.Keys.Last())); - - var serialized = JsonSerializer.Serialize(obj); - obj = JsonSerializer.Deserialize(serialized); - - Assert.IsNotNull(string.IsInterned(obj.Values.Keys.First())); - Assert.IsNotNull(string.IsInterned(obj.Values.Keys.Last())); - } - - public class Test - { - [JsonConverter(typeof(AutoInterningStringConverter))] - public string Name { get; set; } - - [JsonConverter(typeof(AutoInterningStringKeyCaseInsensitiveDictionaryConverter))] - public Dictionary Values { get; set; } = new(); - } -} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Serialization/JsonStringInternConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Serialization/JsonStringInternConverterTests.cs new file mode 100644 index 0000000000..ffec67ee6c --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Serialization/JsonStringInternConverterTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Serialization; + +[TestFixture] +public class JsonStringInternConverterTests +{ + [Test] + public void Intern_Property_String() + { + var obj = new Test + { + Name = Guid.NewGuid().ToString(), + }; + + // Ensure the raw value is not interned + Assert.IsNull(string.IsInterned(obj.Name)); + + // Ensure the value is interned when deserializing using converter + var json = JsonSerializer.Serialize(obj); + obj = JsonSerializer.Deserialize(json); + + Assert.IsNotNull(string.IsInterned(obj.Name)); + + // Ensure the value is interned when deserializing using options + json = JsonSerializer.Serialize(Guid.NewGuid().ToString()); + var str = JsonSerializer.Deserialize(json, new JsonSerializerOptions() + { + Converters = + { + new JsonStringInternConverter(), + }, + }); + + Assert.IsNotNull(string.IsInterned(str)); + } + + [Test] + public void Intern_Property_Dictionary() + { + var obj = new Test + { + Values = new Dictionary + { + [Guid.NewGuid().ToString()] = 0, + [Guid.NewGuid().ToString()] = 1, + }, + }; + + // Ensure the raw values are not interned + Assert.Multiple(() => + { + foreach (string key in obj.Values.Keys) + { + Assert.IsNull(string.IsInterned(key)); + } + }); + + // Add value to dictionary to test case-insensitivity + obj.Values.Add("Test", 3); + + // Ensure the value is interned when deserializing using converter + var json = JsonSerializer.Serialize(obj); + obj = JsonSerializer.Deserialize(json); + + Assert.Multiple(() => + { + foreach (string key in obj.Values.Keys) + { + Assert.IsNotNull(string.IsInterned(key)); + } + }); + + // Check that the dictionary is case-insensitive + Assert.IsTrue(obj.Values.ContainsKey("Test")); + Assert.IsTrue(obj.Values.ContainsKey("test")); + + // Ensure the value is interned when deserializing using options + json = JsonSerializer.Serialize(new Dictionary + { + [Guid.NewGuid().ToString()] = 0, + [Guid.NewGuid().ToString()] = 1, + ["Test"] = 3 + }); + var dictionary = JsonSerializer.Deserialize>(json, new JsonSerializerOptions() + { + Converters = + { + new JsonDictionaryStringInternIgnoreCaseConverter(), + }, + }); + + Assert.Multiple(() => + { + foreach (string key in dictionary.Keys) + { + Assert.IsNotNull(string.IsInterned(key)); + } + }); + + // Check that the dictionary is case-insensitive + Assert.IsTrue(dictionary.ContainsKey("Test")); + Assert.IsTrue(dictionary.ContainsKey("test")); + } + + public class Test + { + [JsonConverter(typeof(JsonStringInternConverter))] + public string Name { get; set; } + + [JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter))] + public Dictionary Values { get; set; } = new(); + } +}