ASP.NET Core 建立 JSON PATCH API 示範

平時使用 API 更新資料時,通常會使用 PUT 這個 HTTP Method ,但是 PUT 通常代表的意思是更新整個資源。假設 product 有 名稱、價格... 等等 10 個屬性,在更新時需要把全部屬性都回傳一遍,如果沒有回傳很可能被當成 null 更新上去,如果有多個階層的資料會非常麻煩,並且占用的流量和資源也比較多。

而使用 PATCH 這個 HTTP Method 就可以避免這個問題,但是 PATCH 的更新就比較繁瑣一點,因為會需要指定要更新資源的哪個部分。接下來示範的是 JSON Patch 這種格式做示範。

假設 id 為 1 的 product 查詢結果如下:
    
{
  "id": 1,
  "name": "ProductName",
  "price": 10
}
    

常見的使用 PUT 更新的方式就是在 body 中帶入所有屬性(通常不會回傳 id,因為 id 無法更新):
    
{
  "name": "NewProductName",
  "price": 100
}
    

而使用 JSON Patch 要更新這兩部分就需要在 Body 中指定該資源在 JSON 格式中的位置(path)和作業的類型(op):
    
[
  {
    "op": "replace",
    "path": "/Name",
    "value": "NewProductName"
  },
  {
    "op": "replace",
    "path": "/Price",
    "value": "100"
  }
]
    

在上面這個簡單的例子中使用使用 JSON Patch 看起來就是多此一舉,不過對於屬性越多的資源來說越方便,並且也可以減輕某種程度的更新衝突。

假設現在有「可樂」這個產品:
    
{
  "name": "可樂",
  "price": 33
}
    

小明和小王同時開啟網頁,同時取得「可樂」這個產品的資訊,一個要改品名為「新版可樂」,另一個要把價格從 33 變成 40 ,猶豫半天後兩個人在網頁上儲存更新資訊,在網頁中使用 PUT 來送出更新請求,範例內容如下:

小明只要改名稱:
    
{
  "name": "新版可樂",
  "price": 33
}
    

小王只要改價格:
    
{
  "name": "可樂",
  "price": 40
}
    

如果小明先發送更新,小王比較後面才更新,但是因為是使用 PUT,所以會把小明已經更新過的名稱蓋掉,名稱還是維持當初小王取得的產品名稱:「可樂」,這時候如果使用 PATCH 來更新,只更新部分資訊,就可以減少這個問題。
當然依照不同的系統有不同的處理方式,例如使用最後更新時間或是雜湊來檢查,有衝突就強制重新整理不儲存資料等,不過本篇的重點是在 ASP.NET Core 中實作 JSON PATCH API,所以這裡就不展開討論了。

在 ASP.NET Core 中要實作 JsonPatch 的更新其實也滿簡單的,只是如果是使用原生的 System.Text.Json 來處理的話需要多一些設定的步驟,並且在微軟官方的教學中還是仰賴第三方套件 Newtonsoft.Json 來達成,所以就算使用官方方式還是需要安裝套件。
筆者在好幾個專案中都需要建立 JSON PATCH 這種格式的 API,但是已經不想要再每次都翻一次文件,不想要繼續每一次都複製貼上,出於懶惰(?),於是就寫了一個套件 JsonPatchSupport.AspNetCore ,加上 using 只要兩行程式碼就可以完成設定,實在是居家旅行、殺人滅口,必備良藥

安裝套件

先使用 NuGet 安裝 JsonPatchSupport.AspNetCore 套件,或是使用 .NET CLI 執行以下指令安裝
	
dotnet add package JsonPatchSupport.AspNetCore
    

在 Program.cs 檔案中增加以下程式碼註冊服務:
    
using JsonPatchSupport.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddJsonPatchSupport();

var app = builder.Build();

app.Run();
    

安裝完畢。

如果之後出現類似下面的錯誤有一半的機率是忘記放上面的程式碼:
    
{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "errors": {
        "$": [
            "The JSON value could not be converted to Microsoft.AspNetCore.JsonPatch.JsonPatchDocument`1[Api.Dtos.ProductDto]. Path: $ | LineNumber: 0 | BytePositionInLine: 1."
        ],
        "patchDocument": [
            "The patchDocument field is required."
        ]
    },
    "traceId": ""
}
    

使用示範

假設我們有一個 API 用來更新 Product ,DTO 如下:
    
public class ProductDto
{
    public string Name { get; set; }
    public int Price { get; set; }
}
    

這裡就實作使用 JSON PATCH 更新 Product 的 API:
    
using Microsoft.AspNetCore.JsonPatch;
using Microsoft.AspNetCore.Mvc;


[ApiController]
[Route("api/[controller]")]
public class ProductController : ControllerBase
{
    [HttpPatch("{id}")]
    public IActionResult Patch([FromRoute] int id, [FromBody] JsonPatchDocument<ProductDto> patchDocument)
    {
        // simulate getting the entity from a data source
        var product = new ProductDto
        {
            Name = "product1",
            Price = 10,
        };

        // Apply the patch to the product
        patchDocument.ApplyTo(product, ModelState);

        if (!ModelState.IsValid)
        {
            return BadRequest(ModelState);
        }

        // Simulate saving the updated entity to a data source
        Console.WriteLine($"Product.Name={product.Name}, Product.Price={product.Price}");

        return Ok(product);
    }
}

    

重點在於傳入的參數需要使用 JsonPatchDocument 包起來,然後取得原始的資料,原始資料的格式就是被 JsonPatchDocument 包起來的那個,兩個格式需要一樣。然後將原始資料使用 ApplyTo 來將更新的部分覆蓋上,如果使用 EF Core 則直接儲存就好了(因為會自動比對差異)。

附上使用 PATCH 更新的 cURL (請自行替換 port):
    
curl -X 'PATCH' \
  'http://localhost:5183/api/Product/1' \
  -H 'accept: */*' \
  -H 'Content-Type: application/json-patch+json' \
  -d '
[
  {
    "op": "replace",
    "path": "/Name",
    "value": "NewProductName"
  },
  {
    "op": "replace",
    "path": "/Price",
    "value": "99"
  }
]'
    

這裡也有重點,Content-Type 需要指定為 application/json-patch+json,如果出現下面的錯誤訊息,有 90% 的機率就是放錯 Content-Type ,因為內容是 JSON 預設都會被帶入 application/json ,只要將 Header 的 Content-Type 指定為 application/json-patch+json 即可,非常重要!
    
{
    "type": "https://tools.ietf.org/html/rfc9110#section-15.5.16",
    "title": "Unsupported Media Type",
    "status": 415,
    "traceId": "00-a26073a738244ac49692bb56bf6d8a76-e6805d2b04a05b39-00"
}
    

如果有照步驟做那可是非常簡單的,如果還是不會或是遇到問題,直接文章下面留言

參考資料:
GitHub - JsonPatchSupport.AspNetCore
NuGet.org - JsonPatchSupport.AspNetCore
Microsoft.Learn - JsonPatch in ASP.NET Core web API
mdn - PATCH
RFC 6902 - JavaScript Object Notation (JSON) Patch

留言