Deserialize JSON property when it can be a single string or a jarray with Newtonsoft

Viewed 313

I am retrieving a JSON response from a web server and have created strongly typed classes for all properties without any issue, except for one property that can be a single string value or a nested array.

Example of the JSON:

"meta_data": [
{
  "id": 12772,
  "key": "_shipping_phone",
  "value": ""
},
{
  "id": 12786,
  "key": "status",
  "value": "completed"
},
{
  "id": 12788,
  "key": "Payment type",
  "value": "instant"
},
{
  "id": 12796,
  "key": "_transaction_fee",
  "value": "0.39"
},
{
  "id": 12806,
  "key": "connect_destination_normalized",
  "value": "1"
},
{
  "id": 12807,
  "key": "wc_connect_labels",
  "value": [
    {
      "label_id": 1633947,
      "tracking": "9400***************",
      "refundable_amount": 3.930000000000000159872115546022541821002960205078125,
      "created": 1589499950667,
      "carrier_id": "usps",
      "service_name": "USPS - First Class Mail",
      "status": "PURCHASED",
      "package_name": "Bubble Mailer",
      "product_names": [
        "Peace Love And Wine Sub Tshirt"
      ],
      "receipt_item_id": 60476008,
      "created_date": 1589499955000,
      "main_receipt_id": 46201718,
      "rate": 3.930000000000000159872115546022541821002960205078125,
      "currency": "USD",
      "expiry_date": 1605051955000,
      "label_cached": 1589499962000
    },
    {
      "label_id": 1633942,
      "tracking": null,
      "refundable_amount": 0,
      "created": 1589499912741,
      "carrier_id": null,
      "service_name": "USPS - First Class Mail",
      "status": "PURCHASE_ERROR",
      "package_name": "Bubble Mailer",
      "product_names": [
        "Peace Love And Wine Sub Tshirt"
      ],
      "receipt_item_id": -1,
      "created_date": 1589499912000,
      "error": "The transaction was declined."
    },
    {
      "label_id": 1633913,
      "tracking": null,
      "refundable_amount": 0,
      "created": 1589499712367,
      "carrier_id": null,
      "service_name": "USPS - First Class Mail",
      "status": "PURCHASE_ERROR",
      "package_name": "Bubble Mailer",
      "product_names": [
        "Peace Love And Wine Sub Tshirt"
      ],
      "receipt_item_id": -1,
      "created_date": 1589499712000,
      "error": "The transaction was declined."
    }
  ]
}

As you can see, the value key is a normal string value (or blank string). However, it can be much more as the final entry shows.

Here are my strongly typed classes:

Public Class Meta_Data
    Public Property id As Integer
    Public Property key As String
    '<JsonConverter(GetType(SingleOrArrayConverter(Of Values())))>
    Public Property value As Values()
End Class

Public Class Values
    Public Property label_id As Integer
    Public Property tracking As String
    Public Property refundable_amount As String
    Public Property created As String
    Public Property carrier_id As String
    Public Property service_name As String
    Public Property status As String
    Public Property package_name As String
    Public Property product_names As String()
    Public Property receipt_item_id As String
    Public Property created_date As String
    Public Property [error] As String
    Public Property main_receipt_id As String
    Public Property rate As String
    Public Property currency As String
    Public Property expiry_date As String
    Public Property label_cached As String
End Class

I've scoured the internet for a solution and was not able to come up with one. I've tried the different converter examples I've found, but none work as they all still try to pass the single strings through the Values class.

Here is my deserialize call:

Dim info As Order = JsonConvert.DeserializeObject(Of Order)(responseFromServer)

I have multiple other classes that I wasn't going to take up space showing, but the root class is called Order. Basically, is there a way to check value to see if it's a string while processing the deserialization and prevent it from processing through the Values class?

2 Answers

Since you want to treat your single string value differently than the array, you need to change your model so that it is polymorphic. That is, make a base class which contains the id and key properties common to all meta_data items, then make subclasses to represent the string and array variants. So, something like this:

Public Class Order
    Public Property meta_data As List(Of BaseMetaData)
End Class

<JsonConverter(GetType(MetaDataConverter))>
Public Class BaseMetaData
    Public Property id As Integer
    Public Property key As String
End Class

Public Class StringMetaData
    Inherits BaseMetaData
    Public Property value As String
End Class

Public Class ComplexMetaData
    Inherits BaseMetaData
    Public Property value As List(Of Values)
End Class

Public Class Values
    Public Property label_id As Integer
    Public Property tracking As String
    Public Property refundable_amount As String
    Public Property created As String
    Public Property carrier_id As String
    Public Property service_name As String
    Public Property status As String
    Public Property package_name As String
    Public Property product_names As String()
    Public Property receipt_item_id As String
    Public Property created_date As String
    Public Property [error] As String
    Public Property main_receipt_id As String
    Public Property rate As String
    Public Property currency As String
    Public Property expiry_date As String
    Public Property label_cached As String
End Class

Then you can make a custom JsonConverter for the base class which detects the value type and creates the correct subclass:

Public Class MetaDataConverter
    Inherits JsonConverter

    Public Overrides Function CanConvert(objectType As Type) As Boolean
        Return GetType(BaseMetaData).IsAssignableFrom(objectType)
    End Function

    Public Overrides Function ReadJson(reader As JsonReader, objectType As Type, existingValue As Object, serializer As JsonSerializer) As Object
        Dim jo As JObject = JObject.Load(reader)
        Dim val As JToken = jo("value")
        Dim meta As BaseMetaData
        If val.Type = JTokenType.Array Then
            meta = New ComplexMetaData()
        Else
            meta = New StringMetaData()
        End If
        serializer.Populate(jo.CreateReader(), meta)
        Return meta
    End Function

    Public Overrides ReadOnly Property CanWrite As Boolean
        Get
            Return False
        End Get
    End Property

    Public Overrides Sub WriteJson(writer As JsonWriter, value As Object, serializer As JsonSerializer)
        Throw New NotImplementedException()
    End Sub
End Class

The last piece is to mark the BaseMetaData with a JsonConverter attribute, which I have already done in the model declarations above. You can then deserialize as you normally would and everything should "just work".

Here is a working demo: https://dotnetfiddle.net/3uYXKu

Option 1

Use a Structure that defined two different types: a String and a List(Of Class) (where Class is the class object generated when value is an complex object).

The main class is named OrderRoot, it contains:

  • The definition of all other classes needed by the Json deserializer to generate the corresponding .Net types
  • Two custom converters:
    • OrderTypeConverter used to deserialize/serialize the value property to either a String type or a List(Of class) and serialize it back to the orginal value, if needed
    • ValueObjectDateConverter, used to convert the Unix dates in .Net DateTime format and back to Unix long date format when serializing.
  • Custom Deserialize() and Serialize() methods, to have a compact class object that calls the corresponding Newtonsoft.Json methods without further intervention of external code.

► The Value property is now a double faced property: when its StringValue field is null or empty, you know that its ArrayValue Field contains the List(Of Value) objects.


To Deserialize the server response:

Dim orderInfo = New OrderRoot.OrderInfo().Deserialize(responseFromServer)

To Serialize it back to the original JSON format:

Dim json As String = orderInfo.Serialize()

The OrderRoot class object definition:

Imports System.Globalization
Imports Newtonsoft.Json
Imports Newtonsoft.Json.Converters

Public Class OrderRoot
    Public Class OrderInfo
        <JsonProperty("meta_data")>
        Public Property MetaData As List(Of MetaData)
        Public Function Deserialize(json As String) As OrderInfo
            Return JsonConvert.DeserializeObject(Of OrderInfo)(json, OrderTypeConverter.Settings)
        End Function

        Public Function Serialize() As String
            Return JsonConvert.SerializeObject(Me, Formatting.Indented, OrderTypeConverter.Settings)
        End Function
    End Class

    Public Class MetaData
        <JsonProperty("id")>
        Public Property Id As Long
        <JsonProperty("key")>
        Public Property Key As String
        <JsonProperty("value")>
        Public Property Value As ValueMetaObject
    End Class

    Partial Public Class ValueObject
        <JsonProperty("label_id")>
        Public Property LabelId As Long
        <JsonProperty("tracking")>
        Public Property Tracking As String
        <JsonProperty("refundable_amount")>
        Public Property RefundableAmount As Double
        <JsonConverter(GetType(ValueObjectDateConverter))>
        <JsonProperty("created")>
        Public Property Created As DateTimeOffset?
        <JsonProperty("carrier_id")>
        Public Property CarrierId As String
        <JsonProperty("service_name")>
        Public Property ServiceName As String
        <JsonProperty("status")>
        Public Property Status() As String
        <JsonProperty("package_name")>
        Public Property PackageName As String
        <JsonProperty("product_names")>
        Public Property ProductNames As List(Of String)
        <JsonProperty("receipt_item_id")>
        Public Property ReceiptItemId As Long
        <JsonConverter(GetType(ValueObjectDateConverter))>
        <JsonProperty("created_date")>
        Public Property CreatedDate As DateTimeOffset?
        <JsonProperty("main_receipt_id")>
        Public Property MainReceiptId As Long
        <JsonProperty("rate")>
        Public Property Rate As Double
        <JsonProperty("currency")>
        Public Property Currency As String
        <JsonConverter(GetType(ValueObjectDateConverter))>
        <JsonProperty("expiry_date")>
        Public Property ExpiryDate As DateTimeOffset?
        <JsonConverter(GetType(ValueObjectDateConverter))>
        <JsonProperty("label_cached")>
        Public Property LabelCached As DateTimeOffset?
        <JsonProperty("error")>
        Public Property ErrorDescription As String
    End Class

    Public Structure ValueMetaObject
        Public StringValue As String
        Public ArrayValue As List(Of ValueObject)

        Public Shared Widening Operator CType(ByVal s As String) As ValueMetaObject
            Return New ValueMetaObject() With {.StringValue = s}
        End Operator

        Public Shared Widening Operator CType(ByVal lstValue As List(Of ValueObject)) As ValueMetaObject
            Return New ValueMetaObject With {.ArrayValue = lstValue}
        End Operator
    End Structure

    Friend Class OrderTypeConverter
        Public Shared ReadOnly Settings As New JsonSerializerSettings() With {
            .DateParseHandling = DateParseHandling.DateTimeOffset,
            .MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
            .NullValueHandling = NullValueHandling.Ignore,
            .Converters = {
                New ValueObjectConverter(),
                New IsoDateTimeConverter() With {
                    .DateTimeStyles = DateTimeStyles.None
                }
            }
        }
    End Class

    Friend Class ValueObjectConverter
        Inherits JsonConverter

        Public Overrides Function CanConvert(t As Type) As Boolean
            Return t = GetType(ValueMetaObject) OrElse t = GetType(ValueMetaObject?)
        End Function

        Public Overrides Function ReadJson(reader As JsonReader, t As Type, existingValue As Object, serializer As JsonSerializer) As Object
            Select Case reader.TokenType
                Case JsonToken.String
                    Dim sValue = serializer.Deserialize(Of String)(reader)
                    Return New ValueMetaObject() With {
                        .StringValue = sValue
                    }
                Case JsonToken.StartArray
                    Dim arValue = serializer.Deserialize(Of List(Of ValueObject))(reader)
                    Return New ValueMetaObject() With {
                        .ArrayValue = arValue
                    }
            End Select
            Throw New Exception("Read failed")
        End Function

        Public Overrides Sub WriteJson(writer As JsonWriter, utValue As Object, serializer As JsonSerializer)
            Dim value = DirectCast(utValue, ValueMetaObject)
            If value.StringValue IsNot Nothing Then
                serializer.Serialize(writer, value.StringValue)
                Return
            End If
            If value.ArrayValue IsNot Nothing Then
                serializer.Serialize(writer, value.ArrayValue)
                Return
            End If
            Throw New Exception("Write failed")
        End Sub
    End Class

    Friend Class ValueObjectDateConverter
        Inherits UnixDateTimeConverter

        Public Overrides Function CanConvert(t As Type) As Boolean
            Return t = GetType(Long) OrElse t = GetType(Long?)
        End Function

        Public Overrides Function ReadJson(reader As JsonReader, t As Type, existingValue As Object, serializer As JsonSerializer) As Object
            Dim uxDT As Long? = serializer.Deserialize(Of Long?)(reader)
            Return DateTimeOffset.FromUnixTimeMilliseconds(uxDT.Value)
        End Function
        Public Overrides Sub WriteJson(writer As JsonWriter, value As Object, serializer As JsonSerializer)
            Dim dtmo = DirectCast(value, DateTimeOffset)
            If dtmo <> DateTimeOffset.MinValue Then
                serializer.Serialize(writer, CType(DirectCast(value, DateTimeOffset).ToUnixTimeMilliseconds(), ULong))
            Else
                MyBase.WriteJson(writer, Nothing, serializer)
            End If
        End Sub
    End Class
End Class

Option 2

Only the Value Property in the MetaData class is changed, defining it as type Object and decorated with the JsonConverter attribute.
The ValueObjectConverter custom converter is slightly different, give it a look.

Public Class MetaData
    <JsonProperty("id")>
    Public Property Id As Long

    <JsonProperty("key")>
    Public Property Key As String

    <JsonConverter(GetType(ValueObjectConverter))>
    <JsonProperty("value")>
    Public Property Value As Object
End Class

Adapted custom converter, to substitute to the previous one if this format is preferable:

Friend Class OrderTypeConverter
    Public Shared ReadOnly Settings As New JsonSerializerSettings() With {
        .DateParseHandling = DateParseHandling.DateTimeOffset,
        .MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
        .NullValueHandling = NullValueHandling.Ignore,
        .Converters = {
            New IsoDateTimeConverter() With {
                .DateTimeStyles = DateTimeStyles.None
            }
        }
    }
End Class

Friend Class ValueObjectConverter
    Inherits JsonConverter

    Public Overrides Function CanConvert(t As Type) As Boolean
        Return t = GetType(String) OrElse t = GetType(Array)
    End Function

    Public Overrides Function ReadJson(reader As JsonReader, t As Type, existingValue As Object, serializer As JsonSerializer) As Object
        Select Case reader.TokenType
            Case JsonToken.String
                Dim sValue = serializer.Deserialize(Of String)(reader)
                Return sValue
            Case JsonToken.StartArray
                Dim arValue = serializer.Deserialize(Of List(Of ValueObject))(reader)
                Return arValue
        End Select
        Throw New Exception("Read failed")
    End Function

    Public Overrides Sub WriteJson(writer As JsonWriter, utValue As Object, serializer As JsonSerializer)
        If utValue IsNot Nothing AndAlso TypeOf utValue Is String Then
            serializer.Serialize(writer, utValue.ToString())
            Return
        Else
            serializer.Serialize(writer, DirectCast(utValue, List(Of ValueObject)))
            Return
        End If
        Throw New Exception("Write failed")
    End Sub
End Class

Option 3:

Posted by Brian Rogers, so I won't bother :)

Related