Reputation: 30335
I want to differentiate between these two json inputs in an action in Asp.Net Core:
{
"field1": null,
"field2": null
}
and
{
"field1": null,
}
I have an ordinary class like this in C#:
public class MyData
{
public string Field1 { get; set;}
public string Field2 { get; set;}
}
I want to run a partial update of an object that can accept null as the value, but when the field will not be in the input it means I don't want to update this field at all (something else from setting it to null).
Upvotes: 25
Views: 5080
Reputation: 345
.NET 7 supports modifying the JSON type resolver in a manner similar to overriding Newtonsoft's DefaultContractResolver
. Using this, it's possible have the JSON deserializer track which properties are present in the JSON.
Define a property-tracking base class:
public abstract class PropertySetTrackingDtoBase
{
// Compare property names case-insensitively because they will be set
// from JSON (e.g. "name") but compared to C# property names (e.g. "Name").
private readonly HashSet<string> setProperties = new(StringComparer.OrdinalIgnoreCase);
void IPropertySetTracking.MarkAsSet(string propertyName) => setProperties.Add(propertyName);
public bool IsSet(string propertyName) => setProperties.Contains(propertyName);
}
Use the base class - this does not require any further modifications to the properties:
public class MyDto : PropertySetTrackingDtoBase
{
public string Name { get; set; }
public string? Description { get; set; }
}
Define a JSON contract modifier:
public static class JsonSerializerModifiers
{
public static void TrackPropertySets(JsonTypeInfo info)
{
if (!info.Type.IsAssignableTo(typeof(PropertySetTrackingDtoBase))) return;
foreach (var property in info.Properties.Where(p => p.Set is not null))
{
var originalSetter = property.Set!;
property.Set = (obj, value) =>
{
// Mark the property as set.
((PropertySetTrackingDtoBase)obj).MarkAsSet(property.Name);
// Invoke the original setter.
originalSetter.Invoke(obj, value);
};
}
}
}
Register the JSON modifier in Program.cs
(this is the part that's new for .NET 7):
builder.Services.AddMvc().AddJsonOptions(json =>
json.JsonSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver()
.WithAddedModifier(JsonSerializerModifiers.TrackPropertySets));
Now each property can be checked using dto.IsSet(nameof(Property))
.
If you're using AutoMapper, you can easily configure your mappings to ignore absent properties.
Define an AutoMapper extension method:
public static IMappingExpression<TSource, TDestination> IgnoreAllNotSetSourceProperties<TSource, TDestination>(this IMappingExpression<TSource, TDestination> map) where TSource : PropertySetTrackingDtoBase
{
map.ForAllMembers(member => member.PreCondition(source => source.IsSet(member.DestinationMember.Name));
return map;
}
Configure AutoMapper to ignore absent properties (this must be done per mapping, it cannot be inherited):
var config = new MapperConfiguration(cfg =>
cfg.CreateMap<MyPatchDto, MyEntity>().IgnoreAllNotSetSourceProperties());
Now if you call e.g. mapper.Map(myPatchDto, myEntity)
, only the properties that are explicitly set in the JSON will be touched on the entity.
Upvotes: 0
Reputation: 53
My two cents is that the frontend should always include all the user-set fields in the request. That way, if you encounter '' or null, you can be sure it's the user explicitly setting the field to no value. And for special case fields like is_archive, then you wouldn't need a body anyway since you can have a separate endpoint for each state (archive and unarchive).
Upvotes: 0
Reputation: 6109
Just to add another 2 cents, we went the similar way to the Ilya's answer, except that we're not calling SetHasProperty
from setter, but overriding DefaultContractResolver
:
public class PatchRequestContractResolver : DefaultContractResolver
{
protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
{
var prop = base.CreateProperty(member, memberSerialization);
prop.SetIsSpecified += (o, o1) =>
{
if (o is PatchRequest patchRequest)
{
patchRequest.SetHasProperty(prop.PropertyName);
}
};
return prop;
}
}
And then register this resolver in Startup:
services
.AddControllers()
.AddNewtonsoftJson(settings =>
settings.SerializerSettings.ContractResolver = new PatchRequestContractResolver());
Note, that we are still using JSON.Net and not the System.Text.Json
(which is default for .NET 3+) for deserializing. As of now there's no way to do things similar to DefaultContractResolver
with System.Text.Json
Upvotes: 5
Reputation: 30335
This is what I ended up doing, as all other options seem to be too complicated (e.g. jsonpatch, model binding) or would not give the flexibility I want.
This solution means there is a bit of a boilerplate to write for each property, but not too much:
public class UpdateRequest : PatchRequest
{
public string Name
{
get => _name;
set { _name = value; SetHasProperty(nameof(Name)); }
}
}
public abstract class PatchRequest
{
private readonly HashSet<string> _properties = new HashSet<string>();
public bool HasProperty(string propertyName) => _properties.Contains(propertyName);
protected void SetHasProperty(string propertyName) => _properties.Add(propertyName);
}
The value can then be read like this:
if (request.HasProperty(nameof(request.Name)) { /* do something with request.Name */ }
and this is how it can be validated with a custom attribute:
var patchRequest = (PatchRequest) validationContext.ObjectInstance;
if (patchRequest.HasProperty(validationContext.MemberName) {/* do validation*/}
Upvotes: 12
Reputation: 51
I've created a solution that works with System.Text.Json using a JsonConverter
DTO class:
public class MyDataDto : PatchRequest<MyDataDto>
{
public string? Field1 { get; set; }
public string? Field2 { get; set; }
}
PatchRequest class:
public abstract class PatchRequest
{
private readonly List<string> _setProperties = new();
public void MarkPropertyAsSet(string propertyName) => _setProperties.Add(propertyName);
public bool IsSet(string propertyName) => _setProperties.Contains(propertyName);
}
public abstract class PatchRequest<T> : PatchRequest where T : PatchRequest<T>
{
public bool IsSet<TProperty>(Expression<Func<T, TProperty>> expression)
=> IsSet((expression.Body as MemberExpression).Member.Name);
}
JsonConverter:
public class PatchRequestConverter : JsonConverter<PatchRequest>
{
public override bool CanConvert(Type typeToConvert) =>
typeof(PatchRequest).IsAssignableFrom(typeToConvert);
public override PatchRequest Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException();
var patchRequest = (PatchRequest)Activator.CreateInstance(typeToConvert)!;
var properties = typeToConvert
.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.SetProperty | BindingFlags.GetProperty)
.ToDictionary(p => options.PropertyNamingPolicy?.ConvertName(p.Name) ?? p.Name);
while (reader.Read())
switch (reader.TokenType)
{
case JsonTokenType.EndObject:
return patchRequest;
case JsonTokenType.PropertyName:
var property = properties[reader.GetString()!];
reader.Read();
property.SetValue(patchRequest, JsonSerializer.Deserialize(ref reader, property.PropertyType, options));
patchRequest.MarkPropertyAsSet(property.Name);
continue;
}
throw new JsonException();
}
public override void Write(Utf8JsonWriter writer, PatchRequest value, JsonSerializerOptions options) =>
JsonSerializer.Serialize(writer, value, value.GetType(), options);
}
Register the JsonConverter like:
builder.Services.Configure<JsonOptions>(options =>
options.JsonSerializerOptions.Converters.Add(new PatchRequestConverter());
);
Use in in a API controller like:
public async Task<ActionResult> PatchMyDataAsync([FromBody] MyDataDto myDataDto)
{
var field1IsSet = myDataDto.IsSet(c => c.Field1);
var field2IsSet = myDataDto.IsSet(nameof(c.Field2));
//...
}
Upvotes: 5
Reputation: 291
Intro: Asp.net core takes your request body and then deserializes to a object of Type MyData, and then it calls the method in your controller by passing the object as parameter. From the object myData you can not know if the field2 was null or not passed. Both ways the property will have a null value. The information you are trying to find is lost at deserialization.
Solution: To find out this, you need to read the request body, and check the request body if the field was passed or not. In asp.net core, there is a bit of complexity is reading the request body once it is read (by the asp.net core framework for creating the object of MyData). We need to rewind the request stream, and then read it. The code for it is below.
[HttpPost]
public void Post([FromBody] MyData myData)
{
HttpContext.Request.Body.Seek(0, System.IO.SeekOrigin.Begin);
System.IO.StreamReader sr = new System.IO.StreamReader(HttpContext.Request.Body);
var requestBody = sr.ReadToEnd();
//Now check the requestBody if the field was passed using JSON parsing or string manipulation
Console.WriteLine(requestBody);
}
Warning: Though this will work. What you are trying do will reduce the readability and make it difficult for other developers. Differentiating if a field value is null or was not present in the request body is not a common practice.
Upvotes: 0