Skip to content

Commit

Permalink
System.Text.Json: String-Based keyed value objects (and smart enums) …
Browse files Browse the repository at this point in the history
…can be used as dictionary keys
  • Loading branch information
PawelGerr committed Jan 31, 2024
1 parent 6245b4e commit dcd4e25
Show file tree
Hide file tree
Showing 10 changed files with 151 additions and 13 deletions.
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<Copyright>(c) $([System.DateTime]::Now.Year), Pawel Gerr. All rights reserved.</Copyright>
<VersionPrefix>7.1.0</VersionPrefix>
<VersionPrefix>7.2.0</VersionPrefix>
<Authors>Pawel Gerr</Authors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageProjectUrl>https://github.com/PawelGerr/Thinktecture.Runtime.Extensions</PackageProjectUrl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,64 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions
_keyConverter.Write(writer, value.ToValue(), options);
}
}

/// <summary>
/// JSON converter for string-based Value Objects.
/// </summary>
/// <typeparam name="T">Type of the value object.</typeparam>
/// <typeparam name="TValidationError">Type of the validation error.</typeparam>
public sealed class ValueObjectJsonConverter<T, TValidationError> : JsonConverter<T>
where T : IValueObjectFactory<T, string, TValidationError>, IValueObjectConvertable<string>
where TValidationError : class, IValidationError<TValidationError>
{
private static readonly bool _mayReturnInvalidObjects = typeof(IValidatableEnum).IsAssignableFrom(typeof(T));

/// <summary>
/// Initializes a new instance of <see cref="ValueObjectJsonConverter{T,TKey,TValidationError}"/>.
/// </summary>
/// <param name="options">JSON serializer options.</param>
public ValueObjectJsonConverter(JsonSerializerOptions options)
{
ArgumentNullException.ThrowIfNull(options);
}

/// <inheritdoc />
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var key = reader.GetString();

if (key is null)
return default;

var validationError = T.Validate(key, null, out var obj);

if (validationError is not null && !_mayReturnInvalidObjects)
throw new JsonException(validationError.ToString() ?? "JSON deserialization failed.");

return obj;
}

/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
if (value is null)
throw new ArgumentNullException(nameof(value));

writer.WriteStringValue(value.ToValue());
}

/// <inheritdoc />
public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return Read(ref reader, typeToConvert, options) ?? base.ReadAsPropertyName(ref reader, typeToConvert, options);
}

/// <inheritdoc />
public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
if (value is null)
throw new ArgumentNullException(nameof(value));

writer.WritePropertyName(value.ToValue());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,29 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer
}
}

/// <summary>
/// Factory for creation of <see cref="ValueObjectJsonConverter{T,TKey,TValidationError}"/>.
/// </summary>
public sealed class ValueObjectJsonConverterFactory<T, TValidationError> : JsonConverterFactory
where T : IValueObjectFactory<T, string, TValidationError>, IValueObjectConvertable<string>
where TValidationError : class, IValidationError<TValidationError>
{
/// <inheritdoc />
public override bool CanConvert(Type typeToConvert)
{
return typeof(T).IsAssignableFrom(typeToConvert);
}

/// <inheritdoc />
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
ArgumentNullException.ThrowIfNull(typeToConvert);
ArgumentNullException.ThrowIfNull(options);

return new ValueObjectJsonConverter<T, TValidationError>(options);
}
}

/// <summary>
/// Factory for creation of <see cref="ValueObjectJsonConverter{T,TKey,TValidationError}"/>.
/// </summary>
Expand Down Expand Up @@ -71,7 +94,9 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer

var validationErrorType = type.GetCustomAttribute<ValueObjectValidationErrorAttribute>()?.Type ?? typeof(ValidationError);

var converterType = typeof(ValueObjectJsonConverter<,,>).MakeGenericType(type, keyType, validationErrorType);
var converterType = keyType == typeof(string)
? typeof(ValueObjectJsonConverter<,>).MakeGenericType(type, validationErrorType)
: typeof(ValueObjectJsonConverter<,,>).MakeGenericType(type, keyType, validationErrorType);
var converter = Activator.CreateInstance(converterType, options);

return (JsonConverter)(converter ?? throw new Exception($"Could not create converter of type '{converterType.Name}'."));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public override void Generate(CancellationToken cancellationToken)
.DesiredFactories
.FirstOrDefault(f => f.UseForSerialization.Has(SerializationFrameworks.SystemTextJson));
var keyType = customFactory?.TypeFullyQualified ?? _state.KeyMember?.TypeFullyQualified;
var isString = customFactory is null
? _state.KeyMember?.SpecialType == SpecialType.System_String
: customFactory.SpecialType == SpecialType.System_String;

_sb.Append(GENERATED_CODE_PREFIX).Append(@"
");
Expand All @@ -34,7 +37,12 @@ namespace ").Append(_state.Type.Namespace).Append(@";
}

_sb.Append(@"
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<").Append(_state.Type.TypeFullyQualified).Append(", ").Append(keyType).Append(", ").Append(_state.AttributeInfo.ValidationError.TypeFullyQualified).Append(@">))]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<").Append(_state.Type.TypeFullyQualified).Append(", ");

if (!isString)
_sb.Append(keyType).Append(", ");

_sb.Append(_state.AttributeInfo.ValidationError.TypeFullyQualified).Append(@">))]
partial ").Append(_state.Type.IsReferenceType ? "class" : "struct").Append(" ").Append(_state.Type.Name).Append(@"
{
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ namespace Thinktecture.CodeAnalysis;
public readonly struct KeyedSerializerGeneratorState : IEquatable<KeyedSerializerGeneratorState>, INamespaceAndName
{
public ITypeInformation Type { get; }
public ITypeFullyQualified? KeyMember { get; }
public IMemberInformation? KeyMember { get; }
public AttributeInfo AttributeInfo { get; }

public string? Namespace => Type.Namespace;
public string Name => Type.Name;

public KeyedSerializerGeneratorState(
ITypeInformation type,
ITypeFullyQualified? keyMember,
IMemberInformation? keyMember,
AttributeInfo attributeInfo)
{
Type = type;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,13 @@ public void Should_deserialize_complex_value_object_with_numbers_as_string()

value.Should().BeEquivalentTo(Boundary.Create(1, 2));
}

[Fact]
public void Should_throw_if_non_string_based_enum_is_used_as_dictionary_key()
{
var options = new JsonSerializerOptions { Converters = { new ValueObjectJsonConverterFactory() } };

FluentActions.Invoking(() => JsonSerializer.Deserialize<Dictionary<TestSmartEnum_Class_IntBased, int>>("""{ "1": 1 }""", options))
.Should().Throw<NotSupportedException>();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Thinktecture.Runtime.Tests.TestEnums;
using Thinktecture.Runtime.Tests.TestValueObjects;
using Thinktecture.Text.Json.Serialization;

namespace Thinktecture.Runtime.Tests.Text.Json.Serialization.ValueObjectJsonConverterFactoryTests;

Expand Down Expand Up @@ -191,4 +195,21 @@ public void Should_deserialize_decimal_from_string_with_corresponding_NumberHand
Serialize<TestValueObjectDecimal, decimal>(obj, numberHandling: JsonNumberHandling.WriteAsString).Should().Be(numberAsStringJson);
Deserialize<TestValueObjectDecimal, decimal>($"\"{number}\"", numberHandling: JsonNumberHandling.AllowReadingFromString).Should().Be(obj);
}

[Fact]
public void Should_roundtrip_serialize_dictionary_with_string_based_enum_key()
{
var dictionary = new Dictionary<TestSmartEnum_Class_StringBased, int>
{
{ TestSmartEnum_Class_StringBased.Value1, 1 },
{ TestSmartEnum_Class_StringBased.Value2, 2 }
};

var options = new JsonSerializerOptions { Converters = { new ValueObjectJsonConverterFactory() } };

var json = JsonSerializer.Serialize(dictionary, options);
var deserializedDictionary = JsonSerializer.Deserialize<Dictionary<TestSmartEnum_Class_StringBased, int>>(json, options);

dictionary.Should().BeEquivalentTo(deserializedDictionary);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,18 @@ public void Should_serialize_complex_value_object_with_ValueObjectValidationErro

value.Should().BeEquivalentTo("{\"lower\":1,\"upper\":2}");
}

[Fact]
public void Should_throw_if_non_string_based_enum_is_used_as_dictionary_key()
{
var dictionary = new Dictionary<TestSmartEnum_Class_IntBased, int>
{
{ TestSmartEnum_Class_IntBased.Value1, 1 }
};

var options = new JsonSerializerOptions { Converters = { new ValueObjectJsonConverterFactory() } };

FluentActions.Invoking(() => JsonSerializer.Serialize(dictionary, options))
.Should().Throw<NotSupportedException>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public partial class TestEnum

namespace Thinktecture.Tests;

[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestEnum, string, global::Thinktecture.ValidationError>))]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestEnum, global::Thinktecture.ValidationError>))]
partial class TestEnum
{
}
Expand Down Expand Up @@ -70,7 +70,7 @@ public partial class TestEnum
// <auto-generated />
#nullable enable

[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::TestEnum, string, global::Thinktecture.ValidationError>))]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::TestEnum, global::Thinktecture.ValidationError>))]
partial class TestEnum
{
}
Expand Down Expand Up @@ -106,7 +106,7 @@ namespace Thinktecture.Tests

namespace Thinktecture.Tests;

[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestEnum, string, global::Thinktecture.ValidationError>))]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestEnum, global::Thinktecture.ValidationError>))]
partial struct TestEnum
{
}
Expand All @@ -130,14 +130,14 @@ public TestEnum_EnumJsonConverter()
: this(null)
{
}

public TestEnum_EnumJsonConverter(
JsonConverter<string>? keyConverter)
: base(TestEnum.Get, keyConverter)
{
}
}

[SmartEnum<string>]
[JsonConverter(typeof(TestEnumJsonConverter))]
public partial class TestEnum
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public partial class TestValueObject

namespace Thinktecture.Tests;

[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestValueObject, string, global::Thinktecture.ValidationError>))]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestValueObject, global::Thinktecture.ValidationError>))]
partial class TestValueObject
{
}
Expand Down Expand Up @@ -67,7 +67,7 @@ public partial class TestValueObject
// <auto-generated />
#nullable enable

[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::TestValueObject, string, global::Thinktecture.ValidationError>))]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::TestValueObject, global::Thinktecture.ValidationError>))]
partial class TestValueObject
{
}
Expand Down Expand Up @@ -102,7 +102,7 @@ namespace Thinktecture.Tests

namespace Thinktecture.Tests;

[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestValueObject, string, global::Thinktecture.ValidationError>))]
[global::System.Text.Json.Serialization.JsonConverterAttribute(typeof(global::Thinktecture.Text.Json.Serialization.ValueObjectJsonConverterFactory<global::Thinktecture.Tests.TestValueObject, global::Thinktecture.ValidationError>))]
partial struct TestValueObject
{
}
Expand Down

0 comments on commit dcd4e25

Please sign in to comment.