ASP.NET Web API 2 and partial updates

2019-04-07 22:59发布

问题:

We are using ASP.NET Web API 2 and want to expose ability to partially edit some object in the following fashion:

HTTP PATCH /customers/1
{
  "firstName": "John",
  "lastName": null
}

... to set firstName to "John" and lastName to null.

HTTP PATCH /customers/1
{
  "firstName": "John"
}

... in order just to update firstName to "John" and do not touch lastName at all. Suppose we have a lot of properties that we want to update with such semantic.

This is quite convenient behavior that is exercised by OData for instance.

The problem is that default JSON serializer will just come up with null in both cases, so it's impossible to distinguish.

I'm looking for some way to annotate model with some kind of wrappers (with value and flag set/unset inside) that would allow to see this difference. Any existing solutions for this?

回答1:

At first I misunderstood the problem. As I was working with Xml I thought it was quite easy. Just add an attribute to the property and leave the property empty. But as I found out, Json doesn't work like that. Since I was looking for a solution that works for both xml and json, you'll find xml references in this answer. Another thing, I wrote this with a C# client in mind.

The first step is to create two classes for serialization.

public class ChangeType
{
    [JsonProperty("#text")]
    [XmlText]
    public string Text { get; set; }
}

public class GenericChangeType<T> : ChangeType
{
}

I've chosen for a generic and a non-generic class because it is hard to cast to a generic type while this is not important. Also, for xml implementation it is necessary that XmlText is string.

XmlText is the actual value of the property. The advantage is that you can add attributes to this object and the fact that this is an object, not just string. In Xml it looks like: <Firstname>John</Firstname>

For Json this doesn't work. Json doesn't know attributes. So for Json this is just a class with properties. To implement the idea of the xml value (I will get to that later), I've renamed the property to #text. This is just a convention.

As XmlText is string (and we want to serialize to string), this is fine to store the value disregard the type. But in case of serialization, I want to know the actual type.

The drawback is that the viewmodel needs to reference these types, the advantage is that the properties are strongly typed for serialization:

public class CustomerViewModel
{
    public GenericChangeType<int> Id { get; set; }
    public ChangeType Firstname { get; set; }
    public ChangeType Lastname { get; set; }
    public ChangeType Reference { get; set; }
}

Suppose I set the values:

var customerViewModel = new CustomerViewModel
{
    // Where int needs to be saved as string.
    Id = new GenericeChangeType<int> { Text = "12" },
    Firstname = new ChangeType { Text = "John" },
    Lastname = new ChangeType { },
    Reference = null // May also be omitted.
}

In xml this will look like:

<CustomerViewModel>
  <Id>12</Id>
  <Firstname>John</Firstname>
  <Lastname />
</CustomerViewModel>

Which is enough for the server to detect the changes. But with json it will generate the following:

{
    "id": { "#text": "12" },
    "firstname": { "#text": "John" },
    "lastname": { "#text": null }
}

It can work, because in my implementation the receiving viewmodel has the same definition. But since you are talking about serialization only and in case you use another implementation you would want:

{
    "id": 12,
    "firstname": "John",
    "lastname": null
}

That is where we need to add a custom json converter to produce this result. The relevant code is in WriteJson, assuming you would add this converter to the serializer settings only. But for the sake of completeness I've added the readJson code as well.

public class ChangeTypeConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        // This is important, we can use this converter for ChangeType only
        return typeof(ChangeType).IsAssignableFrom(objectType);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        var value = JToken.Load(reader);

        // Types match, it can be deserialized without problems.
        if (value.Type == JTokenType.Object)
            return JsonConvert.DeserializeObject(value.ToString(), objectType);

        // Convert to ChangeType and set the value, if not null:
        var t = (ChangeType)Activator.CreateInstance(objectType);
        if (value.Type != JTokenType.Null)
            t.Text = value.ToString();
        return t;
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var d = value.GetType();

        if (typeof(ChangeType).IsAssignableFrom(d))
        {
            var changeObject = (ChangeType)value;

            // e.g. GenericChangeType<int>
            if (value.GetType().IsGenericType)
            {
                try
                {
                    // type - int
                    var type = value.GetType().GetGenericArguments()[0];
                    var c = Convert.ChangeType(changeObject.Text, type);
                    // write the int value
                    writer.WriteValue(c);
                }
                catch
                {
                    // Ignore the exception, just write null.
                    writer.WriteNull();
                }
            }
            else
            {
                // ChangeType object. Write the inner string (like xmlText value)
                writer.WriteValue(changeObject.Text);
            }
            // Done writing.
            return;
        }
        // Another object that is derived from ChangeType.
        // Do not add the current converter here because this will result in a loop.
        var s = new JsonSerializer
        {
            NullValueHandling = serializer.NullValueHandling,
            DefaultValueHandling = serializer.DefaultValueHandling,
            ContractResolver = serializer.ContractResolver
        };
        JToken.FromObject(value, s).WriteTo(writer);
    }
}

At first I tried to add the converter to the class: [JsonConverter(ChangeTypeConverter)]. But the problem is that the converter will be used at all times, which creates a reference loop (as also mentioned in the comment in the code above). Also you may want to use this converter for serialization only. That is why I've added it to the serializer only:

var serializerSettings = new JsonSerializerSettings
{
    NullValueHandling = NullValueHandling.Ignore,
    DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate,
    Converters = new List<JsonConverter> { new ChangeTypeConverter() },
    ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
};
var s = JsonConvert.SerializeObject(customerViewModel, serializerSettings);

This will generate the json I was looking for and should be enough to let the server detect the changes.

-- update --

As this answer focusses on serialization, the most important thing is that lastname is part of the serialization string. It then depends on the receiving party how to deserialize the string into an object again.

Serialization and deserialization use different settings. In order to deserialize again you can use:

var deserializerSettings = new JsonSerializerSettings
{
    //NullValueHandling = NullValueHandling.Ignore,
    DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate,
    Converters = new List<JsonConverter> { new Converters.NoChangeTypeConverter() },
    ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
};
var obj = JsonConvert.DeserializeObject<CustomerViewModel>(s, deserializerSettings);

If you use the same classes for deserialization then Request.Lastname should be of ChangeType, with Text = null.

I'm not sure why removing the NullValueHandling from the deserialization settings causes problems in your case. But you can overcome this by writing an empty object as value instead of null. In the converter the current ReadJson can already handle this. But in WriteJson there has to be a modification. Instead of writer.WriteValue(changeObject.Text); you need something like:

if (changeObject.Text == null)
    JToken.FromObject(new ChangeType(), s).WriteTo(writer);
else
    writer.WriteValue(changeObject.Text);

This would result in:

{
    "id": 12,
    "firstname": "John",
    "lastname": {}
}


回答2:

I know I'm a little bit late on this answer, but I think I have a solution that doesn't require having to change serialization and also doesn't include reflection (This article refers you to a JsonPatch library that someone wrote that uses reflection).

Basically create a generic class representing a property that could be patched

    public class PatchProperty<T> where T : class
    {
        public bool Include { get; set; }
        public T Value { get; set; }
    }

And then create models representing the objects that you want to patch where each of the properties is a PatchProperty

    public class CustomerPatchModel
    {
        public PatchProperty<string> FirstName { get; set; }
        public PatchProperty<string> LastName { get; set; }
        public PatchProperty<int> IntProperty { get; set; }
    }

Then your WebApi method would look like

    public void PatchCustomer(CustomerPatchModel customerPatchModel)
    {
        if (customerPatchModel.FirstName?.Include == true)
        {
            // update first name 
            string firstName = customerPatchModel.FirstName.Value;
        }
        if (customerPatchModel.LastName?.Include == true)
        {
            // update last name
            string lastName = customerPatchModel.LastName.Value;
        }
        if (customerPatchModel.IntProperty?.Include == true)
        {
            // update int property
            int intProperty = customerPatchModel.IntProperty.Value;
        }
    }

And you could send a request with some Json that looks like

{
    "LastName": { "Include": true, "Value": null },
    "OtherProperty": { "Include": true, "Value": 7 }
}

Then we would know to ignore FirstName but still set the other properties to null and 7 respectively.

Note that I haven't tested this and I'm not 100% sure it would work. It would basically rely on .NET's ability to serialize the generic PatchProperty. But since the properties on the model specify the type of the generic T, I would think it would be able to. Also since we have "where T : class" on the PatchProperty declaration, the Value should be nullable. I'd be interested to know if this actually works though. Worst case you could implement a StringPatchProperty, IntPatchProperty, etc. for all your property types.



回答3:

I know that answers which are already given cover all aspects already, but just want to share concise summary of what we ended up doing and what seems to work for us pretty well.

Created a generic data contract

[DataContract]
public class RQFieldPatch<T>
{
    [DataMember(Name = "value")]
    public T Value { get; set; }
}

Created ad-hoc data cotnracts for patch requests

Sample is below.

[DataContract]
public class PatchSomethingRequest
{
    [DataMember(Name = "prop1")]
    public RQFieldPatch<EnumTypeHere> Prop1 { get; set; }

    [DataMember(Name = "prop2")]
    public RQFieldPatch<ComplexTypeContractHere> Prop2 { get; set; }

    [DataMember(Name = "prop3")]
    public RQFieldPatch<string> Prop3 { get; set; }

    [DataMember(Name = "prop4")]
    public RQFieldPatch<int> Prop4 { get; set; }

    [DataMember(Name = "prop5")]
    public RQFieldPatch<int?> Prop5 { get; set; }
}

Business Logic

Simple.

if (request.Prop1 != null)
{
    // update code for Prop1, the value is stored in request.Prop1.Value
}

Json format

Simple. Not that extensive as "JSON Patch" standard, but covers all our needs.

{
  "prop1": null, // will be skipped
  // "prop2": null // skipped props also skipped as they will get default (null) value
  "prop3": { "value": "test" } // value update requested
}

Properties

  • Simple contracts, simple logic
  • No serialization customization
  • Support for null values assignment
  • Covers any types: value, reference, complex custom types, whatever


回答4:

Here's my quick and inexpensive solution...

public static ObjectType Patch<ObjectType>(ObjectType source, JObject document)
    where ObjectType : class
{
    JsonSerializerSettings settings = new JsonSerializerSettings
    {
        ContractResolver = new CamelCasePropertyNamesContractResolver()
    };

    try
    {
        String currentEntry = JsonConvert.SerializeObject(source, settings);

        JObject currentObj = JObject.Parse(currentEntry);

        foreach (KeyValuePair<String, JToken> property in document)
        {    
            currentObj[property.Key] = property.Value;
        }

        String updatedObj = currentObj.ToString();

        return JsonConvert.DeserializeObject<ObjectType>(updatedObj);
    }
    catch (Exception ex)
    {
        throw ex;
    }
}

When fetching the request body from your PATCH based method, make sure to take the argument as a type such as JObject. JObject during iteration returns a KeyValuePair struct which inherently simplifies the modification process. This allows you to get your request body content without receiving a deserialized result of your desired type.

This is beneficial due to the fact that you don't need any additional verification for nullified properties. If you want your values to be nullified that also works because the Patch<ObjectType>() method only loops through properties given in the partial JSON document.

With the Patch<ObjectType>() method, you only need to pass your source or target instance, and the partial JSON document that will update your object. This method will apply camelCase based contract resolver to prevent incompatible and inaccurate property names from being made. This method will then serialize your passed instance of a certain type and turned into a JObject.

The method then replaces all properties from the new JSON document to the current and serialized document without any unnecessary if statements.

The method stringifies the current document which now is modified, and deserializes the modified JSON document to your desired generic type.

If an exception occur, the method will simply throw it. Yes, it is rather unspecific but you are the programmer, you need to know what to expect...

This can all be done on a single and simple syntax with the following:

Entity entity = AtomicModifier.Patch<Entity>(entity, partialDocument);

This is what the operation would normally look like:

// Partial JSON document (originates from controller).
JObject newData = new { role = 9001 };

// Current entity from EF persistence medium.
User user = await context.Users.FindAsync(id);

// Output:
//
//     Username : engineer-186f
//     Role     : 1
//
Debug.WriteLine($"Username : {0}", user.Username);
Debug.WriteLine($"Role     : {0}", user.Role);

// Partially updated entity.
user = AtomicModifier.Patch<User>(user, newData);

// Output:
//
//     Username : engineer-186f
//     Role     : 9001
//
Debug.WriteLine($"Username : {0}", user.Username);
Debug.WriteLine($"Role     : {0}", user.Role);

// Setting the new values to the context.
context.Entry(user).State = EntityState.Modified;

This method will work well if you can correctly map your two documents with the camelCase contract resolver.

Enjoy...

Update

I updated the Patch<T>() method with the following code...

public static T PatchObject<T>(T source, JObject document) where T : class
{
    Type type = typeof(T);

    IDictionary<String, Object> dict = 
        type
            .GetProperties()
            .ToDictionary(e => e.Name, e => e.GetValue(source));

    string json = document.ToString();

    var patchedObject = JsonConvert.DeserializeObject<T>(json);

    foreach (KeyValuePair<String, Object> pair in dict)
    {
        foreach (KeyValuePair<String, JToken> node in document)
        {
            string propertyName =   char.ToUpper(node.Key[0]) + 
                                    node.Key.Substring(1);

            if (propertyName == pair.Key)
            {
                PropertyInfo property = type.GetProperty(propertyName);

                property.SetValue(source, property.GetValue(patchedObject));

                break;
            }
        }
    }

    return source;
}