ASP.NET Core MVC のモデルバインドで null が明示的に渡されたものかどうか判断したい

はじめに

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();
        }
    }
}
実行結果

f:id:griefworker:20200123101514p:plain

おわりに

ベースクラスを用意する方法で、とりあえず目的は達成することができた。 ただ、正直スマートな方法じゃないので、まったく満足していない。 もっと良い方法がある気がするし、あってほしい。

一応 Microsoft Docs には、ASP.NET Core で JSON パッチを行うドキュメントがある。

docs.microsoft.com

ただ、ここまで求めてないんだよねぇ。 やりたいのは null が明示的に指定されたものかどうかの判断であって、 それに対して JSON パッチはオーバーキル過ぎる。