Search code examples
c#json.netjson-deserializationconstructor-injection

JSON deserialisation with a CustomCreationConverter to create the type and inject data


I have an task based application that needs to inject information into some tasks. The tasks can be cloned or stored in a save file, in each case the class is serialised as JSON. The application information passed to the tasks is not stored as it only persists the application session.

public interface IApplicationData { }
public class ApplicationData : IApplicationData { }

public interface ITask {
   IApplicationData Data { get; }
}

[DataContract]
public abstract class Task : ITask, ICloneable {
   protected Task(IApplicationData data = null) {
      Data = data;
   }

   public IApplicationData Data { get; }

   public object Clone() {
      var settings = new JsonSerializerSettings() {
         TypeNameHandling = TypeNameHandling.All
      };
      settings.Converters.Add(new TaskCreator(Data));

      var json = JsonConvert.SerializeObject(this, settings);

      // Reflection equivalent of JsonConvert.DeserializeObject<T>(json, settings);
      var expectedParameters = new Type[] { typeof(string), typeof(JsonSerializerSettings) };
      var method = typeof(JsonConvert).GetMethods().Where(mi => mi.IsGenericMethod && mi.IsStatic && mi.IsPublic && mi.GetParameters().Select(pi => pi.ParameterType).SequenceEqual(expectedParameters)).Single();
      return method.MakeGenericMethod(this.GetType()).Invoke(null, new object[] { json, settings });
   }
}

Tasks can 'opt-in' to holding the application data or not so might look something like the below:

public class NoDataTask : Task {
   public NoDataTask() { }
}

public class DataTask : Task {
   public DataTask(IApplicationData data) : base(data) { }
}

I implemented a CustomCreationConverter to create a new instance of the relevant class when deserialising from JSON (you may have noticed this is being utilised in the Clone() implementation in the Task base class shown above.

public class TaskCreator : CustomCreationConverter<Task> {
   //public TaskCreator() { } // uncomment to try using converter with JsonProperty attribute in Project

   private readonly IApplicationData _data;
   public TaskCreator(IApplicationData data) {
      _data = data;
   }

   public override Task Create(Type objectType) {
      var hasDataConstructor = objectType.GetConstructor(new Type[] { typeof(IApplicationData) }) != null;
      return hasDataConstructor ? (Task)Activator.CreateInstance(objectType, _data) : (Task)Activator.CreateInstance(objectType);
   }
}

This works exactly as required on the Clone() method, the objectType received is of the DerivedClass (DataTask in the below example)

var data = new ApplicationData();
var dataTask = new DataTask(data);
var dataTaskCloneData = ((DataTask)dataTask.Clone()).Data; // still has data intact - excellent

However I am unsure as to how to make this work for the case of storing the tasks. I currently have a Project class that contains a List<ITask> that I serialise / de-serialise. That works perfectly with respect to the data in each task, however I have been unable to inject the ApplicationData into the deserialised task instances.

[DataContract]
public class Project {
   [DataMember]
   //[JsonProperty(ItemConverterType = typeof(TaskCreator))] // uncomment to force use of converter
   public List<ITask> Tasks { get; set; }
}
var project = new Project {
   Tasks = new List<ITask> {
      new NoDataTask(),
      new DataTask(data)
   }
};

var serialiserSettings = new JsonSerializerSettings {
   TypeNameHandling = TypeNameHandling.All
};
serialiserSettings.Converters.Add(new TaskCreator(data));

var json = JsonConvert.SerializeObject(project, serialiserSettings);
var projectCopy = JsonConvert.DeserializeObject<Project>(json, serialiserSettings);

var projectCopyTask2Data = projectCopy.Tasks[1].Data; // data is null - bad

I have found that due to the project containing a List<ITask> the converter isn't utilised. I could add a converter of CustomCreationConverter<ITask> but either way the objectType passed to the converter is always of type ITask whereas I need to derived class to be able to create an appropriate new instance.

Adding the [JsonProperty] attribute provides the ability for the converter to be used as-is, but I'm unaware of a method that I can apply this without it using the parameterless constructor which is useless given my implementation as the IApplicationData would always be null.

.NET Fiddle example here - https://dotnetfiddle.net/WdyfDv


Solution

  • I have been able to solve my issue by writing my own JsonConverter (based heavily on CustomCreationConverter in Newtonsoft.Json.Converters - GitHub link) as follows:

    public class TaskCreator : JsonConverter<ITask> {
        private readonly IApplicationData _data;
        public TaskCreator(IApplicationData data) {
            _data = data;
        }
    
        public override ITask ReadJson(JsonReader reader, Type objectType, [AllowNull] ITask existingValue, bool hasExistingValue, JsonSerializer serializer) {
            if (reader.TokenType == JsonToken.Null) {
                return null;
            }
    
            // Determine and create the task by reading the type in the JSON
            var jObj = JObject.Load(reader);
            var jsonType = jObj["$type"]?.ToString();
            if (string.IsNullOrWhiteSpace(jsonType)) throw new JsonSerializationException("Cannot determine type of task to create.");
            var type = Type.GetType(jsonType);
            if (type == null) throw new JsonSerializationException($"Could not find the task type {jsonType}");
            var value = Create(type);
            if (value == null) throw new JsonSerializationException("No object created.");
    
            reader = jObj.CreateReader();
            serializer.Populate(reader, value);
            return value;
        }
    
        /// <summary>
        /// Creates an object which will then be populated by the serializer.
        /// </summary>
        /// <param name="objectType">Type of the object.</param>
        /// <returns>The created object.</returns>
        public ITask Create(Type objectType) {
            var hasDataConstructor = objectType.GetConstructor(new Type[] { typeof(IApplicationData) }) != null;
            return hasDataConstructor ? (ITask)Activator.CreateInstance(objectType, _data) : (ITask)Activator.CreateInstance(objectType);
        }
    
        /// <summary>
        /// Writes the JSON representation of the object.
        /// </summary>
        /// <param name="writer">The <see cref="JsonWriter"/> to write to.</param>
        /// <param name="value">The value.</param>
        /// <param name="serializer">The calling serializer.</param>
        public override void WriteJson(JsonWriter writer, [AllowNull] ITask value, JsonSerializer serializer) {
            throw new NotSupportedException($"{ nameof(TaskCreator) } should only be used while deserializing.");
        }
    
        /// <summary>
        /// Gets a value indicating whether this <see cref="JsonConverter"/> can write JSON.
        /// </summary>
        /// <value>
        ///     <c>true</c> if this <see cref="JsonConverter"/> can write JSON; otherwise, <c>false</c>.
        /// </value>
        public override bool CanWrite => false;
    }
    

    The 'magic' happens in ReadJson() where the derived class of ITask is pulled out from the json '$type' and created using reflection. This does require that TypeNameHandling is set to TypeNameHandling.Objects, which is it in my serialiser settings.

    To use this I can remove the JsonProperty attribute from the Project class and ensure that the JsonSerializerSettings includes the converter as so:

    var data = new ApplicationData("Hello World");
    var project = new Project {
        Tasks = new List<ITask> {
            new NoDataTask(),
            new DataTask(data)
        }
    };
    var serialiserSettings = new JsonSerializerSettings {
        TypeNameHandling = TypeNameHandling.All
    };
    serialiserSettings.Converters.Add(new TaskCreator(data));
    var json = JsonConvert.SerializeObject(project, serialiserSettings);
    var projectCopy = JsonConvert.DeserializeObject<Project>(json, serialiserSettings);
    

    A fully working example (.NET fiddle ) here - https://dotnetfiddle.net/Ecrz2S

    I'm still very much open to alternative approaches if anyone has one to suggest, as this solution does still feel a little 'hacky' to me.