20

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).

Ilya Chernomordik
  • 27,817
  • 27
  • 121
  • 207
  • Track if the setter got called? – Rand Random Oct 31 '19 at 13:52
  • Yes, that's probably a good idea, then I'll have to have some more internal fields to check if it was set for each field. A bit cumbersome, but doable, perhaps some better way exists? – Ilya Chernomordik Oct 31 '19 at 13:53
  • Could do one dictionary instead of seperate fields, but thats all I can think of. Good luck :) – Rand Random Oct 31 '19 at 13:54
  • 1
    Rather than binding to a static class, I think you should be able to take a `[FromBody] JObject` parameter and query it for specific values. – StriplingWarrior Oct 31 '19 at 15:12
  • Sounds good, I shall try – Ilya Chernomordik Oct 31 '19 at 15:14
  • Don't bind to `JObject`; that's an awful idea. This smells like an XY problem. What is the *actual* issue here? Why do you need to know whether it was explicitly set to null or not included at all? – Chris Pratt Oct 31 '19 at 15:48
  • I will only update properties that are included in the request. If the field is not included, I will not update it. If the field is set to null, I will update that field to the null value. I have a description of what I want to achieve at the bottom, but perhaps it's not very explanatory – Ilya Chernomordik Oct 31 '19 at 17:57
  • @ChrisPratt Not sure what is XY problem, but I guess it might be a security issue to do such a binding. Is that what you meant? – Ilya Chernomordik Nov 01 '19 at 11:49
  • No an XY problem is when you have a problem X, so you come up with solution Y, but you also don't know how to do Y. Instead of asking about X, you're asking about Y, and Y isn't even the right solution. That's in fact the case here. If you only want to update certain properties, that's what a patch is for (HTTP PATCH verb, and json+patch). If you're going to do a PUT, you're replacing one version with an entirely different version, not just certain properties. – Chris Pratt Nov 01 '19 at 11:54
  • Ok, I can use a PATCH, sound quite correct, but won't I get the same problem using Patch? – Ilya Chernomordik Nov 01 '19 at 11:55
  • Do you mean this (https://learn.microsoft.com/en-us/aspnet/core/web-api/jsonpatch?view=aspnetcore-2.2)? – Ilya Chernomordik Nov 01 '19 at 12:40
  • @RandRandom thanks for the tip, I have extended it and all seems to work fine, explained in the answer what have I done – Ilya Chernomordik Nov 07 '19 at 11:42

5 Answers5

8

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*/}
Ilya Chernomordik
  • 27,817
  • 27
  • 121
  • 207
4

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

Guru Stron
  • 102,774
  • 10
  • 95
  • 132
Shaddix
  • 5,901
  • 8
  • 45
  • 86
  • Nice trick, I would think there is a way to do this not at the Json serialization level, but some model binding level in MVC. You can perhaps override something there to make it independent of Json serialization – Ilya Chernomordik Mar 23 '20 at 10:16
  • I actually tried to integrate on ModelBinding level initially, but had no success (haven't found a way to override only small part of the ModelBinding logic, what I tried required copy&pasting default model binder's code which isn't good) – Shaddix Mar 23 '20 at 16:17
  • yes, that sounds like an overkill. .Net core is a great platform, but model binding leaves much to be desired unfortunately in terms of extensibility – Ilya Chernomordik Mar 23 '20 at 19:28
  • This solution needs `NullValueHandling.Include` to be set. Do you know if it's possible to include NULLs only on deserialize? – Thowk Jan 11 '21 at 20:05
4

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));

        //...
}
  • it worked for me, but it seems that with .NET 7 it can't deserialize nested objects that implement interface, e.g. `public class Dummy : IDummy { public Dummy? Nested {get;set;}}`. Deserializing `{ Nested: { Nested: null} }` fails on `Activator.CreateInstance(typeToConvert)!;` since `typeToConvert` is IDummy. – Shaddix Nov 15 '22 at 15:50
0

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.

0

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).

Parlor311
  • 33
  • 4
  • Your answer could be improved with additional supporting information. Please [edit] to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers [in the help center](/help/how-to-answer). – Community Jul 28 '23 at 17:56