C# Polymorphic Deserialization of Bookmarks file

82 Views Asked by At

I am trying to deserialize a Bookmarks file. Specifically using polymorphic deserialization without Newtonsoft. When running I get an exception in the Read method of the converter class, at return for case "folder". It looks like I need some sort of constructor for either folder or its base class. I tried using the [JsonConstructor] attribute in each class but no luck.

Additionally, when I omit the getter and setter for Folder's FolderElement list the program compiles and runs but in the JSON output only the objects of type 'folder' are created, and they are missing the 'children' properties.

Model

public abstract class BookmarkElement
{
    public BookmarkElement() {}
    [JsonPropertyName("date_added")]
    public string DateAdded { get; set; }
    [JsonPropertyName("date_last_used")]
    public string DateLastUsed { get; set; }
    [JsonPropertyName("guid")]
    public string Guid { get; set; }
    [JsonPropertyName("id")]
    public string Id { get; set; }
    [JsonPropertyName("name")]
    public string Name { get; set; }
    [JsonPropertyName("type")]
    public string Type { get; set; }
}

public class Bookmark : BookmarkElement
{
    public Bookmark() { }
    [JsonPropertyName("url")]
    public string Url {get; set;}
}

public class Folder : BookmarkElement
{
    public Folder() {}
    [JsonPropertyName("date_modified")]
    public string DateModified { get; set; }
    [JsonPropertyName("children")]
    public List<BookmarkElement> FolderElements {get; set;}
}

public class Root : BookmarkElement
{
    public Root() {}
    [JsonPropertyName("date_modified")]
    public string DateModified { get; set; }
    [JsonPropertyName("children")]
    public List<BookmarkElement> RootFolder { get; set; }
}

public class BookmarkModel
{
    public BookmarkModel() {}
    [JsonPropertyName("checksum")]
    public string Checksum { get; set; }
    [JsonPropertyName("roots")]
    public Dictionary<string, Root> Roots { get; set; }
    [JsonPropertyName("version")]
    public int Version { get; set; }
}

JSON Converter

public class BookmarkElementConverter : JsonConverter<BookmarkElement>
{
    public override bool CanConvert(Type typeToConvert) => 
        typeof(BookmarkElement).IsAssignableFrom(typeToConvert);

    public override BookmarkElement? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if(reader.TokenType != JsonTokenType.StartObject) throw new JsonException();

        using (var jsonDocument = JsonDocument.ParseValue(ref reader))
        {
            if(!jsonDocument.RootElement.TryGetProperty("type", out var typeProperty)) throw new JsonException();

            var jsonType = jsonDocument.RootElement.GetRawText();

            switch(typeProperty.GetString())
            {
                case "url":
                    return (Bookmark)JsonSerializer.Deserialize(jsonType, typeof(Bookmark));
                case "folder":
                    return (Folder)JsonSerializer.Deserialize(jsonType, typeof(Folder));
                default:
                    throw new JsonException();
            }
        }
    }

    public override void Write(Utf8JsonWriter writer, BookmarkElement value, JsonSerializerOptions options)
    {
        if (value is Bookmark bookmark)
        {
            JsonSerializer.Serialize(writer, bookmark);
        }
        else if (value is Folder folder)
        {
            JsonSerializer.Serialize(writer, folder);
        }
    }
}

Sample Bookmarks file

{
   "checksum": "cc1f5c62ec7814f7928e2befab26c311",
   "roots": {
      "bookmark_bar": {
         "children": [ {
            "children": [  ],
            "date_added": "13335767383821356",
            "date_last_used": "0",
            "date_modified": "13335767383821356",
            "guid": "efeb5549-612d-4656-8982-a17069075213",
            "id": "13",
            "name": "test",
            "type": "folder"
         }, {
            "date_added": "13335767548044529",
            "date_last_used": "0",
            "guid": "df7a482b-c1c5-4aa2-af8e-8cee539513d9",
            "id": "15",
            "name": "DuckDuckGo — Privacy, simplified.",
            "type": "url",
            "url": "https://duckduckgo.com/"
         } ],
         "date_added": "13335764354355200",
         "date_last_used": "0",
         "date_modified": "13335767548044529",
         "guid": "0bc5d13f-2cba-5d74-951f-3f233fe6c908",
         "id": "1",
         "name": "Bookmarks bar",
         "type": "folder"
      },
      "other": {
         "children": [  ],
         "date_added": "13335764354355201",
         "date_last_used": "0",
         "date_modified": "0",
         "guid": "82b081ec-3dd3-529c-8475-ab6c344590dd",
         "id": "2",
         "name": "Other bookmarks",
         "type": "folder"
      },
      "synced": {
         "children": [  ],
         "date_added": "13335764354355202",
         "date_last_used": "0",
         "date_modified": "0",
         "guid": "4cf2e351-0e85-532b-bb37-df045d8f8d0f",
         "id": "3",
         "name": "Mobile bookmarks",
         "type": "folder"
      }
   },
   "version": 1
}

Exception

Exception has occurred: CLR/System.NotSupportedException
An exception of type 'System.NotSupportedException' occurred in System.Text.Json.dll but was not handled in user code: 'Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'BookmarkReader.Model.BookmarkElement'. Path: $.children[0] | LineNumber: 1 | BytePositionInLine: 24.'
 Inner exceptions found, see $exception in variables window for more details.
 Innermost exception     System.NotSupportedException : Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'BookmarkReader.Model.BookmarkElement'.
1

There are 1 best solutions below

7
On BEST ANSWER

The error is caused because, at some point, your converter is not getting picked up, and your code is attempting to deserialize an object of type BookmarkElement, which is abstract, and so cannot be constructed. Microsoft's error message text is misleading, so see demo fiddle #1 which completely omits the converter for confirmation.

To resolve the problem, fix BookmarkElementConverter().Read() so that the incoming options (which will include BookmarkElementConverter in the converters list) are passed into JsonSerializer.Deserialize(). You need to do this because your BookmarkElement data model is recursive and thus the converter must also be invoked recursively. And you must also fix CanConvert() to return true only for the abstract type BookmarkElement. This is the default behavior so you can just remove your override. The converter is not necessary for the derived concrete types, and if you attempt to use the converter for the derived types you will get a stack overflow exception.

Thus BookmarkElementConverter should look like the following, with some code simplifications:

public class BookmarkElementConverter : JsonConverter<BookmarkElement>
{
    public override BookmarkElement? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if(reader.TokenType != JsonTokenType.StartObject) 
            throw new JsonException();

        using (var jsonDocument = JsonDocument.ParseValue(ref reader))
        {
            if(!jsonDocument.RootElement.TryGetProperty("type", out var typeProperty)) 
                throw new JsonException();
            return typeProperty.GetString() switch
            {
                "url" => jsonDocument.RootElement.Deserialize<Bookmark>(options),
                "folder" => jsonDocument.RootElement.Deserialize<Folder>(options),
                _ => throw new JsonException(),
            };
        }
    }

    public override void Write(Utf8JsonWriter writer, BookmarkElement value, JsonSerializerOptions options) =>
        JsonSerializer.Serialize(writer, value, value.GetType(), options);
}

Then when you deserialize, be sure to include BookmarkElementConverter in your JsonSerializerOptions.Converters:

var options = new JsonSerializerOptions
{
    Converters = { new BookmarkElementConverter() },
    // Add any additional required options here:
    WriteIndented = true,
};

var model = JsonSerializer.Deserialize<BookmarkModel>(json, options);

Demo fiddle #2 here.