My scenario is this:
I am implementing an update operation in a REST API that handles typical CRUD type operations and is developed using ASP.NET Core. The operations in the API typically take json payloads.
I would like to be able to tell the difference between when a property is omitted from a json payload, and when the a property in the json payload has been set to null (or the default value for that particular type).
For example, in one particular operation there is an optional due date, which can be a DateTime value or null. So once the model has been bound - and the due date is null - how to tell if it is null because the client wants to update it to null (set to no due date), or because the client omitted it from the payload (and so would indicate they don't want it updated at all)?
What I have tried:
I implemented a struct similar to Optional in the codeanalysis namespace in that it has a reference to a value, and keeps track of whether that value has been set/is meaningful.
I tried implementing both a custom JSON deserializer and a TypeConverter for my struct, but neither approach seems to work. Frustratingly if the value of a property is null, the custom deserializer or the ConvertFrom
method of the TypeConverter do not seem to be called during model binding, this results in the default constuctor for my optional being used for non-omitted null values, and so I cannot tell the difference between a null value and an omission.
The optional values I have are properties on the model (and I would like to use them across multiple models), so from what I gather, using a custom model binder would not be appropriate (and doesn't really get me any closer I don't think).
To give some concrete examples, a cut down version of my struct is as follows;
[TypeConverter(typeof(OptionalConverter))]
[JsonConverter(typeof(OptionalJsonConverter))]
public readonly struct Optional<T>
{
private readonly T _value;
public T Value
{
get
{
return _value;
}
}
public bool HasValue { get; }
public Optional(T value)
{
HasValue = true;
_value = value;
}
public static implicit operator Optional<T>(T value)
{
return new Optional<T>(value);
}
public static explicit operator T(Optional<T> value)
{
return value.Value;
}
}
The relevant type converter methods are like the following:
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return true;
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
return Activator.CreateInstance(typeof(Optional<>).MakeGenericType(context.PropertyDescriptor.PropertyType), new object[] { value });
}
And my relevent JSON converter method is (I am using newtonsoft (note it works the way I want if I manually deserialize a string)):
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType != JsonToken.Undefined)
{
var genericTypes = objectType.GetGenericArguments();
var value = serializer.Deserialize(reader, genericTypes[0]);
return Activator.CreateInstance(
typeof(Optional<>).MakeGenericType(genericTypes[0]),
new object[] { value });
}
return existingValue;
}
I have a test route like the following;
[HttpPut]
[Route("foo")]
public IActionResult Foo(SomeDto someDto)
{
return Ok();
}
And a test model DTO of;
public class SomeDto
{
public Optional<string> Foo { get; set; }
public Optional<string> Bar { get; set; }
public Optional<string> Baz { get; set; }
}
Given a PUT to /foo of { "foo": "foo", "bar": null }
I would hope to get the value of someDto
bound as:
{
Foo: { Value: "foo", HasValue: true },
Bar: { Value: null, HasValue: true }, <-- was passed as null.
Baz: { Value: null, HasValue: false } <-- omitted.
}
But instead I get
{
Foo: { Value: "foo", HasValue: true },
Bar: { Value: null, HasValue: false }, <-- was passed as null.
Baz: { Value: null, HasValue: false } <-- omitted.
}
Again this is seemingly because as soon as a value is null, the ASP.NET binder uses the default contructor for the struct, and so does not give you the chance to provide a different value using a JSON or type converter. I am at a loss for what I might be missing to solve this binding issue, but perhaps I am missing something. Failing that it would at least be helpful to have someone confirm that this approach cannot be done.
Note:
I realise there are other ways to acheive a similar outcome, e.g. having a separate route to update each field on an entity, or using jsonpatch. But these have implications for how clients can consume the API, so I would rather only go down that path if this could not be solved otherwise.
CodePudding user response:
I managed to answer my question as it turned out to be my own dumb fault.
I had the default serialization settings to ignore nulls so that is why null values were not being passed to be JsonConverter or TypeConverter.
i.e. I had
services.AddControllers(options =>
{
...
})
.AddNewtonsoftJson(options =>
{
...
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
...
});
So there are two solutions to this;
- Don't set the above setting (or set it to include).
- Create a custom contract resolver that can override the setting (example below).
public class OptionalJsonContractResolver : DefaultContractResolver
{
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
JsonProperty property = base.CreateProperty(member, memberSerialization);
if (property.PropertyType.IsGenericType && property.PropertyType.GetGenericTypeDefinition() == typeof(Optional<>))
{
// We always want this to be include unless otherwise specified so the value always gets deserialized
// even when null.
property.NullValueHandling ??= NullValueHandling.Include;
// You can also add code to set ShouldSerialize to false if the
// Optional.HasValue is false here.
}
return property;
}
}
and then update your startup to:
services.AddControllers(options =>
{
...
})
.AddNewtonsoftJson(options =>
{
...
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
options.SerializerSettings.ContractResolver = new OptionalJsonContractResolver();
...
});
I still need to play around with this to see if there are any other annoyances for side effects, but this at least answers my original question.