https://stackoverflow.com/questions/78216715/serializing-custom-types-in-a-c-sharp-concurrentdictionary

CSharp · Console

/Program.cs

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Runtime.Serialization.Formatters;
using System.IO;
using System.Data;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Reflection;
using System.Collections.ObjectModel;
using System.Collections.Concurrent;

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.Json.Nodes;

using NUnit.Framework; // Throws NUnit.Framework.AssertionException

//https://stackoverflow.com/questions/78216715/serializing-custom-types-in-a-c-sharp-concurrentdictionary

#nullable enable

	public class DictionaryValueConverterDecorator<TItemConverter> : JsonConverterFactory where TItemConverter : JsonConverter, new()
	{
		readonly TItemConverter itemConverter = new TItemConverter();

		public override bool CanConvert(Type typeToConvert) =>
			GetStringKeyedDictionaryValueType(typeToConvert) != null && GetConcreteTypeToConvert(typeToConvert) != null;

		public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
		{
			if (!(GetStringKeyedDictionaryValueType(typeToConvert) is {} valueType)
				|| !(GetConcreteTypeToConvert(typeToConvert) is {} concreteTypeToConvert))
				throw new ArgumentException($"Invalid type {typeToConvert}");
			// Clone the incoming options and insert the item converter at the beginning of the clone.
			// Then if converter is actually a JsonConverterFactory (e.g. JsonStringEnumConverter) then the correct JsonConverter<T> will be manufactured or fetched.
			var modifiedOptions = new JsonSerializerOptions(options);
			modifiedOptions.Converters.Insert(0, itemConverter);
			var actualInnerConverter = modifiedOptions.GetConverter(valueType);
			return (JsonConverter)Activator.CreateInstance(typeof(DictionaryValueConverterDecoratorInner<,>).MakeGenericType(new [] { concreteTypeToConvert, valueType }), new object [] { actualInnerConverter })!;
		}
		
		static Type? GetStringKeyedDictionaryValueType(Type typeToConvert)
		{
			if (!(typeToConvert.GetDictionaryKeyValueType() is {} types))
				return null;
			if (types[0] != typeof(string))
				return null;
			return types[1];
		}
		
		static Type? GetConcreteTypeToConvert(Type typeToConvert) =>
			typeToConvert.IsInterface switch
			{
				false when typeToConvert.GetConstructor(Type.EmptyTypes) != null => 
					typeToConvert,
				true when typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() is var generic && (generic == typeof(IDictionary<,>) || generic == typeof(IReadOnlyDictionary<,>)) => 
					typeof(Dictionary<,>).MakeGenericType(typeToConvert.GetGenericArguments()),
				_ => null,
			};
	}

	internal class DictionaryValueConverterDecoratorInner<TDictionary, TValue> : JsonConverter<TDictionary> where TDictionary : IDictionary<string, TValue>, new()
	{
		readonly JsonConverter<TValue> innerConverter;

		public DictionaryValueConverterDecoratorInner(JsonConverter<TValue> innerConverter) => this.innerConverter = innerConverter ?? throw new ArgumentNullException(nameof(innerConverter));
		
	    public override TDictionary? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 
		{
			if (reader.TokenType == JsonTokenType.Null)
				return (TDictionary?)(object?)null;
			else if (reader.TokenType != JsonTokenType.StartObject)
				throw new JsonException();
			var dictionary = new TDictionary();
			while (reader.Read())
			{
				if (reader.TokenType == JsonTokenType.EndObject)
					break;
				else if (reader.TokenType != JsonTokenType.PropertyName)
					throw new JsonException();
				var key = reader.GetString().ThrowOnNull();
				reader.ReadAndAssert();
				var value = innerConverter.Read(ref reader, typeof(TValue), options);
				dictionary.Add(key, value!);
			}
			return dictionary;
		}

		public override void Write(Utf8JsonWriter writer, TDictionary value, JsonSerializerOptions options)
		{
			writer.WriteStartObject();
			foreach (var pair in value)
			{
				writer.WritePropertyName(pair.Key);
				if (value == null && !innerConverter.HandleNull)
					writer.WriteNullValue();
				else
					innerConverter.Write(writer, pair.Value, options);
			}
			writer.WriteEndObject();
		}
	}

	public static partial class JsonExtensions
	{
		public static ref Utf8JsonReader ReadAndAssert(ref this Utf8JsonReader reader) { if (!reader.Read()) { throw new JsonException(); } return ref reader; }
		public static T ThrowOnNull<T>(this T? value, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(value))] string? paramName = null) where T : class => value ?? throw new ArgumentNullException(paramName);
	}

    public static class TypeExtensions
    {
        public static IEnumerable<Type> GetInterfacesAndSelf(this Type type)
			=> (type ?? throw new ArgumentNullException()).IsInterface ? new[] { type }.Concat(type.GetInterfaces()) : type.GetInterfaces();

        public static IEnumerable<Type []> GetDictionaryKeyValueTypes(this Type type)
			=> type.GetInterfacesAndSelf().Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IDictionary<,>)).Select(t => t.GetGenericArguments());

        public static Type []? GetDictionaryKeyValueType(this Type type)
			=> type.GetDictionaryKeyValueTypes().SingleOrDefaultIfMultiple();
    }

	public static class LinqExtensions
	{
		// Copied from this answer https://stackoverflow.com/a/25319572
		// By https://stackoverflow.com/users/3542863/sean-rose
		// To https://stackoverflow.com/questions/3185067/singleordefault-throws-an-exception-on-more-than-one-element
		public static TSource? SingleOrDefaultIfMultiple<TSource>(this IEnumerable<TSource> source)
		{
			var elements = source.Take(2).ToArray();
			return (elements.Length == 1) ? elements[0] : default(TSource);
		}
	}

	//=======================================

	public partial class Model
	{
		//[JsonProperty(PropertyName = "substances", ItemConverterType = typeof(SaturationJsonConverter))]
		[JsonPropertyName("substances"), 
		 JsonInclude, 
		 JsonConverter(typeof(DictionaryValueConverterDecorator<SaturationJsonConverter>))]
		protected ConcurrentDictionary<string, Saturation> Substances { get; private set; } = new();		
	}

	public partial class Model
	{
		[JsonIgnore]
		public IDictionary<string, Saturation> SubstancesSurrogate => Substances;
	}

	public class Saturation
	{
		public string? Value { get; set; }
	}

	public class SaturationJsonConverter : JsonConverter<Saturation>
	{
	    public override Saturation? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
			new Saturation { Value = reader.GetString() };

		public override void Write(Utf8JsonWriter writer, Saturation? value, JsonSerializerOptions options) => 
			// Write the value as a string
			writer.WriteStringValue(value?.Value);
	}


    class TestClass
    {
        public static void Test()
        {
			var model = new Model
			{
				SubstancesSurrogate = 
				{
					["Item1"] = new Saturation { Value  = "Item1" },
					["Item2"] = new Saturation { Value  = "Item2" },
				},
			};
			
			var json = JsonSerializer.Serialize(model);
			
			Console.WriteLine(json);

			Assert.AreEqual("""{"substances":{"Item2":"Item2","Item1":"Item1"}}""", json);
	
			var model2 = JsonSerializer.Deserialize<Model>(json);

			var json2 = JsonSerializer.Serialize(model2);
			
			Assert.That(JsonNode.DeepEquals(JsonNode.Parse(json), JsonNode.Parse(json2)));
			
			Assert.That(model.SubstancesSurrogate.Count == model2?.SubstancesSurrogate.Count);
		}
	}

	public class Program
	{
		public static void Main()
		{
			Console.WriteLine("Environment version: {0} ({1}), {2}", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription , Environment.Version, Environment.OSVersion);
			Console.WriteLine("System.Text.Json version: " + typeof(JsonSerializer).Assembly.FullName);
			Console.WriteLine();

			try
			{
				TestClass.Test();
			}
			catch (Exception ex)
			{
				Console.WriteLine("Failed with unhandled exception: ");
				Console.WriteLine(ex);
				throw;
			}
		}
	}
;