Skip to content

Instantly share code, notes, and snippets.

@benfoster
Created January 8, 2013 22:48
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save benfoster/4488755 to your computer and use it in GitHub Desktop.
Save benfoster/4488755 to your computer and use it in GitHub Desktop.
Patching in ASP.NET Web API
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Dynamic;
using System.Linq;
using System.Linq.Expressions;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Runtime.Serialization;
using System.Web.Http;
using System.Web.Http.Controllers;
namespace WebAPIPatchDemo.Controllers
{
public class SettingsController : ApiController
{
static SiteSettings settings = new SiteSettings
{
Title = "Test Site",
Enabled = true,
PageSize = 20,
ThemeId = null
};
public SiteSettings Get()
{
return settings;
}
public HttpResponseMessage Put(UpdateSiteSettingsCommand command)
{
if (!ModelState.IsValid)
{
return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState);
}
Update(settings, command);
return Request.CreateResponse(HttpStatusCode.OK);
}
public void Patch(Delta<UpdateSiteSettingsCommand> patch)
{
// probably use automapper for this
var command = new UpdateSiteSettingsCommand
{
Title = settings.Title,
Enabled = settings.Enabled,
PageSize =settings.PageSize,
ThemeId = settings.ThemeId
};
patch.Patch(command);
Update(settings, command);
}
private void Update(SiteSettings settings, UpdateSiteSettingsCommand command)
{
settings.Title = command.Title;
settings.Enabled = command.Enabled;
settings.PageSize = (int)command.PageSize; // because JSON.net deserializes as long instead of int32
settings.ThemeId = (int?)command.ThemeId;
}
}
public class SiteSettings
{
public int? ThemeId { get; set; }
public string Title { get; set; }
public bool Enabled { get; set; }
public int PageSize { get; set; }
}
[DataContract]
public class UpdateSiteSettingsCommand
{
[Required]
[DataMember]
public string Title { get; set; }
[DataMember(IsRequired = true)]
public bool Enabled { get; set; }
[DataMember(IsRequired = true)]
public long PageSize { get; set; }
[DataMember]
public long? ThemeId { get; set; }
[DataMember]
public object Foo { get; set; }
}
/// <summary>
/// <see cref="IDelta" /> allows and tracks changes to an object.
/// </summary>
public interface IDelta
{
/// <summary>
/// Returns the Properties that have been modified through this IDelta as an
/// enumerable of Property Names
/// </summary>
[SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "Not appropriate to be a property")]
IEnumerable<string> GetChangedPropertyNames();
/// <summary>
/// Returns the Properties that have not been modified through this IDelta as an
/// enumerable of Property Names
/// </summary>
[SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "Not appropriate to be a property")]
IEnumerable<string> GetUnchangedPropertyNames();
/// <summary>
/// Attempts to set the Property called <paramref name="name"/> to the <paramref name="value"/> specified.
/// </summary>
/// <param name="name">The name of the Property</param>
/// <param name="value">The new value of the Property</param>
/// <returns>Returns <c>true</c> if successful and <c>false</c> if not.</returns>
bool TrySetPropertyValue(string name, object value);
/// <summary>
/// Attempts to get the value of the Property called <paramref name="name"/> from the underlying Entity.
/// </summary>
/// <param name="name">The name of the Property</param>
/// <param name="value">The value of the Property</param>
/// <returns>Returns <c>true</c> if the Property was found and <c>false</c> if not.</returns>
[SuppressMessage("Microsoft.Design", "CA1007:UseGenericsWhereAppropriate", Justification = "Generics not appropriate here")]
[SuppressMessage("Microsoft.Design", "CA1021:AvoidOutParameters", MessageId = "1#", Justification = "Out param is appropriate here")]
bool TryGetPropertyValue(string name, out object value);
/// <summary>
/// Attempts to get the <see cref="Type"/> of the Property called <paramref name="name"/> from the underlying Entity.
/// </summary>
/// <param name="name">The name of the Property</param>
/// <param name="type">The type of the Property</param>
/// <returns>Returns <c>true</c> if the Property was found and <c>false</c> if not.</returns>
bool TryGetPropertyType(string name, out Type type);
/// <summary>
/// Clears the <see cref="IDelta" />.
/// </summary>
void Clear();
}
/// <summary>
/// An attribute to disable WebApi model validation for a particular type.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)]
internal sealed class NonValidatingParameterBindingAttribute : ParameterBindingAttribute
{
public override HttpParameterBinding GetBinding(HttpParameterDescriptor parameter)
{
return parameter.BindWithFormatter(parameter.Configuration.Formatters, bodyModelValidator: null);
}
}
/// <summary>
/// A class the tracks changes (i.e. the Delta) for a particular <typeparamref name="TEntityType"/>.
/// </summary>
/// <typeparam name="TEntityType">TEntityType is the base type of entity this delta tracks changes for.</typeparam>
[NonValidatingParameterBinding]
public class Delta<TEntityType> : DynamicObject, IDelta where TEntityType : class
{
// cache property accessors for this type and all its derived types.
private static ConcurrentDictionary<Type, Dictionary<string, PropertyAccessor<TEntityType>>> _propertyCache = new ConcurrentDictionary<Type, Dictionary<string, PropertyAccessor<TEntityType>>>();
private Dictionary<string, PropertyAccessor<TEntityType>> _propertiesThatExist;
private HashSet<string> _changedProperties;
private TEntityType _entity;
private Type _entityType;
/// <summary>
/// Initializes a new instance of <see cref="Delta{TEntityType}"/>.
/// </summary>
public Delta()
: this(typeof(TEntityType))
{
}
/// <summary>
/// Initializes a new instance of <see cref="Delta{TEntityType}"/>.
/// </summary>
/// <param name="entityType">The derived entity type for which the changes would be tracked.
/// <paramref name="entityType"/> should be assignable to instances of <typeparamref name="TEntityType"/>.</param>
public Delta(Type entityType)
{
Initialize(entityType);
}
/// <summary>
/// The actual type of the entity for which the changes are tracked.
/// </summary>
public Type EntityType
{
get
{
return _entityType;
}
}
/// <summary>
/// Clears the Delta and resets the underlying Entity.
/// </summary>
public void Clear()
{
Initialize(_entityType);
}
/// <summary>
/// Attempts to set the Property called <paramref name="name"/> to the <paramref name="value"/> specified.
/// <remarks>
/// Only properties that exist on <see cref="EntityType"/> can be set.
/// If there is a type mismatch the request will fail.
/// </remarks>
/// </summary>
/// <param name="name">The name of the Property</param>
/// <param name="value">The new value of the Property</param>
/// <returns>True if successful</returns>
public bool TrySetPropertyValue(string name, object value)
{
if (name == null)
{
throw new ArgumentNullException("name");
}
if (!_propertiesThatExist.ContainsKey(name)) // BF - this is case sensitive, probably shouldn't be if working with json
{
return false;
}
PropertyAccessor<TEntityType> cacheHit = _propertiesThatExist[name];
if (value == null && !IsNullable(cacheHit.Property.PropertyType))
{
return false;
}
// BF - Issue here is that a nullable int is passed as a long
if (value != null && !cacheHit.Property.PropertyType.IsAssignableFrom(value.GetType()))
{
return false;
}
//.Setter.Invoke(_entity, new object[] { value });
cacheHit.SetValue(_entity, value);
_changedProperties.Add(name);
return true;
}
/// <summary>
/// Attempts to get the value of the Property called <paramref name="name"/> from the underlying Entity.
/// <remarks>
/// Only properties that exist on <see cref="EntityType"/> can be retrieved.
/// Both modified and unmodified properties can be retrieved.
/// </remarks>
/// </summary>
/// <param name="name">The name of the Property</param>
/// <param name="value">The value of the Property</param>
/// <returns>True if the Property was found</returns>
public bool TryGetPropertyValue(string name, out object value)
{
if (name == null)
{
throw new ArgumentNullException("name");
}
if (_propertiesThatExist.ContainsKey(name))
{
PropertyAccessor<TEntityType> cacheHit = _propertiesThatExist[name];
value = cacheHit.GetValue(_entity);
return true;
}
else
{
value = null;
return false;
}
}
/// <summary>
/// Attempts to get the <see cref="Type"/> of the Property called <paramref name="name"/> from the underlying Entity.
/// <remarks>
/// Only properties that exist on <see cref="EntityType"/> can be retrieved.
/// Both modified and unmodified properties can be retrieved.
/// </remarks>
/// </summary>
/// <param name="name">The name of the Property</param>
/// <param name="type">The type of the Property</param>
/// <returns>Returns <c>true</c> if the Property was found and <c>false</c> if not.</returns>
public bool TryGetPropertyType(string name, out Type type)
{
if (name == null)
{
throw new ArgumentNullException("name");
}
PropertyAccessor<TEntityType> value;
if (_propertiesThatExist.TryGetValue(name, out value))
{
type = value.Property.PropertyType;
return true;
}
else
{
type = null;
return false;
}
}
/// <summary>
/// Overrides the DynamicObject TrySetMember method, so that only the properties
/// of <see cref="EntityType"/> can be set.
/// </summary>
public override bool TrySetMember(SetMemberBinder binder, object value)
{
if (binder == null)
{
throw new ArgumentNullException("binder");
}
return TrySetPropertyValue(binder.Name, value);
}
/// <summary>
/// Overrides the DynamicObject TryGetMember method, so that only the properties
/// of <see cref="EntityType"/> can be got.
/// </summary>
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
if (binder == null)
{
throw new ArgumentNullException("binder");
}
return TryGetPropertyValue(binder.Name, out result);
}
/// <summary>
/// Returns the <see cref="EntityType"/> instance
/// that holds all the changes (and original values) being tracked by this Delta.
/// </summary>
[SuppressMessage("Microsoft.Design", "CA1024:UsePropertiesWhereAppropriate", Justification = "Not appropriate to be a property")]
public TEntityType GetEntity()
{
return _entity;
}
/// <summary>
/// Returns the Properties that have been modified through this Delta as an
/// enumeration of Property Names
/// </summary>
public IEnumerable<string> GetChangedPropertyNames()
{
return _changedProperties;
}
/// <summary>
/// Returns the Properties that have not been modified through this Delta as an
/// enumeration of Property Names
/// </summary>
public IEnumerable<string> GetUnchangedPropertyNames()
{
return _propertiesThatExist.Keys.Except(GetChangedPropertyNames());
}
/// <summary>
/// Copies the changed property values from the underlying entity (accessible via <see cref="GetEntity()" />)
/// to the <paramref name="original"/> entity.
/// </summary>
/// <param name="original">The entity to be updated.</param>
public void CopyChangedValues(TEntityType original)
{
if (original == null)
{
throw new ArgumentNullException("original");
}
if (!_entityType.IsAssignableFrom(original.GetType()))
{
throw new InvalidOperationException(string.Format("Cannot use Delta of type '{0}' on an entity of type '{1}'.", _entityType, original.GetType()));
}
PropertyAccessor<TEntityType>[] propertiesToCopy = GetChangedPropertyNames().Select(s => _propertiesThatExist[s]).ToArray();
foreach (PropertyAccessor<TEntityType> propertyToCopy in propertiesToCopy)
{
propertyToCopy.Copy(_entity, original);
}
}
/// <summary>
/// Copies the unchanged property values from the underlying entity (accessible via <see cref="GetEntity()" />)
/// to the <paramref name="original"/> entity.
/// </summary>
/// <param name="original">The entity to be updated.</param>
public void CopyUnchangedValues(TEntityType original)
{
if (original == null)
{
throw new ArgumentNullException("original");
}
if (!_entityType.IsAssignableFrom(original.GetType()))
{
throw new InvalidOperationException(string.Format("Cannot use Delta of type '{0}' on an entity of type '{1}'.", _entityType, original.GetType()));
}
PropertyAccessor<TEntityType>[] propertiesToCopy = GetUnchangedPropertyNames().Select(s => _propertiesThatExist[s]).ToArray();
foreach (PropertyAccessor<TEntityType> propertyToCopy in propertiesToCopy)
{
propertyToCopy.Copy(_entity, original);
}
}
/// <summary>
/// Overwrites the <paramref name="original"/> entity with the changes tracked by this Delta.
/// <remarks>The semantics of this operation are equivalent to a HTTP PATCH operation, hence the name.</remarks>
/// </summary>
/// <param name="original">The entity to be updated.</param>
public void Patch(TEntityType original)
{
CopyChangedValues(original);
}
/// <summary>
/// Overwrites the <paramref name="original"/> entity with the values stored in this Delta.
/// <remarks>The semantics of this operation are equivalent to a HTTP PUT operation, hence the name.</remarks>
/// </summary>
/// <param name="original">The entity to be updated.</param>
public void Put(TEntityType original)
{
CopyChangedValues(original);
CopyUnchangedValues(original);
}
private void Initialize(Type entityType)
{
if (entityType == null)
{
throw new ArgumentNullException("entityType");
}
if (!typeof(TEntityType).IsAssignableFrom(entityType))
{
throw new InvalidOperationException(string.Format("The entity type '{0}' is not assignable to the Delta type '{1}'.", entityType, typeof(TEntityType)));
}
_entity = Activator.CreateInstance(entityType) as TEntityType;
_changedProperties = new HashSet<string>();
_entityType = entityType;
_propertiesThatExist = InitializePropertiesThatExist();
}
private Dictionary<string, PropertyAccessor<TEntityType>> InitializePropertiesThatExist()
{
return _propertyCache.GetOrAdd(
_entityType,
(backingType) => backingType
.GetProperties()
.Where(p => p.GetSetMethod() != null && p.GetGetMethod() != null)
.Select<PropertyInfo, PropertyAccessor<TEntityType>>(p => new CompiledPropertyAccessor<TEntityType>(p))
.ToDictionary(p => p.Property.Name));
}
private static bool IsNullable(Type type)
{
return !type.IsValueType || Nullable.GetUnderlyingType(type) != null;
}
}
/// <summary>
/// Represents a strategy for Getting and Setting a PropertyInfo on <typeparamref name="TEntityType"/>
/// </summary>
/// <typeparam name="TEntityType">The type that contains the PropertyInfo</typeparam>
internal abstract class PropertyAccessor<TEntityType> where TEntityType : class
{
protected PropertyAccessor(PropertyInfo property)
{
if (property == null)
{
throw new ArgumentNullException("property");
}
Property = property;
if (Property.GetGetMethod() == null || Property.GetSetMethod() == null)
{
throw new ArgumentException("The PropertyInfo provided must have public 'get' and 'set' accessor methods.", "property");
}
}
public PropertyInfo Property
{
get;
private set;
}
public void Copy(TEntityType from, TEntityType to)
{
if (from == null)
{
throw new ArgumentNullException("from");
}
if (to == null)
{
throw new ArgumentNullException("to");
}
SetValue(to, GetValue(from));
}
public abstract object GetValue(TEntityType entity);
public abstract void SetValue(TEntityType entity, object value);
}
/// <summary>
/// CompiledPropertyAccessor is a <see cref="PropertyAccessor{TEntityType}"/> that pre-compiles (using expression)
/// a Getter and Setter for the PropertyInfo of TEntityType provided via the constructor.
/// </summary>
/// <typeparam name="TEntityType">The type on which the PropertyInfo exists</typeparam>
internal class CompiledPropertyAccessor<TEntityType> : PropertyAccessor<TEntityType> where TEntityType : class
{
private Action<TEntityType, object> _setter;
private Func<TEntityType, object> _getter;
public CompiledPropertyAccessor(PropertyInfo property)
: base(property)
{
_setter = MakeSetter(Property);
_getter = MakeGetter(Property);
}
public override object GetValue(TEntityType entity)
{
if (entity == null)
{
throw new ArgumentNullException("entity");
}
return _getter(entity);
}
public override void SetValue(TEntityType entity, object value)
{
if (entity == null)
{
throw new ArgumentNullException("entity");
}
_setter(entity, value);
}
private static Action<TEntityType, object> MakeSetter(PropertyInfo property)
{
Type type = typeof(TEntityType);
ParameterExpression entityParameter = Expression.Parameter(type);
ParameterExpression objectParameter = Expression.Parameter(typeof(object));
MemberExpression toProperty = Expression.Property(Expression.TypeAs(entityParameter, property.DeclaringType), property);
UnaryExpression fromValue = Expression.Convert(objectParameter, property.PropertyType);
BinaryExpression assignment = Expression.Assign(toProperty, fromValue);
Expression<Action<TEntityType, object>> lambda = Expression.Lambda<Action<TEntityType, object>>(assignment, entityParameter, objectParameter);
return lambda.Compile();
}
private static Func<TEntityType, object> MakeGetter(PropertyInfo property)
{
Type type = typeof(TEntityType);
ParameterExpression entityParameter = Expression.Parameter(type);
MemberExpression fromProperty = Expression.Property(Expression.TypeAs(entityParameter, property.DeclaringType), property);
UnaryExpression convert = Expression.Convert(fromProperty, typeof(Object));
Expression<Func<TEntityType, object>> lambda = Expression.Lambda<Func<TEntityType, object>>(convert, entityParameter);
return lambda.Compile();
}
}
}
@benfoster
Copy link
Author

The above example uses the Delta<T> patching support added for OData in ASP.NET Web API. The above code can be used independently of the OData Nuget package.

Some things worth pointing out.

The Delta<T> is designed around the OData protocol. This means if you want to use it with a different media type such as JSON you may need to tweak it slightly.

On line #230 the following is executed:

if (!_propertiesThatExist.ContainsKey(name))

This is case sensitive. If using JSON you'd probably want to pass in a case in-sensitive comparer.

On line #243 the property type is compared to that of the input type:

if (value != null && !cacheHit.Property.PropertyType.IsAssignableFrom(value.GetType()))

With JSON numbers are being deserialized as long so you would need to update the above code, or explicitly cast in your own code e.g.

    private void Update(SiteSettings settings, UpdateSiteSettingsCommand command)
    {
        settings.Title = command.Title;
        settings.Enabled = command.Enabled;
        settings.PageSize = (int)command.PageSize; // because JSON.net deserializes as long instead of int32
        settings.ThemeId = (int?)command.ThemeId;
    }

Finally, this example shows how I would perform patching. Whilst the OData Patching support is great, like so many Microsoft examples they demonstrate updating your domain objects directly. Personally I would create a PATCHed "UpdateCommand" and then apply that in the same way I would a full PUT.

@igor9silva
Copy link

Gorgeous!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment