Search code examples
jsonconstructorsystem.text.json.net-5

JsonConstructorAttribute in System.Text.Json (but not Newtonsoft.Json) results in exception when property and constructor argument types differ


Given a Base64 string, the following sample class will deserialize properly using Newtonsoft.Json, but not with System.Text.Json:

using System;
using System.Text.Json.Serialization;

public class AvatarImage{

  public Byte[] Data { get; set; } = null;

  public AvatarImage() {
  }

  [JsonConstructor]
  public AvatarImage(String Data) {
  //Remove Base64 header info, leaving only the data block and convert it to a Byte array
    this.Data = Convert.FromBase64String(Data.Remove(0, Data.IndexOf(',') + 1));
  }

}

With System.Text.Json, the following exception is thrown:

must bind to an object property or field on deserialization. Each parameter name must match with a property or field on the object. The match can be case-insensitive.

Apparently System.Text.Json doesn't like the fact the property is a Byte[] but the parameter is a String, which shouldn't really matter because the whole point is that the constructor should be taking care of the assignments.

Is there any way to get this working with System.Text.Json?

In my particular case Base64 images are being sent to a WebAPI controller, but the final object only needs the Byte[]. In Newtonsoft this was a quick and clean solution.


Solution

  • This is apparently a known restriction of System.Text.Json. See the issues:

    Thus (in .Net 5 at least) you will need to refactor your class to avoid the limitation.

    One solution would be to add a surrogate Base64 encoded string property:

    public class AvatarImage
    {
        [JsonIgnore]
        public Byte[] Data { get; set; } = null;
    
        [JsonInclude]
        [JsonPropertyName("Data")]
        public string Base64Data 
        { 
            private get => Data == null ? null : Convert.ToBase64String(Data);
            set
            {
                var index = value.IndexOf(',');
                this.Data = Convert.FromBase64String(index < 0 ? value : value.Remove(0, index + 1));
            }
        }
    }
    

    Note that, ordinarily, JsonSerializer will only serialize public properties. However, if you mark a property with [JsonInclude] then either the setter or the getter -- but not both -- can be nonpublic. (I have no idea why Microsoft doesn't allow both to be private, the data contract serializers certainly support private members marked with [DataMember].) In this case I chose to make the getter private to reduce the chance the surrogate property is serialized by some other serializer or displayed via some property browser.

    Demo fiddle #1 here.

    Alternatively, you could introduce a custom JsonConverter<T> for AvatarImage

    [JsonConverter(typeof(AvatarConverter))]
    public class AvatarImage
    {
        public Byte[] Data { get; set; } = null;
    }
    
    class AvatarConverter : JsonConverter<AvatarImage>
    {
        class AvatarDTO { public string Data { get; set; } }
        public override AvatarImage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            var dto = JsonSerializer.Deserialize<AvatarDTO>(ref reader, options);
            var index = dto.Data?.IndexOf(',') ?? -1;
            return new AvatarImage { Data = dto.Data == null ? null : Convert.FromBase64String(index < 0 ? dto.Data : dto.Data.Remove(0, index + 1)) };
        }
    
        public override void Write(Utf8JsonWriter writer, AvatarImage value, JsonSerializerOptions options) =>
            JsonSerializer.Serialize(writer, new { Data = value.Data }, options);
    }
    

    This seems to be the easier solution if for simple models, but can become a nuisance for complex models or models to which properties are frequently added.

    Demo fiddle #2 here.

    Finally, it seems a bit unfortunate that the Data property will have some extra header prepended during deserialization that is not present during serialization. Rather than fixing this during deserialization, consider modifying your architecture to avoid mangling the Data string in the first place.