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 <mail@bergmania.dk>
This commit is contained in:
Ronald Barendse
2024-04-10 20:21:24 +02:00
committed by GitHub
parent 1c5e27397b
commit 16dd5327d4
33 changed files with 495 additions and 332 deletions

View File

@@ -32,7 +32,7 @@ public class ConfigureUmbracoBackofficeJsonOptions : IConfigureNamedOptions<Json
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.Converters.Add(new JsonUdiConverter());
options.JsonSerializerOptions.Converters.Add(new JsonGuidUdiConverter());
options.JsonSerializerOptions.Converters.Add(new JsonUdiRangeConverter());
options.JsonSerializerOptions.Converters.Add(new JsonObjectConverter());
options.JsonSerializerOptions.TypeInfoResolver = _umbracoJsonTypeInfoResolver;

View File

@@ -1,5 +1,7 @@
namespace Umbraco.Cms.Core.Serialization;
/// <summary>
/// Provides functionality to serialize objects or value types to JSON and to deserialize JSON into objects or value types, used for data type configuration.
/// </summary>
public interface IConfigurationEditorJsonSerializer : IJsonSerializer
{
}
{ }

View File

@@ -1,8 +1,27 @@
namespace Umbraco.Cms.Core.Serialization;
/// <summary>
/// Provides functionality to serialize objects or value types to JSON and to deserialize JSON into objects or value types.
/// </summary>
public interface IJsonSerializer
{
/// <summary>
/// Converts the specified <paramref name="input" /> into a JSON string.
/// </summary>
/// <param name="input">The input.</param>
/// <returns>
/// A JSON string representation of the value.
/// </returns>
string Serialize(object? input);
/// <summary>
/// Parses the text representing a single JSON value into an instance of the type specified by a generic type parameter.
/// </summary>
/// <typeparam name="T">The target type of the JSON value.</typeparam>
/// <param name="input">The JSON input to parse.</param>
/// <returns>
/// A <typeparamref name="T" /> representation of the JSON value.
/// </returns>
T? Deserialize<T>(string input);
}

View File

@@ -1,26 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Infrastructure.Serialization;
public class AutoInterningStringConverter : JsonConverter<string>
{
// 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<string> _fallbackConverter = (JsonConverter<string>)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);
}

View File

@@ -1,49 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Infrastructure.Serialization;
public class AutoInterningStringKeyCaseInsensitiveDictionaryConverter<TValue> : JsonConverter<IDictionary<string, TValue>>
{
// 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<IDictionary<string, TValue>> _fallbackConverter = (JsonConverter<IDictionary<string, TValue>>)JsonSerializerOptions.Default.GetConverter(typeof(IDictionary<string, TValue>));
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert) => typeof(IDictionary<string, TValue>).IsAssignableFrom(typeToConvert);
public override Dictionary<string, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
return null;
}
var dictionary = new Dictionary<string, TValue>(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<TValue>(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<string, TValue> value, JsonSerializerOptions options) => _fallbackConverter.Write(writer, value, options);
}

View File

@@ -1,24 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Infrastructure.Serialization;
public class CaseInsensitiveDictionaryConverter<TValue> : JsonConverter<IDictionary<string, TValue>>
{
// 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<IDictionary<string, TValue>> _fallbackConverter = (JsonConverter<IDictionary<string, TValue>>)JsonSerializerOptions.Default.GetConverter(typeof(IDictionary<string, TValue>));
public override IDictionary<string, TValue>? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
IDictionary<string, TValue>? defaultDictionary = JsonSerializer.Deserialize<IDictionary<string, TValue>>(ref reader, options);
return defaultDictionary is null
? null
: new Dictionary<string, TValue>(defaultDictionary, StringComparer.OrdinalIgnoreCase);
}
public override void Write(Utf8JsonWriter writer, IDictionary<string, TValue> value, JsonSerializerOptions options) => _fallbackConverter.Write(writer, value, options);
}

View File

@@ -1,20 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// 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
/// </summary>
public class ForceUtcDateTimeConverter : JsonConverter<DateTime>
{
private readonly JsonConverter<DateTime> _fallBackConverter = (JsonConverter<DateTime>)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);
}

View File

@@ -1,36 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Infrastructure.Serialization;
public class JsonBoolConverter : JsonConverter<bool>
{
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();
}
}

View File

@@ -0,0 +1,62 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// Converts a boolean value to or from JSON, always converting a boolean like value (like <c>1</c> or <c>0</c>) to a boolean.
/// </summary>
public sealed class JsonBooleanConverter : JsonConverter<bool>
{
/// <inheritdoc />
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(),
};
/// <inheritdoc />
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();
}
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// Converts a dictionary with a string key to or from JSON, using the <see cref="StringComparer.OrdinalIgnoreCase" /> comparer.
/// </summary>
/// <typeparam name="TValue">The type of the dictionary value.</typeparam>
public sealed class JsonDictionaryStringIgnoreCaseConverter<TValue> : ReadOnlyJsonConverter<Dictionary<string, TValue>>
{
/// <inheritdoc />
public override Dictionary<string, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}
var dictionary = new Dictionary<string, TValue>(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<TValue>(ref reader, options);
if (value is not null)
{
dictionary[propertyName] = value;
}
}
throw new JsonException();
}
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// Converts a dictionary with a string key to or from JSON, using the <see cref="StringComparer.OrdinalIgnoreCase" /> comparer and interning the string key when reading.
/// </summary>
/// <typeparam name="TValue">The type of the dictionary value.</typeparam>
public sealed class JsonDictionaryStringInternIgnoreCaseConverter<TValue> : ReadOnlyJsonConverter<Dictionary<string, TValue>>
{
/// <inheritdoc />
public override Dictionary<string, TValue>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException();
}
var dictionary = new Dictionary<string, TValue>(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<TValue>(ref reader, options);
if (value is not null)
{
dictionary[string.Intern(propertyName)] = value;
}
}
throw new JsonException();
}
}

View File

@@ -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<GuidUdi>
{
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());
}

View File

@@ -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<object>
/// <summary>
/// Converts an object to or from JSON.
/// </summary>
public sealed class JsonObjectConverter : JsonConverter<object>
{
public override object? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options) =>
ParseObject(ref reader);
/// <inheritdoc />
public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> ParseObject(ref reader);
public override void Write(
Utf8JsonWriter writer,
object objectToWrite,
JsonSerializerOptions options)
/// <inheritdoc />
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)
{

View File

@@ -0,0 +1,15 @@
using System.Text.Json;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// Converts a string to or from JSON, interning the string when reading.
/// </summary>
public sealed class JsonStringInternConverter : ReadOnlyJsonConverter<string>
{
/// <inheritdoc />
public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.GetString() is string value
? string.Intern(value)
: null;
}

View File

@@ -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<Udi>
/// <summary>
/// Converts an <see cref="Udi" /> to or from JSON.
/// </summary>
public sealed class JsonUdiConverter : JsonConverter<Udi>
{
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;
}
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert)
=> typeof(Udi).IsAssignableFrom(typeToConvert);
/// <inheritdoc />
public override Udi? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.GetString() is string value
? UdiParser.Parse(value)
: null;
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, Udi value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// Converts an <see cref="UdiRange" /> to or from JSON.
/// </summary>
public sealed class JsonUdiRangeConverter : JsonConverter<UdiRange>
{
/// <inheritdoc />
public override UdiRange? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.GetString() is string value
? UdiRange.Parse(value)
: null;
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, UdiRange value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// Converts a DateTime value to or from JSON, always converting the value to Coordinated Universal Time (UTC) when reading.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class JsonUniversalDateTimeConverter : ReadOnlyJsonConverter<DateTime>
{
/// <inheritdoc />
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.GetDateTime().ToUniversalTime();
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// Converts an object or value from JSON.
/// </summary>
/// <typeparam name="T">The type of object or value handled by the converter.</typeparam>
public abstract class ReadOnlyJsonConverter<T> : JsonConverter<T>
{
private readonly JsonConverter<T> _fallbackConverter = (JsonConverter<T>)JsonSerializerOptions.Default.GetConverter(typeof(T));
/// <inheritdoc />
public sealed override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
=> _fallbackConverter.Write(writer, value, options);
}

View File

@@ -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
/// <inheritdoc />
public sealed class SystemTextConfigurationEditorJsonSerializer : IConfigurationEditorJsonSerializer
{
private JsonSerializerOptions _jsonSerializerOptions;
private readonly JsonSerializerOptions _jsonSerializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="SystemTextConfigurationEditorJsonSerializer" /> class.
/// </summary>
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());
}
/// <inheritdoc />
public string Serialize(object? input) => JsonSerializer.Serialize(input, _jsonSerializerOptions);
/// <inheritdoc />
public T? Deserialize<T>(string input) => JsonSerializer.Deserialize<T>(input, _jsonSerializerOptions);
}

View File

@@ -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
/// <inheritdoc />
public sealed class SystemTextJsonSerializer : IJsonSerializer
{
private readonly JsonSerializerOptions _jsonSerializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="SystemTextJsonSerializer" /> class.
/// </summary>
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()
}
};
/// <inheritdoc />
public string Serialize(object? input) => JsonSerializer.Serialize(input, _jsonSerializerOptions);
/// <inheritdoc />
public T? Deserialize<T>(string input) => JsonSerializer.Deserialize<T>(input, _jsonSerializerOptions);
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Umbraco.Cms.Infrastructure.Serialization;
/// <summary>
/// Converts an object or value to JSON.
/// </summary>
/// <typeparam name="T">The type of object or value handled by the converter.</typeparam>
public abstract class WriteOnlyJsonConverter<T> : JsonConverter<T>
{
private readonly JsonConverter<T> _fallbackConverter = (JsonConverter<T>)JsonSerializerOptions.Default.GetConverter(typeof(T));
/// <inheritdoc />
public sealed override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> _fallbackConverter.Read(ref reader, typeToConvert, options);
}

View File

@@ -15,14 +15,14 @@ public class ContentCacheDataModel
// dont serialize empty properties
[DataMember(Order = 0)]
[JsonPropertyName("pd")]
[JsonConverter(typeof(AutoInterningStringKeyCaseInsensitiveDictionaryConverter<PropertyData[]>))]
[MessagePackFormatter(typeof(MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter<PropertyData[]>))]
[JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter<PropertyData[]>))]
[MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter<PropertyData[]>))]
public Dictionary<string, PropertyData[]>? PropertyData { get; set; }
[DataMember(Order = 1)]
[JsonPropertyName("cd")]
[JsonConverter(typeof(AutoInterningStringKeyCaseInsensitiveDictionaryConverter<CultureVariation>))]
[MessagePackFormatter(typeof(MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter<CultureVariation>))]
[JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter<CultureVariation>))]
[MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter<CultureVariation>))]
public Dictionary<string, CultureVariation>? 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<PropertyData[]>))]
[JsonConverter(typeof(JsonDictionaryStringIgnoreCaseConverter<PropertyData[]>))]
private Dictionary<string, PropertyData[]> LegacyPropertyData { set => PropertyData = value; }
[IgnoreDataMember]
[JsonPropertyName("cultureData")]
[JsonConverter(typeof(CaseInsensitiveDictionaryConverter<CultureVariation>))]
[JsonConverter(typeof(JsonDictionaryStringIgnoreCaseConverter<CultureVariation>))]
private Dictionary<string, CultureVariation> LegacyCultureData { set => CultureData = value; }
[IgnoreDataMember]

View File

@@ -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]

View File

@@ -1,32 +0,0 @@
using MessagePack;
using MessagePack.Formatters;
namespace Umbraco.Cms.Infrastructure.PublishedCache.DataSource;
/// <summary>
/// A MessagePack formatter (deserializer) for a string key dictionary that uses
/// <see cref="StringComparer.OrdinalIgnoreCase" /> for the key string comparison.
/// </summary>
/// <typeparam name="TValue">The type of the value.</typeparam>
/// <seealso
/// cref="DictionaryFormatterBase&lt;string, TValue, Dictionary&lt;string, TValue&gt;, Dictionary&lt;string, TValue&gt;.Enumerator, Dictionary&lt;string, TValue&gt;&gt;" />
public sealed class MessagePackAutoInterningStringKeyCaseInsensitiveDictionaryFormatter<TValue> :
DictionaryFormatterBase<string, TValue, Dictionary<string, TValue>, Dictionary<string, TValue>.Enumerator,
Dictionary<string, TValue>>
{
protected override void Add(Dictionary<string, TValue> collection, int index, string key, TValue value,
MessagePackSerializerOptions options)
{
string.Intern(key);
collection.Add(key, value);
}
protected override Dictionary<string, TValue> Complete(Dictionary<string, TValue> intermediateCollection) =>
intermediateCollection;
protected override Dictionary<string, TValue>.Enumerator GetSourceEnumerator(Dictionary<string, TValue> source) =>
source.GetEnumerator();
protected override Dictionary<string, TValue> Create(int count, MessagePackSerializerOptions options) =>
new(count, StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,27 @@
using MessagePack;
using MessagePack.Formatters;
namespace Umbraco.Cms.Infrastructure.PublishedCache.DataSource;
/// <summary>
/// A MessagePack formatter (deserializer) for a string key dictionary that uses <see cref="StringComparer.OrdinalIgnoreCase" /> for the key string comparison and interns the string.
/// </summary>
/// <typeparam name="TValue">The type of the value.</typeparam>
public sealed class MessagePackDictionaryStringInternIgnoreCaseFormatter<TValue> : DictionaryFormatterBase<string, TValue, Dictionary<string, TValue>, Dictionary<string, TValue>.Enumerator, Dictionary<string, TValue>>
{
/// <inheritdoc />
protected override void Add(Dictionary<string, TValue> collection, int index, string key, TValue value, MessagePackSerializerOptions options)
=> collection.Add(string.Intern(key), value);
/// <inheritdoc />
protected override Dictionary<string, TValue> Complete(Dictionary<string, TValue> intermediateCollection)
=> intermediateCollection;
/// <inheritdoc />
protected override Dictionary<string, TValue>.Enumerator GetSourceEnumerator(Dictionary<string, TValue> source)
=> source.GetEnumerator();
/// <inheritdoc />
protected override Dictionary<string, TValue> Create(int count, MessagePackSerializerOptions options)
=> new(count, StringComparer.OrdinalIgnoreCase);
}

View File

@@ -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

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;

View File

@@ -1,4 +1,4 @@
using NUnit.Framework;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;

View File

@@ -1,4 +1,4 @@
using Moq;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;

View File

@@ -23,7 +23,6 @@ public class ArtifactBaseTests
Converters =
{
new JsonUdiConverter(),
new JsonGuidUdiConverter()
}
});

View File

@@ -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<Test>(serialized);
Assert.IsNotNull(string.IsInterned(obj.Name));
}
[Test]
public void Intern_Property_Dictionary()
{
var str1 = "key";
var obj = new Test
{
Values = new Dictionary<string, int> { [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<Test>(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<int>))]
public Dictionary<string, int> Values { get; set; } = new();
}
}

View File

@@ -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<Test>(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<string>(json, new JsonSerializerOptions()
{
Converters =
{
new JsonStringInternConverter(),
},
});
Assert.IsNotNull(string.IsInterned(str));
}
[Test]
public void Intern_Property_Dictionary()
{
var obj = new Test
{
Values = new Dictionary<string, int>
{
[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<Test>(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<string, int>
{
[Guid.NewGuid().ToString()] = 0,
[Guid.NewGuid().ToString()] = 1,
["Test"] = 3
});
var dictionary = JsonSerializer.Deserialize<Dictionary<string, int>>(json, new JsonSerializerOptions()
{
Converters =
{
new JsonDictionaryStringInternIgnoreCaseConverter<int>(),
},
});
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<int>))]
public Dictionary<string, int> Values { get; set; } = new();
}
}