Map JSON string array of headers and nested array of results to C# Generic

64 Views Asked by At

I am working with a service that returns the following JSON. While I can use Newtonsoft to deserialize into a C# Generic, the string array of headers, then nested array of results has thrown me a curveball. Instead of mapping to a string array, I would really like to have a List that are strongly typed and mapped.

{
    "rootEntity": "function",
    "count": 2,
    "header": [
        "name",
        "email",
        "function"
    ],
    "results": [
        [
            "12 HOURS",
            "[email protected]",
            "testing"
        ],
        [
            "Example",
            "[email protected]",
            "second"
        ]
    ]
} 

The service does allow you to add more fields, which is why it includes both the headers and then results, but those results will not be strongly typed.

My goal is to have a model that looks like:

public class EventResults
{
    public string? RootEntity { get; set; }
    public int Count { get; set; }
    public List<Events> Events { get; set; }
}

public class Events
{
    public string Name  { get; set; }
    public string Email { get; set; }
    public string Function { get; set; }
}

Is there a way to better convert this to a C# Object without hoping that same name is always in string[0]?

1

There are 1 best solutions below

0
dbc On

You have some data model like this:

public class EventResults
{
    public string? RootEntity { get; set; }
    public int Count { get; set; }
    public List<Events> Events { get; set; }
}

public class Events
{
    public string Name  { get; set; }
    public string Email { get; set; }
    public string Function { get; set; }
}

And you would like to deserialize JSON to this model that has been "compressed" by converting the Events array of objects to an array of array of property values named "results", and moving the property names of each event object to a separate array of strings called "headers".

You can do this with a generic converter such as the following:

public class ObjectWithResultArrayConverter<TModel> : JsonConverter<TModel> where TModel : class, new()
{
    protected virtual string HeadersName => "header";
    protected virtual string ResultsName => "results";
    
    public override TModel? ReadJson(JsonReader reader, Type objectType, TModel? existingValue, bool hasExistingValue, JsonSerializer serializer)
    {
        if (!(serializer.ContractResolver.ResolveContract(objectType) is JsonObjectContract contract))
            throw new NotImplementedException("Contract is not a JsonObjectContract");
        if (reader.MoveToContentAndAssert().TokenType == JsonToken.Null)
            return null;
        var jObj = JObject.Load(reader);

        // Transform "results" from an array of array of values to an array of objects:
        var headers = jObj.GetValue(HeadersName, StringComparison.OrdinalIgnoreCase).RemoveFromLowestPossibleParent() as JArray;
        var resultsProperty = jObj.Property(ResultsName, StringComparison.OrdinalIgnoreCase).RemoveFromLowestPossibleParent();
        if (headers != null && resultsProperty?.Value is JArray results)
        {
            var resultsJArray = new JArray(results.Children<JArray>().Select(innerArray => new JObject(headers.Zip(innerArray, (h, i) => new JProperty((string)h!, i)))));
            jObj[resultsProperty.Name] = resultsJArray;
        }
        
        // Construct the TModel and populate it with the transformed JSON values.
        var value = existingValue ?? (TModel?)contract.DefaultCreator?.Invoke() ?? new TModel();
        serializer.Populate(jObj.CreateReader(), value);
        return value;
    }

    public override bool CanWrite => false;
    public override void WriteJson(JsonWriter writer, TModel? value, JsonSerializer serializer) => throw new NotImplementedException();
}

public static partial class JsonExtensions
{
    public static JsonReader MoveToContentAndAssert(this JsonReader reader)
    {
        ArgumentNullException.ThrowIfNull(reader);
        if (reader.TokenType == JsonToken.None)       // Skip past beginning of stream.
            reader.ReadAndAssert();
        while (reader.TokenType == JsonToken.Comment) // Skip past comments.
            reader.ReadAndAssert();
        return reader;
    }

    public static JsonReader ReadAndAssert(this JsonReader reader)
    {
        ArgumentNullException.ThrowIfNull(reader);
        if (!reader.Read())
            throw new JsonReaderException("Unexpected end of JSON stream.");
        return reader;
    }

    public static TJToken? RemoveFromLowestPossibleParent<TJToken>(this TJToken? node) where TJToken : JToken
    {
        if (node == null)
            return null;
        JToken toRemove;
        var property = node.Parent as JProperty;
        if (property != null)
        {
            // Also detach the node from its immediate containing property -- Remove() does not do this even though it seems like it should
            toRemove = property;
            property.Value = null!;
        }
        else
        {
            toRemove = node;
        }
        if (toRemove.Parent != null)
            toRemove.Remove();
        return node;
    }
}

Then add [JsonProperty("results")] to the Events property:

[JsonProperty("results")] // ADD THIS
public List<Events> Events { get; set; }

Finally deserialize by adding the converter to JsonSerializerSettings.Converters e.g. as follows:

var settings = new JsonSerializerSettings
{
    Converters = { new ObjectWithResultArrayConverter<EventResults>() },
    // Add other settings as required, e.g.
    ContractResolver = new CamelCasePropertyNamesContractResolver(),
};
var model = JsonConvert.DeserializeObject<EventResults>(jsonString, settings);

Notes:

  • The converter assumes that the object has a Results property. This assumption can be changed by overriding ResultsName.

  • Serialization in the same format is not implemented. Your model will be re-serialized using default serialization as follows:

    {
      "rootEntity": "function",
      "count": 2,
      "results": [
        {
          "name": "12 HOURS",
          "email": "[email protected]",
          "function": "testing"
        },
        {
          "name": "Example",
          "email": "[email protected]",
          "function": "second"
        }
      ]
    }
    
  • The question Deserializing JSON containing an array of headers and a separate nested array of rows deals with a similar situation where the "results" array is to be mapped to a List<Dictionary<string, string>>.

Demo fiddle here.