2

I'd like to know if a certain property in json was skipped or was provided null. For this I'm using a setter flag like this. This works fine but its much uglier and I will have to create flags for every property I want to check. Is there a neater solution, create a custom class that has functions like isSet, Value?

    public class Tmp2
    {
        private int a;
        public bool isASet;

        private int? b;
        public bool isBSet;

        public int A { get { return a; } 
            set { a = value; isASet = true; } }
        public int? B { get { return b; } 
            set { b = value; isBSet = true; } }
    } 

Looking for a better solution

dbc
  • 104,963
  • 20
  • 228
  • 340
  • 3
    What serializer are you using? Json.NET? System.Text.Json? Either way you could use the `Optional` pattern from the question [Custom JSON serializer for optional property with System.Text.Json](https://stackoverflow.com/q/63418549/3744182) by [Maxime Rossini](https://stackoverflow.com/users/547733/maxime-rossini) and combine it with a generic converter that serializes and deserializes an `Optional` object as its value `T`. – dbc May 10 '23 at 18:23
  • This looks good but I'm using newsoft.json which doesn't support condition such as [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] in JsonIgnore which seems to be the key of the solution – Corner Stone Developer May 10 '23 at 20:07
  • Json.NET has [`[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]`](https://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_DefaultValueHandling.htm) which is basically the same. – dbc May 10 '23 at 22:39

2 Answers2

2

You could adopt the Optional<T> pattern from the question Custom JSON serializer for optional property with System.Text.Json by Maxime Rossini to wrap your values in an Optional<T> struct that tracks whether or not the value was ever initialized. Since you are using Json.NET you will need to port its logic from System.Text.Json.

First, define the following interface, struct and converter:

public interface IHasValue
{
    bool HasValue { get; }
    object? GetValue();
}

[JsonConverter(typeof(OptionalConverter))]
public readonly struct Optional<T> : IHasValue
{
    //Taken from https://stackoverflow.com/q/63418549/3744182
    //By https://stackoverflow.com/users/547733/maxime-rossini
    public Optional(T value) => (this.HasValue, this.Value) = (true, value);
    public bool HasValue { get; }
    public T Value { get; }
    object? IHasValue.GetValue() => Value;
    public override string ToString() => this.HasValue ? (this.Value?.ToString() ?? "null") : "unspecified";
    public static implicit operator Optional<T>(T value) => new Optional<T>(value);
    public static implicit operator T(Optional<T> value) => value.Value;
}

class OptionalConverter : JsonConverter
{
    static Type? GetValueType(Type objectType) =>
        objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Optional<>) ? objectType.GetGenericArguments()[0] : null;
        
    public override bool CanConvert(Type objectType) => GetValueType(objectType) != null;

    public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
    {
        var valueType = GetValueType(objectType) ?? throw new ArgumentException(objectType.ToString());
        var value = serializer.Deserialize(reader, valueType);
        return Activator.CreateInstance(objectType, value);
    }

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

Now modify your classes (here Tmp2) and replace the value of every property whose presence you want to track with an Optional<T> wrapper like so:

public class Tmp2
{
    [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] 
    public Optional<int> A { get; set; }
    [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
    public Optional<int?> B { get; set; }
} 

And now you will be able to tell whether any particular value was ever set by checking value.HasValue, e.g.:

Assert.That(!JsonConvert.DeserializeObject<Tmp2>("{}")!.A.HasValue);
Assert.That(JsonConvert.SerializeObject(new Tmp2 { B = 32 }) == "{\"B\":32}");

In order to suppress output of unset properties when serialized, you have two options:

  1. Mark each property with `[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] :

    Ignore members where the member value is the same as the member's default value when serializing objects so that it is not written to JSON.

  2. Introduce a custom contract resolver that automatically suppresses serialization of unset property values.

The version of Tmp2 above uses approach #1 but if you prefer #2, define the following contract resolver:

public class OptionalResolver : DefaultContractResolver
{
    protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
    {
        var property = base.CreateProperty(member, memberSerialization);
        if (property.ValueProvider != null && property.Readable && (typeof(IHasValue).IsAssignableFrom(property.PropertyType) || typeof(IHasValue).IsAssignableTo(property.PropertyType)))
        {
            var old = property.ShouldSerialize;
            Predicate<object> shouldSerialize = (o) => property.ValueProvider.GetValue(o) is IHasValue v ? v.HasValue : true;
            property.ShouldSerialize = (old == null ? shouldSerialize : (o) => old(o) && shouldSerialize(o));
        }
        return property;
    }
}

And simplify your model as follows:

public record Tmp2(Optional<int> A, Optional<int?> B);

And you will now be able to round-trip Tmp2 and track the presence of properties using the following settings:

// Cache this statically somewhere for best performance
IContractResolver resolver = new OptionalResolver
{
    // Configure as required, e.g.:
    NamingStrategy = new CamelCaseNamingStrategy(),
};
var settings = new JsonSerializerSettings { ContractResolver = resolver };

var tmp = JsonConvert.DeserializeObject<Tmp2>(json, settings);
var newJson = JsonConvert.SerializeObject(tmp, settings);
Assert.That(JToken.DeepEquals(JToken.Parse(json), JToken.Parse(newJson)));

Demo fiddle here.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • Thank you for sharing this. It works fine as is but when I try to include it in my project, I get The JSON value could not be converted to Optional`1[System.Nullable`1[System.Decimal]]. Path: $.value[0].total| LineNumber: 5 | BytePositionInLine: 38.' where total : null . Basically my request body is not getting binded and is null. The error goes away if I remove total : null from the body. It fails on total : 200 as well – Corner Stone Developer May 11 '23 at 18:11
  • @CornerStoneDeveloper - The error message *`The JSON value could not be converted to Optional1[System.Nullable1[System.Decimal]]. Path: $.value[0].total| LineNumber: 5 | BytePositionInLine: 38.'`* is a **System.Text.Json** error message, not a Json.NET error message, You can tell from the *`BytePositionInLine`* statement -- System.Text.Json deserializes utf8 byte streams, while Json.NET deserializes UTF16 `char` streams. If you are indeed using System.Text.Json then the applied converter won't get invoked. – dbc May 11 '23 at 18:26
  • @CornerStoneDeveloper - If you are actually using System.Text.Json, use the converter & attributes from [this answer](https://stackoverflow.com/a/63431434/3744182) to the [linked question](https://stackoverflow.com/q/63418549/3744182). And please [edit](https://stackoverflow.com/posts/76221332/edit) your question to clarify. – dbc May 11 '23 at 18:27
  • Here's an equivalent fiddle using your `Tmp2` model with `Optional` and System.Text.Json: https://dotnetfiddle.net/lnmK7N. – dbc May 11 '23 at 18:52
  • I checked, I am using Newtonsoft.Json, my project doesn't reference System.Text.Json – Corner Stone Developer May 11 '23 at 19:15
  • I'm marking your answer as the right one as it does solve the specific problem that I mentioned originally. Thank you. – Corner Stone Developer May 11 '23 at 19:16
  • @CornerStoneDeveloper - even if your project doesn't reference System.Text.Json, it is built into .Net Core itself so possibly some higher-level method you are calling uses it internally, for instance `ApiController.Ok()` or `HttpClientJsonExtensions.PostAsJsonAsync()`. Sorry I can't be of more help in figuring out where it is getting invoked. – dbc May 11 '23 at 20:32
  • You were already helpful. Thank you again. – Corner Stone Developer May 11 '23 at 21:04
-1

if you don't want to add any extra code to your class, you can create a generic wrapper class. This class can be used for any of your data models.

public class Tmp2
{
    public int A { get; set; }

    public int? B { get; set; }
}

public class Wraper<T>
{
    public List<string> IsValChanged { get; set; }
    public T Tmp { get; set; }
    
    private void Init(JObject tmp)
    {
        IsValChanged = tmp.Properties().DescendantsAndSelf().OfType<JProperty>()
                          .Select(jp => jp.Name).ToList();
        Tmp = tmp.ToObject<T>();
    }
    
    public Wraper(JObject tmp)
    {
        Init(tmp);
    }
    public Wraper(string json)
    {
        Init(JObject.Parse(json));
    }

    public bool IsPropertyChanged(object prop, [CallerArgumentExpression("prop")] string propName = null)
    {
        return IsValChanged.Any(x => x == propName.Substring(propName.IndexOf(".") + 1));
    }
}

test

    var json1 = "{\"A\":0}";
    var json2 = "{\"B\":null}";
    var json3 = "{\"A\":0,\"B\":null}";

    var wrapper = new Wraper<Tmp2>(json2);

    Tmp2 tmp = wrapper.Tmp;
    var valueChangedList = string.Join(", ", wrapper.IsValChanged);

    Console.WriteLine($"Changed properties: {valueChangedList}");
    Console.WriteLine($"{nameof(tmp.A)} changed: {wrapper.IsPropertyChanged(tmp.A)}");
    Console.WriteLine($"{nameof(tmp.B)} changed: {wrapper.IsPropertyChanged(tmp.B)}");

test result

Changed properties: B
A changed: False
B changed: True

and how to use a wrapper in a controller action

public ActionResult Post([FromBody] JObject model)
{
    var wrapper = new Wraper<Tmp2>(model);
    Tmp2 tmp = wrapper.Tmp;
    var valueChangedList = string.Join(", ", wrapper.IsValChanged);
    // another code
}
Serge
  • 40,935
  • 4
  • 18
  • 45