0

Using System.Text.Json, how can I serialize (and deserialize) an array of n elements (classes), to an object of n children, where the element name is one of the properties of the object?

For example, by default the following classes

public class Project
{
   public string Name {get;set;}
   public Environment[] Environments {get;set;}
}

public class Environment
{
   public string Name {get;set;}
   public string Region {get;set;}
   public Settings Settings {get;set;}
}
public class Settings
{
   public bool OverrideOnFetch {get;set;}
}

will be serialized as

{
  "name": "MyProject",  
  "environments": [
    {
      "name": "dev",
      "region": "us1",
      "settings": {
        "overrideOnFetch": false
      }
    },
    {
      "name": "prod",
      "region": "us1",
      "settings": {
        "overrideOnFetch": false
      }
    }
  ]
}

But I want to change this behavior and serialize it as

 {
   "name": "MyProject",  
   "environments": {
     "dev": {
       "region": "us1",
       "settings": {
         "overrideOnFetch": false
       }
     },
     "prod": {
       "region": "us1",
       "settings": {
         "overrideOnFetch": false
       }
     }
   }
 }

without changing the classes (or creating another). As you can see, the Environment.Name acts like the property of environments in JSON.

I have no idea where to continue from here

class ProjectJsonConverter : JsonConverter<Project>
{
   public override Project? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
   {
      throw new NotImplementedException();
   }

    public override void Write(Utf8JsonWriter writer, Project value, JsonSerializerOptions options)
    {
        var namePolicy = options.PropertyNamingPolicy ?? new DefaultJsonNamingPolicy();

        writer.WriteStartObject(); //starts the "project" object
        writer.WriteString(JsonEncodedText.Encode(namePolicy.ConvertName(nameof(value.Name))), value.Name);
        //How to write the "value.Settings" without rewriting everything?

        writer.WriteStartObject(); //starts the "environment" object
        foreach(var env in value.Environments)
        {

        }
        writer.WriteEndObject(); //ends the "environment" object

        writer.WriteEndObject(); //ends the "project" object
    }

    class DefaultJsonNamingPolicy : JsonNamingPolicy
    {
        public override string ConvertName(string name)
        {
            return name;
        }
    }
}
JobaDiniz
  • 862
  • 1
  • 14
  • 32
  • You can serialize it as `Dictionary` where each of the keys is a name such as `dev` – Charlieface Aug 08 '22 at 01:00
  • hum, that's interesting, but it does not solve everything because the `Environment.Name` property should not be be serialized. I'm now actually trying to make this generic, so that I create a converter for `array of T` and the converter serialize the array as `dictionary`, given the _property name_ to be the _key_ and to be removed from the `t` serialization. – JobaDiniz Aug 08 '22 at 01:11
  • Yes you could make a converter to do this, but the problem is you have no way of specifying constructor parameters for it, so you can't tell it which property to remove and turn into a key. Will it always be `name` on all classes? – Charlieface Aug 08 '22 at 01:17
  • This can be accomplished with JsonConverterAttribute + my custom JsonConverter. In the attribute you specify the property name as string (using nameof) – JobaDiniz Aug 08 '22 at 02:11

1 Answers1

2

A much simpler solution than manually writing the JSON array, is to serialize and deserialize to/from a Dictionary within your converter. Then simply convert to/from a list or array type.

A generic version of this would be:

public class PropertyToObjectListConverter<T> : JsonConverter<ICollection<T>>
{
    static PropertyInfo _nameProp =
        typeof(T).GetProperties().FirstOrDefault(p => p.GetCustomAttributes(typeof(JsonPropertyToObjectAttribute)).Any())
        ?? typeof(T).GetProperty("Name");

    static Func<T, string> _getter = _nameProp?.GetMethod?.CreateDelegate<Func<T, string>>() ?? throw new Exception("No getter available");
    static Action<T, string> _setter = _nameProp?.SetMethod?.CreateDelegate<Action<T, string>>() ?? throw new Exception("No setter available");

    public override bool CanConvert(Type typeToConvert) =>
        typeof(ICollection<T>).IsAssignableFrom(typeToConvert);

    public override ICollection<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var dict = JsonSerializer.Deserialize<Dictionary<string, T>>(ref reader, options);
        var coll = (ICollection<T>) Activator.CreateInstance(typeToConvert);
        foreach (var kvp in dict)
        {
            var value = kvp.Value;
            _setter(value, kvp.Key);
            coll.Add(value);
        }
        return coll;
    }
    
    public override void Write(Utf8JsonWriter writer, ICollection<T> value, JsonSerializerOptions options)
    {
        var dict = value.ToDictionary(_getter);
        JsonSerializer.Serialize(writer, dict, options);
    }
}

[AttributeUsage(AttributeTargets.Property)]
public class JsonPropertyToObjectAttribute : Attribute
{
}

It looks first for a property with the JsonPropertyToObject attribute, otherwise it tries to find a property called Name. It must be a string type.

Note the use of static getter and setter functions to make the reflection fast, this only works on properties, not fields.

You would use it like this:

public class Project
{
   public string Name {get;set;}
   [JsonConverter(typeof(PropertyToObjectListConverter<Environment>))]
   public Environment[] Environments {get;set;}
}

public class Environment
{
   [JsonIgnore]
   public string Name {get;set;}
   public string Region {get;set;}
   public Settings Settings {get;set;}
}

dotnetfiddle

Charlieface
  • 52,284
  • 6
  • 19
  • 43