Web API 2 - 实现 PATCH 请求

34

我目前拥有一个实现了 RESTful API 的 Web API。我的 API 模型如下:

public class Member
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime Created { get; set; }
    public DateTime BirthDate { get; set; }
    public bool IsDeleted { get; set; }
}

我已经实现了一个类似于这样更新行的PUT方法(为简洁起见,我省略了一些不相关的内容):

[Route("{id}")]
[HttpPut]
public async System.Threading.Tasks.Task<HttpResponseMessage> UpdateRow(int id, 
    [FromBody]Models.Member model)
{
    // Do some error checking
    // ...
    // ...

    var myDatabaseEntity = new BusinessLayer.Member(id);
    myDatabaseEntity.FirstName = model.FirstName;
    myDatabaseEntity.LastName = model.LastName;
    myDatabaseEntity.Created = model.Created;
    myDatabaseEntity.BirthDate = model.BirthDate;
    myDatabaseEntity.IsDeleted = model.IsDeleted;

    await myDatabaseEntity.SaveAsync();
}

使用PostMan,我可以发送以下 JSON 数据,一切都能正常工作:

{
    firstName: "Sara",
    lastName: "Smith",
    created: "2018/05/10",
    birthDate: "1977/09/12",
    isDeleted: false
}
如果我将此作为PUT请求的正文发送到http://localhost:8311/api/v1/Member/12,则我的数据记录中ID为12的记录将更新为JSON中所见。
但是我想实现PATCH动词以进行部分更新。 如果Sara结婚了,我想能够发送这个JSON:
{
    lastName: "Jones"
}

我希望能够仅发送该JSON并更新LastName字段,而将所有其他字段保持不变。

我尝试了以下代码:

[Route("{id}")]
[HttpPatch]
public async System.Threading.Tasks.Task<HttpResponseMessage> UpdateRow(int id, 
    [FromBody]Models.Member model)
{
}
我的问题是这个代码返回了model对象中的所有字段(除了LastName字段以外,其他都是null),这很合理,因为我要求一个Models.Member对象。我想知道的是,是否有一种方法能够检测到实际上已经在JSON请求中发送了哪些属性,以便我只更新这些字段?

如果由于某些原因您不想使用 JsonPatchDocument,而您的客户希望发送一个直接的 API 调用,并仅指定一些属性,就像您在示例中所做的那样 - 请查看 patcharp 库 https://github.com/mexanichp/patcharp这可以为您提供方向,或者一旦发布,您可以将其作为 NuGet 包使用。 - mexanichp
6个回答

34

我希望这可以帮助你使用Microsoft JsonPatchDocument:

.Net Core 2.1控制器中的补丁操作:

[HttpPatch("{id}")]
public IActionResult Patch(int id, [FromBody]JsonPatchDocument<Node> value)
{
    try
    {
        //nodes collection is an in memory list of nodes for this example
        var result = nodes.FirstOrDefault(n => n.Id == id);
        if (result == null)
        {
            return BadRequest();
        }    
        value.ApplyTo(result, ModelState);//result gets the values from the patch request
        return NoContent();
    }
    catch (Exception ex)
    {
        return StatusCode(StatusCodes.Status500InternalServerError, ex);
    }
}

Node模型类:

[DataContract(Name ="Node")]
public class Node
{
    [DataMember(Name = "id")]
    public int Id { get; set; }

    [DataMember(Name = "node_id")]
    public int Node_id { get; set; }

    [DataMember(Name = "name")]
    public string Name { get; set; }

    [DataMember(Name = "full_name")]
    public string Full_name { get; set; }
}
更新“full_name”和“node_id”属性的有效Patch JSon将是操作数组,例如:
[
  { "op": "replace", "path": "full_name", "value": "NewNameWithPatch"},
  { "op": "replace", "path": "node_id", "value": 10}
]

如您所见,“op”表示您想执行的操作,最常见的是“replace”,它将只为新值设置该属性的现有值,但还有其他选项:

[
  { "op": "test", "path": "property_name", "value": "value" },
  { "op": "remove", "path": "property_name" },
  { "op": "add", "path": "property_name", "value": [ "value1", "value2" ] },
  { "op": "replace", "path": "property_name", "value": 12 },
  { "op": "move", "from": "property_name", "path": "other_property_name" },
  { "op": "copy", "from": "property_name", "path": "other_property_name" }
]

这里是我基于反射所构建的扩展方法,用于实现C#中Patch("replace")规范。您可以使用它将任何对象序列化以执行Patch (“replace”)操作。同时,您还可以传递所需的编码方式,它将返回一个HttpContent (StringContent),可直接发送给httpClient.PatchAsync(endPoint, httpContent):

public static StringContent ToPatchJsonContent(this object node, Encoding enc = null)
{
    List<PatchObject> patchObjectsCollection = new List<PatchObject>();

    foreach (var prop in node.GetType().GetProperties())
    {
        var patch = new PatchObject{ Op = "replace", Path = prop.Name , Value = prop.GetValue(node) };
        patchObjectsCollection.Add(patch);                
    }

    MemoryStream payloadStream = new MemoryStream();
    DataContractJsonSerializer serializer = new DataContractJsonSerializer(patchObjectsCollection.GetType());
    serializer.WriteObject(payloadStream, patchObjectsCollection);
    Encoding encoding = enc ?? Encoding.UTF8;
    var content = new StringContent(Encoding.UTF8.GetString(payloadStream.ToArray()), encoding, "application/json");

    return content;
}

}

注意到tt也使用我创建的这个类来使用DataContractJsonSerializer序列化PatchObject:

[DataContract(Name = "PatchObject")]
class PatchObject
{
    [DataMember(Name = "op")]
    public string Op { get; set; }
    [DataMember(Name = "path")]
    public string Path { get; set; }
    [DataMember(Name = "value")]
    public object Value { get; set; }
}

使用C#编写的如何使用扩展方法和HttpClient发出Patch请求的示例:

    var nodeToPatch = new { Name = "TestPatch", Private = true };//You can use anonymous type
    HttpContent content = nodeToPatch.ToPatchJsonContent();//Invoke the extension method to serialize the object

    HttpClient httpClient = new HttpClient();
    string endPoint = "https://localhost:44320/api/nodes/1";
    var response = httpClient.PatchAsync(endPoint, content).Result;

谢谢


24

PATCH 操作通常不使用与 POSTPUT 操作相同的模型,原因是如何区分 null不更改。根据IETF

然而,在 PATCH 中,封闭的实体包含一组指令,描述应该如何修改当前驻留在起始服务器上的资源,以生成新版本。

您可以在这里查看它们关于 PATCH 的建议。(链接) 摘要如下:

[
    { "op": "test", "path": "/a/b/c", "value": "foo" },
    { "op": "remove", "path": "/a/b/c" },
    { "op": "add", "path": "/a/b/c", "value": [ "foo", "bar" ] },
    { "op": "replace", "path": "/a/b/c", "value": 42 },
    { "op": "move", "from": "/a/b/c", "path": "/a/b/d" },
    { "op": "copy", "from": "/a/b/d", "path": "/a/b/e" }
]

2
因为误导性地暗示了IETF建议了这种格式,所以被踩了。在查看了他们的“关于我们”和“联系我们”页面后,你提供的“here”网站似乎与IETF没有任何关联。 - jramm
1
@jramm - 同意下降投票; 我有同样的反应。然而,进一步研究表明,IETF确实有一个单独的RFC描述了Tipx给出链接中相同的格式 - RFC 6902,https://tools.ietf.org/html/rfc6902。 6902是5987的子集,因此不强制要求使用它 - 也许甚至不建议使用。 Tipx可以相应地更新您的答案吗? - Andrew Stevens
1
不,我会保持原样。我给出的示例恰好是您链接的RFC中“文档结构”部分中的示例。虽然它不是100%完整的答案,但我仍然坚持我的原始想法,即这是一个很好的信息片段,可以指导正确的方向。我的回答背后的主要思想是“小心,补丁不仅仅是SQL upsert”。如果您想编辑答案,请随意! - Tipx

7

@Tipx的答案关于使用PATCH是正确的,但正如你可能已经发现的那样,在像C#这样的静态类型语言中实际实现它并不是一个简单的练习。

在使用PATCH来表示单个领域实体的部分更新集的情况下(例如仅为具有许多其他属性的联系人更新名字和姓氏),您需要做的是沿着'PATCH'请求中的每个指令循环,然后将该指令应用于类的实例。

应用单个指令将包括:

  • 查找与指令中的名称匹配的实例属性,或处理您没有预期的属性名称
  • 对于更新:尝试解析提交的补丁中的值到实例属性中,并处理错误(例如,如果实例属性是布尔型但补丁指令包含日期)
  • 决定如何处理Add指令,因为您不能将新属性添加到静态类型的C#类中。一种方法是说Add意味着“仅在属性的现有值为空时设置实例属性的值”

针对完整的.NET Framework上的Web API 2,JSONPatch github项目似乎尝试提供此代码,尽管最近该存储库上没有进行太多开发,并且自述文件确实说明:

这仍然是一个早期项目,除非您了解源代码并且不介意修复一些错误,否则不要在生产中使用它。

.NET Core上的情况更简单,因为它具有一组功能来支持Microsoft.AspNetCore.JsonPatch命名空间

相当有用的jsonpatch.com网站还列出了其他几个.NET中的Patch选项:

我需要将此功能添加到我们现有的Web API 2项目中,因此在执行此操作时,如果发现其他有用的内容,我会更新此答案。


2
如果你的JSON对象中省略了某个属性,ASP.NET不会在对象上“设置”该属性,该属性将具有其默认值。为了知道哪些属性与JSON对象一起发送,你需要有一种方法来检测对象的哪些属性被设置。
为了检测哪些属性实际上已经被JSON对象发送,你可以修改你的Member类以包含一个集合,其中包含已经“设置”的属性名称。然后,对于所有你想要知道它们是否在JSON对象中被发送的属性,在设置属性时应该将属性的名称添加到设置属性的集合中。
public class Member
{
    private string _firstName;
    private string _lastName;
    ...
    private bool _isDeleted;

    public string FirstName 
    { 
        get => _firstName;  
        set 
        {
            _firstName = value;
            _setProperties.Add(nameof(FirstName));
        }
     }
    public string LastName
    { 
        get => _lastName;  
        set 
        {
            _lastName = value;
            _setProperties.Add(nameof(LastName));
        }
     }
    ...
    public bool IsDeleted
    { 
        get => _isDeleted;  
        set 
        {
            _isDeleted= value;
            _setProperties.Add(nameof(IsDeleted));
        }
     }
    
    private readonly HashSet<string> _setProperties = new HashSet<string>();
    public HashSet<string> GetTheSetProperties()
    {
        return new HashSet<string>(_setProperties);
    }

}

UpdateRow方法中,您现在可以通过检查它是否在_setProperties集合中来检查属性是否已在JSON中发送。因此,如果您想查看LastName是否已在JSON中发送,只需执行以下操作:bool lastNameWasInJson = model.Contains(nameof(model.LastName));

2
我想达到的目标与其他人描述的不同,但使用了不同的方法。我创建了一个可工作的repo,如果您想查看它:https://github.com/emab/patch-example 如果您拥有以下两个模型: 数据库模型
public class WeatherDBModel
    {
        [Key]
        public int Id { get; set; }
        public string City { get; set; }
        public string Country { get; set; }
        public double Temperature { get; set; }
        public double WindSpeed { get; set; }
        public double Rain { get; set; }

        public Weather(int id, string city, string country, double temperature, double windSpeed, double rain)
        {
            Id = id;
            City = city;
            Country = country;
            Temperature = temperature;
            WindSpeed = windSpeed;
            Rain = rain;
        }
    }

更新模型

包含数据库模型属性的确切名称。包括可更新的属性。

public class WeatherUpdateModel
{
      public string? City { get; set; }
      public string? Country { get; set; }
      public double Temperature { get; set; }
      public double WindSpeed { get; set; }
      public double Rain { get; set; }
}

将此更新模型与要更新的对象的id一起发送到服务层。

然后,您可以在存储库层实现以下方法,该方法将updateModel中的任何非空值映射到已找到的现有实体中:

public Weather Update(int id, WeatherUpdate updateObject)
{
    // find existing entity
    var existingEntity = _context.Weather.Find(id);
    
    // handle not found
    if (existingEntity == null)
    {
        throw new EntityNotFoundException(id);
    }

    // iterate through all of the properties of the update object
    // in this example it includes all properties apart from `id`
    foreach (PropertyInfo prop in updateObject.GetType().GetProperties())
    {
        // check if the property has been set in the updateObject
        // if it is null we ignore it. If you want to allow null values to be set, you could add a flag to the update object to allow specific nulls
        if (prop.GetValue(updateObject) != null)
        {
            // if it has been set update the existing entity value
            existingEntity.GetType().GetProperty(prop.Name)?.SetValue(existingEntity, prop.GetValue(updateObject));               
        }
    }
    _context.SaveChanges();
    return existingEntity;
}

使用这种方法,您可以更改您的模型而不必担心更新逻辑,只要确保UpdateModel与数据库模型保持同步即可。


如果您想将数据库模型的值设置回null,该怎么办? - jimebe
@jimebe 我想这种方法的确存在一些限制。也许你可以考虑创建一个属性列表,列出你想要更新的属性,如果该属性包含在列表中,则进行更新,否则不更新。这样,你就可以使用任何你想要的属性值,包括 null,只要它被包含在列表中,就会被应用为更新。 - emab
@emab:我认为你已经重命名了一些变量以提高代码的清晰度,但是你错过了一些。你能否更新一下以便未来读者阅读? - hormberg
1
@hormberg 我已经更新了上面的代码示例,并提供了一个在GitHub上使用该代码的工作解决方案链接。希望能对你有所帮助! - emab

0

继续跟进Avid Learners的方法,我发现这很容易添加到现有的PUT方法中。 或者,为了避免重复加载,您可以应用更新操作,然后在保存之前应用补丁,但我宁愿加载两次并拥有简单的代码。

public ResultModel Patch(UpdateModel model)
{
   var record = LoadAsUpdateModel(model.Id);
   if (record == null) return null;
   
   foreach(var propertyName in model.SetProperties())
   {
       var property = model.GetType().GetProperty(propertyName);
       property.SetValue(record, property.GetValue(model));
   }

   return Update(record);
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接