Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Json.NET - Serialize generic type wrapper without property name

Tags:

c#

json.net

I have a generic type that wraps a single primitive type to give it value equality semantics

public class ValueObject<T>
{
    public T Value { get; }
    public ValueObject(T value) => Value = value;

    // various other equality members etc...
}

It is used like:

public class CustomerId : ValueObject<Guid>
{
    public CustomerId(Guid value) : base(value) { }
}

public class EmailAddress : ValueObject<string>
{
    public EmailAddress(string value) : base(value) { }
}

The issue is when serializing a type like:

public class Customer
{
    public CustomerId Id { get; }
    public EmailAddress Email { get; }

    public Customer(CustomerId id, EmailAddress email) 
    { 
        Id = id;
        Email = email;
    }
}

Each object the inherits from ValueObject<T> is wrapped in a Value property (as expected). For example

var customerId = new CustomerId(Guid.NewGuid());
var emailAddress = new EmailAddress("[email protected]");

var customer = new Customer(customerId, emailAddress);

var customerAsJson = JsonConvert.SerializeObject(customer, Formatting.Indented, new JsonSerializerSettings
{
    ContractResolver = new CamelCasePropertyNamesContractResolver() 
})

Results in

{
  "id": {
    "value": "f5ce21a5-a0d1-4888-8d22-6f484794ac7c"
  },
  "email": {
    "value": "[email protected]"
  }
}

Is there a way to write a custom JsonConverter so the the Value property is excluded for types subclassing ValueObject<T> so that the above example would output

{
  "id": "f5ce21a5-a0d1-4888-8d22-6f484794ac7c",
  "email": "[email protected]"
}

I would prefer to have a single JsonConverter that can handle all ValueObject<T> rather than having to define a separate JsonConverter for each ValueObject<T> subclass

My first attempt was

public class ValueObjectOfTConverter : JsonConverter
{
    private static readonly Type ValueObjectGenericType = typeof(ValueObject<>);
    private static readonly string ValuePropertyName = nameof(ValueObject<object>.Value);

    public override bool CanConvert(Type objectType) =>
        IsSubclassOfGenericType(objectType, ValueObjectGenericType);

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        // converts "f5ce21a5-a0d1-4888-8d22-6f484794ac7c" => "value": "f5ce21a5-a0d1-4888-8d22-6f484794ac7c"
        var existingJsonWrappedInValueProperty = new JObject(new JProperty(ValuePropertyName, JToken.Load(reader)));
        return existingJsonWrappedInValueProperty.ToObject(objectType, serializer);
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        // to implement
    }

    private static bool IsSubclassOfGenericType(Type typeToCheck, Type openGenericType)
    {
        while (typeToCheck != null && typeToCheck != typeof(object))
        {
            var cur = typeToCheck.IsGenericType ? typeToCheck.GetGenericTypeDefinition() : typeToCheck;
            if (openGenericType == cur) return true;

            typeToCheck = typeToCheck.BaseType;
        }

        return false;
    }
}
like image 415
kimsagro Avatar asked May 13 '18 07:05

kimsagro


People also ask

Is Newtonsoft JSON obsolete?

Newtonsoft. Json package is not provided by RestSharp, is marked as obsolete on NuGet, and no longer supported by its creator.

What is Jsonconvert?

Provides methods for converting between . NET types and JSON types.

Should I use Newtonsoft or System text JSON?

By default, Newtonsoft. Json does case insensitive property name matching during deserialization whereas System. Text. Json does case sensitive matching (with exception in ASP.Net core where you don't need to do anything to achieve behavior like Newtonsoft.

Does JSON serialize private fields?

All fields, both public and private, are serialized and properties are ignored.


1 Answers

You can do this with a custom JsonConverter similar to the ones shown in Json.Net: Serialize/Deserialize property as a value, not as an object. However, since ValueObject<T> does not have a non-generic method to get and set the Value as an object, you will need to use reflection.

Here's one approach:

class ValueConverter : JsonConverter
{
    static Type GetValueType(Type objectType)
    {
        return objectType
            .BaseTypesAndSelf()
            .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ValueObject<>))
            .Select(t => t.GetGenericArguments()[0])
            .FirstOrDefault();
    }

    public override bool CanConvert(Type objectType)
    {
        return GetValueType(objectType) != null;
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        // You need to decide whether a null JSON token results in a null ValueObject<T> or 
        // an allocated ValueObject<T> with a null Value.
        if (reader.SkipComments().TokenType == JsonToken.Null)
            return null;
        var valueType = GetValueType(objectType);
        var value = serializer.Deserialize(reader, valueType);

        // Here we assume that every subclass of ValueObject<T> has a constructor with a single argument, of type T.
        return Activator.CreateInstance(objectType, value);
    }

    const string ValuePropertyName = nameof(ValueObject<object>.Value);

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var contract = (JsonObjectContract)serializer.ContractResolver.ResolveContract(value.GetType());
        var valueProperty = contract.Properties.Where(p => p.UnderlyingName == ValuePropertyName).Single();
        // You can simplify this to .Single() if ValueObject<T> has no other properties:
        // var valueProperty = contract.Properties.Single();
        serializer.Serialize(writer, valueProperty.ValueProvider.GetValue(value));
    }
}

public static partial class JsonExtensions
{
    public static JsonReader SkipComments(this JsonReader reader)
    {
        while (reader.TokenType == JsonToken.Comment && reader.Read())
            ;
        return reader;
    }
}

public static class TypeExtensions
{
    public static IEnumerable<Type> BaseTypesAndSelf(this Type type)
    {
        while (type != null)
        {
            yield return type;
            type = type.BaseType;
        }
    }
}

You could then apply the converter directly to ValueType<T> like so:

[JsonConverter(typeof(ValueConverter))]
public class ValueObject<T>
{
    // Remainder unchanged
}

Or apply it in settings instead:

var settings = new JsonSerializerSettings
{
    Converters = { new ValueConverter() },
    ContractResolver = new CamelCasePropertyNamesContractResolver() 
};
var customerAsJson = JsonConvert.SerializeObject(customer, Formatting.Indented, settings);

Working sample .Net fiddle #1 here.

Alternatively, you might consider adding a non-generic method to access the value as an object, e.g. like so:

public interface IHasValue
{
    object GetValue(); // A method rather than a property to ensure the non-generic value is never serialized directly.
}

public class ValueObject<T> : IHasValue
{
    public T Value { get; }
    public ValueObject(T value) => Value = value;

    // various other equality members etc...

    #region IHasValue Members

    object IHasValue.GetValue() => Value;

    #endregion
}

With this addition, WriteJson() becomes much simpler:

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        serializer.Serialize(writer, ((IHasValue)value).GetValue());
    }

Working sample .Net fiddle #2 here.

Notes:

  • ReadJson() assumes that every subclass of Value<T> has a public constructor taking a single argument of type T.

  • Applying the converter directly to ValueType<T> using [JsonConverter(typeof(ValueConverter))] will have slightly better performance, since CanConvert need never get called. See Performance Tips: JsonConverters for details.

  • You need to decide how to handle a null JSON token. Should it result in a null ValueType<T>, or an allocated ValueType<T> with a null Value?

  • In the second version of ValueType<T> I implemented IHasValue.GetValue() explicitly to discourage its use in cases where an instance of ValueType<T> is used in statically typed code.

  • If you really only want to apply the converter to types subclassing ValueObject<T> and not ValueObject<T> itself, in GetValueType(Type objectType) add a call to .Skip(1):

    static Type GetValueType(Type objectType)
    {
        return objectType
            .BaseTypesAndSelf()
            .Skip(1) // Do not apply the converter to ValueObject<T> when not subclassed
            .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ValueObject<>))
            .Select(t => t.GetGenericArguments()[0])
            .FirstOrDefault();
    }
    

    And then apply the converter in JsonSerializerSettings.Converters rather than directly to ValueObject<T>.

like image 50
dbc Avatar answered Oct 16 '22 01:10

dbc