5

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.

Ruben Bartelink
  • 59,778
  • 26
  • 187
  • 249
Xorcist
  • 3,129
  • 3
  • 21
  • 47
  • Why does the `Data` property have some extra header during deserialization that it doesn't have during serialization? – dbc Apr 10 '21 at 03:50
  • The images are being acquired through a browser via the JavaScript [FileReader](https://developer.mozilla.org/en-US/docs/Web/API/FileReader). The resultant Base64 string is prefixed with header information (i.e. **data:image/png;base64,**) which is then followed by the actual file data as a Base64 string. Keeping this header information would make the resultant file (stored on the server file system) invalid and unopen-able by associated programs. *Please note the above class is heavily paired down to focus just on the constructor issue, more functionality is provided in the actual class.* – Xorcist Apr 12 '21 at 15:13

2 Answers2

9

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.

dbc
  • 104,963
  • 20
  • 228
  • 340
0

Implementing the custom converter deserialization using an ExpandoObject can avoid the nested DTO class if desired:

using System.Dynamic;

.
.
.

public override FileEntity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
  dynamic obj = JsonSerializer.Deserialize<ExpandoObject>(ref reader, options);
  return new FileEntity {
    Data = (obj.data == null) ? null : Convert.FromBase64String(obj.data.GetString().Remove(0, obj.data.GetString().IndexOf(',') + 1))
  };
}

it makes the custom converter a little more flexible when developing, since the DTO doesn't continually need to grow with the base class being deserialized into. It also makes handling potential nullable properties a bit easier too (over standard JsonElement deserialization, i.e. JsonSerializer.Deserialize< JsonElement >) as such:

public override FileEntity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
  dynamic obj = JsonSerializer.Deserialize<ExpandoObject>(ref reader, options);
  return new FileEntity {
    SomeNullableInt32Property = obj.id?.GetInt32(),
    Data = (obj.data?.GetString() == null) ? null : Convert.FromBase64String(obj.data.GetString().Remove(0, obj.data.GetString().IndexOf(',') + 1))
  };
}
Xorcist
  • 3,129
  • 3
  • 21
  • 47