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

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