はじめに
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)))
{
}
if (model.HasProperty(nameof(model.Author)))
{
}
}
サンプルコード全体
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.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 パッチを行うドキュメントがある。
docs.microsoft.com
ただ、ここまで求めてないんだよねぇ。
やりたいのは null が明示的に指定されたものかどうかの判断であって、
それに対して JSON パッチはオーバーキル過ぎる。