Shane Siebken
Shane Siebken

Reputation: 69

JSON.Net Serializing Derived Classes

To work with a recipe webservice I'm developing, I have the following classes to hold and serialize recipe data:

class Recipe {

    public string RecipeId { get; private set; }

    public string UserId { get; set; }
    public string Title { get; set; }

    public IList<string> IngredientsList { get; set; }

    public List<Group<string, Ingredient>> IngredientsWithHeaders { get; set; }
    public List<Group<string, string>> InstructionsWithHeaders { get; set; }

    public List<string> Notes { get; set; }
    public ISet<string> Tags { get; set; }
    public int Yield { get; set; }

    public Recipe(string recipeId)
    {
        RecipeId = recipeId;
        IngredientsWithHeaders = new List<Group<string,Ingredient>>();
        InstructionsWithHeaders = new List<Group<string, string>>();
        IngredientsList = new List<string>();
    }

    public byte[] Image { get; set; }
}

class Ingredient
{
    public string Quantity { get; set; }
    public string Modifier { get; set; }
    public string Unit { get; set; }
    public string IngredientName { get; set; }
    public string Preparation { get; set; }

    public Ingredient(string[] line)
    {
        if (!string.IsNullOrWhiteSpace(line.ElementAt(0)))
        {
            Quantity = line.ElementAt(0);
        }
        if (!string.IsNullOrWhiteSpace(line.ElementAt(1)))
        {
            Unit = line.ElementAt(1);
        }
        if (!string.IsNullOrWhiteSpace(line.ElementAt(2)))
        {
            IngredientName = line.ElementAt(2);
        }
        if(line.Length>3)
        {
            Preparation = line.Last();
        }
    }
}

class Group<K, T> : ObservableCollection<T>
{
    public K Key { get; set; }

    public Group(K key, IEnumerable<T> items) : base(items)
    {
        Key = key;
        Debug.WriteLine(key);
    }
}    

The JSON output I am getting for the List<Group<string, Ingredient>> is

{
"IngredientsWithHeaders": [
    [
        {
            "Quantity": "3",
            "Modifier": null,
            "Unit": "tbsp",
            "IngredientName": "butter",
            "Preparation": null
        },
        {
            "Quantity": "1",
            "Modifier": null,
            "Unit": "16 oz. bag",
            "IngredientName": "marshmallows",
            "Preparation": null
        },
        {
            "Quantity": "2/3",
            "Modifier": null,
            "Unit": "cup",
            "IngredientName": "dry cake mix",
            "Preparation": null
        },
        {
            "Quantity": "6",
            "Modifier": null,
            "Unit": "cups",
            "IngredientName": "crispy rice cereal",
            "Preparation": null
        },
        {
            "Quantity": "1",
            "Modifier": null,
            "Unit": "container",
            "IngredientName": "sprinkles",
            "Preparation": "optional"
        }
        ]
    ]
}

and what I would like to be getting is more along the lines of

{
"IngredientsWithHeaders": [
    {
        "Group": {
            "Header": "BlankHeader",
            "Items": [
                {
                    "Quantity": "3",
                    "Modifier": null,
                    "Unit": "tbsp",
                    "IngredientName": "butter",
                    "Preparation": null
                },
                {
                    "Quantity": "1",
                    "Modifier": null,
                    "Unit": "16 oz. bag",
                    "IngredientName": "marshmallows",
                    "Preparation": null
                },
                {
                    "Quantity": "2/3",
                    "Modifier": null,
                    "Unit": "cup",
                    "IngredientName": "dry cake mix",
                    "Preparation": null
                },
                {
                    "Quantity": "6",
                    "Modifier": null,
                    "Unit": "cups",
                    "IngredientName": "crispy rice cereal",
                    "Preparation": null
                },
                {
                    "Quantity": "1",
                    "Modifier": null,
                    "Unit": "container",
                    "IngredientName": "sprinkles",
                    "Preparation": "optional"
                }
                ]
            }
        }
    ]
}

Do I need to write a custom serializer? If so, how do I go about casting an object to a parameterized Group without knowing if it is

Group<string, Ingredient> 

or

Group<string, string>

?

Upvotes: 1

Views: 1727

Answers (1)

dbc
dbc

Reputation: 116532

The issue here is that your Group<K, T> is a collection that also has properties. Since a JSON container can either be an array (with no properties) or an object (with named key/value pairs), a collection with custom properties cannot be mapped automatically to either without data loss. Json.NET (and all other serializers AFAIK) choose to map the items not the custom properties.

You have a couple ways to deal with this:

  1. Write your own custom JsonConverter. You can determine the generic arguments using reflection along the lines of Json.Net returns Empty Brackets.

  2. Mark your Group<K, T> with [JsonObject].

The second option seems simplest, and would look like:

[JsonObject(MemberSerialization = MemberSerialization.OptIn)] // OptIn to omit the properties of the base class,  e.g. Count
class Group<K, T> : ObservableCollection<T>
{
    [JsonProperty("Header")]
    public K Key { get; set; }

    [JsonProperty("Items")]
    IEnumerable<T> Values
    {
        get
        {
            foreach (var item in this)
                yield return item;
        }
        set
        {
            if (value != null)
                foreach (var item in value)
                    Add(item);
        }
    }

    public Group(K Header, IEnumerable<T> Items) // Since there is no default constructor, argument names should match JSON property names.
        : base(Items)
    {
        Key = Header;
    }
}

Incidentally, you have another problem -- your Ingredient class does not have a default constructor, and its single parameterized throws a NullReferenceException if the line argument is null. In the absence of a default constructor Json.NET will call the single parameterized constructor, mapping JSON object values to constructor arguments by name. Thus, deserialization throws an exception.

You have a few ways to deal with this:

  1. Add a public default constructor.

  2. Add a private default constructor and mark it with [JsonConstructor]:

    [JsonConstructor]
    Ingredient() { }
    
  3. Add a private default constructor and deserialize with ConstructorHandling.AllowNonPublicDefaultConstructor:

    var settings = new JsonSerializerSettings { ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor };
    var recipe = JsonConvert.DeserializeObject<Recipe>(json, settings);
    
  4. Add an if (line != null) check in the constructor. (Not really recommended. Instead your constructor should explicitly throw an ArgumentNullException.)

Having done this, you will gt JSON that looks like:

{
  "IngredientsWithHeaders": [
    {
      "Header": "BlankHeader",
      "Items": [
        {
          "Quantity": "3",
          "Modifier": null,
          "Unit": "tbsp",
          "IngredientName": "butter",
          "Preparation": null
        }
      ]
    }
  ],
}

Your proposed JSON has an extra level of nesting with

{
"IngredientsWithHeaders": [
    {
        "Group": {
            "Header": "BlankHeader",

This extra "Group" object is unnecessary.

Upvotes: 2

Related Questions