はじめに
ASP.NET Core MVC のモデルバインドで、 JSON をバインドした後にモデルのプロパティの値が null だったとして、 デフォルト値の null なのか、 それともクライアント側から明示的に null を渡されたのか判断したい。
そして、null を設定できるプロパティを更新する場合に、 クライアント側が明示的に JSON で null を指定していたら null を設定し、 指定していなかったらそのままにしたい。 要は部分更新がしたい。
具体例
例えば、こんな入力用モデルとコントローラーがあるとする。
public class ComicInputModel { public string Title { get; set; } public string Author { get; set; } } [ApiController] [Route("[controller]")] public class ComicsController : ControllerBase { [HttpPatch("{id}")] public ActionResult<Comic> Update(string id, [FromBody] ComicInputModel model) { // 省略 } }
クライアントが下記の JSON を送信。
{ "title": "かぐや様は告らせたい", "author": null }
この JSON が ComicInputModel にバインドされると、Author は null になる。 一方で、下記のような author 属性自体が存在しない JSON をクライアントが送信した場合。
{ "title": "かぐや様は告らせたい" }
こちらも ComicInputModel にバインドされると、Author が null になる。
ComicInputModel にバインドされた後では、JSON で明示的に null が指定されたのか、それとも属性を省略されたのかが分からない。リクエストのボディを見れば分かると思うが、そのために追加のコストを払いたくない。
とりあえずの対策
下記のようなベースクラスを作成する。
public abstract class PatchRequest { readonly HashSet<string> properties = new HashSet<string>(); public bool HasProperty(string propertyName) => properties.Contains(propertyName); protected void SetProperty<T>( ref T field, T value, [CallerMemberName] string propertyName = "") { field = value; properties.Add(propertyName); } }
このクラスを継承するように、ComicsInputModel を修正。
public class ComicInputModel : PatchRequest { string title; public string Title { get => title; set => SetProperty(ref title, value); } string author; public string Author { get => author; set => SetProperty(ref author, value); } }
モデルバインドではプロパティを介して値を設定してくれる。 JSON に属性が存在しない場合は、プロパティのセッターは使われない。 そのため、コントローラーでは下記のように確認できる。
[HttpPatch("{id}")] public ActionResult<Comic> Update(string id, [FromBody]ComicInputModel model) { if (model.HasProperty(nameof(model.Title))) { // title が明示的に指定されていたら更新する } if (model.HasProperty(nameof(model.Author))) { // author が明示的に指定されていたら更新する } // 省略 }
サンプルコード全体
Web API 側
using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace PatchSample { public class Program { public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); } public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); }); } public class Startup { public Startup(IConfiguration configuration) { Configuration = configuration; } public IConfiguration Configuration { get; } public void ConfigureServices(IServiceCollection services) { services.AddControllers(); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { app.UseRouting(); //app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } } [ApiController] [Route("[controller]")] public class ComicsController : ControllerBase { static readonly List<Comic> comics = new List<Comic>() { new Comic { Id = "kaguyasama", Title = "かぐや様は告らせたい", Author = "赤坂アカ", }, new Comic { Id = "5hanayome", Title = "五等分の花嫁", Author = "春場ねぎ", }, new Comic { Id = "karakai", Title = "からかい上手の高木さん", Author = "山本崇一朗", }, }; [HttpGet] public ActionResult<IEnumerable<Comic>> GetAll() { return comics; } [HttpPatch("{id}")] public ActionResult<Comic> Update(string id, [FromBody]ComicInputModel model) { var comic = comics.FirstOrDefault(x => x.Id == id); if (comic == null) { return NotFound(); } if (model.HasProperty(nameof(model.Title))) { comic.Title = model.Title; } if (model.HasProperty(nameof(model.Author))) { comic.Author = model.Author; } return comic; } } public abstract class PatchRequest { readonly HashSet<string> properties = new HashSet<string>(); public bool HasProperty(string propertyName) => properties.Contains(propertyName); protected void SetProperty<T>( ref T field, T value, [CallerMemberName] string propertyName = "") { field = value; properties.Add(propertyName); } } public class ComicInputModel : PatchRequest { string title; public string Title { get => title; set => SetProperty(ref title, value); } string author; public string Author { get => author; set => SetProperty(ref author, value); } } public class Comic { public string Id { get; set; } public string Title { get; set; } public string Author { get; set; } } }
クライアント側
using System; using System.Text; using System.Net.Http; using System.Threading.Tasks; namespace PatchClient { class Program { static async Task Main(string[] args) { var client = new HttpClient() { BaseAddress = new Uri("http://localhost:5000") }; Console.WriteLine("===== 初期状態 ====="); { var result = await client.GetStringAsync("/comics"); Console.WriteLine(result); } Console.WriteLine("===== 部分更新 ====="); { var response = await client.PatchAsync( "/comics/kaguyasama", new StringContent( @"{ ""author"": ""アカ"" }", Encoding.UTF8, "application/json")); response.EnsureSuccessStatusCode(); var result = await client.GetStringAsync("/comics"); Console.WriteLine(result); } Console.WriteLine("===== 全部更新 ====="); { var response = await client.PatchAsync( "/comics/kaguyasama", new StringContent( @"{ ""title"": ""かぐや"", ""author"": null }", Encoding.UTF8, "application/json")); response.EnsureSuccessStatusCode(); var result = await client.GetStringAsync("/comics"); Console.WriteLine(result); } Console.ReadLine(); } } }
実行結果
おわりに
ベースクラスを用意する方法で、とりあえず目的は達成することができた。 ただ、正直スマートな方法じゃないので、まったく満足していない。 もっと良い方法がある気がするし、あってほしい。
一応 Microsoft Docs には、ASP.NET Core で JSON パッチを行うドキュメントがある。
ただ、ここまで求めてないんだよねぇ。 やりたいのは null が明示的に指定されたものかどうかの判断であって、 それに対して JSON パッチはオーバーキル過ぎる。