Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Implementing "JSON Merge Patch" in ASP.NET Core - best way do differentiate between null and not defined property

I would like to create and endpoint conforming to "JSON Merge Patch" https://www.rfc-editor.org/rfc/rfc7396

Please do not confuse it with "JavaScript Object Notation (JSON) Patch" https://www.rfc-editor.org/rfc/rfc6902

However, I have a slight problem with differentiating between two situations in request:

  • removal of property value, here email value is being removed:

      {
          surname: "Kowalski"
          email: null
      }
    
  • property not included because client simply does not want to update it, here email is not included because it should not be updated:

      {
          surname: "Kowalski"
      }
    

The problem arises because in both situations after model binding email would have value null.

Do you have suggestion how this might be implemented?

like image 801
user44 Avatar asked Dec 08 '17 16:12

user44


4 Answers

You need 3 different states for email value here:

  1. Filled value for update (e.g. [email protected])
  2. null value if email should be removed
  3. Missing value if email should not be touched.

So the problem actually is how to express these 3 states in string property of your model. You can't do this with just raw string property because null value and missing value will conflict as you correctly described. Solution is to use some flag that indicates whether the value was provided in the request. You could either have this flag as another property in your model or create a simple wrapper over string, very similar to Nullable<T> class. I suggest creation of simple generic OptionalValue<T> class:

public class OptionalValue<T>
{
    private T value;
    public T Value
    {
        get => value;

        set
        {
            HasValue = true;
            this.value = value;
        }
    }

    public bool HasValue { get; set; }
}

Then you need custom JsonConverter that could deserialize usual json value to OptionalValue<T>:

class OptionalValueConverter<T> : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof(OptionalValue<T>);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        return new OptionalValue<T>
        {
            Value = (T) reader.Value,
        };
    }

    public override bool CanWrite => false;

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }
}

Your model will look something like this:

public class SomeModel
{
    public string Surname { get; set; }

    [JsonConverter(typeof(OptionalValueConverter<string>))]
    public OptionalValue<string> Email { get; set; } = new OptionalValue<string>();
}

Note that you assign Email with empty OptionalValue<string>(). If input json does not contains email value than Email property will keep it OptionalValue with HasValue set to false. If input json contains some email, even null, then OptionalValueConverter will create instance of OptionalValue with HasValue set to true.

Now in controller action you could determine any of 3 states for email:

[HttpPatch]
public void Patch([FromBody]SomeModel data)
{
    if (data.Email.HasValue)
    {
        //  Email presents in Json
        if (data.Email.Value == null)
        {
            //  Email should be removed
        }
        else
        {
            //  Email should be updated
        }
    }
    else
    {
        //  Email does not present in Json and should not be affected
    }
}
like image 130
CodeFuller Avatar answered Nov 09 '22 01:11

CodeFuller


Could you use the JsonMergePatch library? https://github.com/Morcatko/Morcatko.AspNetCore.JsonMergePatch

The usage is very simple:

[HttpPatch]
[Consumes(JsonMergePatchDocument.ContentType)]
public void Patch([FromBody] JsonMergePatchDocument<Model> patch)
{
   ...
   patch.ApplyTo(backendModel);
   ...
}

It appears to support setting some properties to null, and leaving other properties untouched. Internally, the JsonMergePatchDocument creates a JsonPatch document, with one OperationType.Replace for each item in the request. https://github.com/Morcatko/Morcatko.AspNetCore.JsonMergePatch/blob/master/src/Morcatko.AspNetCore.JsonMergePatch/Formatters/JsonMergePatchInputFormatter.cs

like image 42
Matt Avatar answered Nov 09 '22 00:11

Matt


This is a particular problem when using a language that doesn't support a distinction between undefined and null like JavaScript and TypeScript do. There are other options which you might consider:

  • use PUT (not always feasible)
  • for strings use "" to delete it because an empty string is often not a valid value (also not always feasible)
  • add an extra custom header to indicate if you really want to delete that value with a default value set to false (e.g. X-MYAPP-SET-EMAIL=true will delete email if it is null). Downside is that this could blow up your request and pain for client developers

Each option from above has its own drawbacks so think carefully before you decide which way you go.

like image 2
r0bnet Avatar answered Nov 08 '22 23:11

r0bnet


I come to this thread with the same question. My solution is similar to the 'CodeFuller' one but more complete as it covers API documentation with swagger and better because uses less code. It also uses System.text.json instead of the Newtonsoft library.

  1. Define your model by taking advantaging of the existent Optional struct (no need to create a new OptionalValue class)

    {
        public string Surname { get; set; }
    
        [JsonConverter(typeof(OptionalConverter<string>))]
        public Optional<string> Email { get; set; } = default;
    }
    
    
  2. Tell Swagger (if applicable) to format as a string input/type for a better client experience:

    c.MapType<Optional<string>>(() => new OpenApiSchema { Type = "string" });

  3. Add a custom JSON converter based on System.text.json:

    public class OptionalConverter<T> : JsonConverter<Optional<T>>
        {
            // https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to
            public override bool CanConvert(Type typeToConvert) =>
                typeToConvert == typeof(Optional<T>);
    
            public override Optional<T> Read(
                ref Utf8JsonReader reader,
                Type typeToConvert,
                JsonSerializerOptions options) =>
                new Optional<T>(JsonSerializer.Deserialize<T>(ref reader, options));
    
            public override void Write(
                Utf8JsonWriter writer,
                Optional<T> value,
                JsonSerializerOptions options) =>
                throw new NotImplementedException("OptionalValue is not suppose to be written");
        }
    
    
  4. That is. Now you have 3 states:

    [HttpPatch]
    [Consumes("application/merge-patch+json")]
    public void Patch([FromBody]SomeModel data)
    {
        if (data.Email.HasValue)
        {
            //  Email presents in Json
            if (data.Email.Value == null)
            {
                //  Email should be removed
            }
            else
            {
                //  Email should be updated
            }
        }
        else
        {
            //  Email does not present in Json and should not be affected
        }
    }
    
    
like image 1
r.pedrosa Avatar answered Nov 09 '22 00:11

r.pedrosa