😺

[ASP.NET Core 6] モデルバインドでrecordを使うときの注意点

2022/10/15に公開

あまり内容自信なし。

先に結論

[FromForm] [FromQuery] などを record で作るモデルに使う際は、property: を付けない。指定なし(param:)で良い。

# OK
public record MyRequest(
    [FromForm(Name = "id")] string Id,
    [FromForm(Name = "first_name")] string FirstName,
    [FromForm(Name = "last_name")] string LastName);

# Bad
# (ビルドエラーにはならない)
public record MyRequest(
    [property: FromForm(Name = "id")] string Id,
    [property: FromForm(Name = "first_name")] string FirstName,
    [property: FromForm(Name = "last_name")] string LastName);  

FromBodyの場合はproperty:を付ける。

public record MyRequest(
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("first_name")] string FirstName,
    [property: JsonPropertyName("last_name")] string LastName);  

環境

  • .NET 6 (ASP.NET Core 6)

コード

https://github.com/shimat/AspNetCoreParameterTest

従来(class)のバインド用モデル定義

以下、ASP.NET Core WebAPIを前提として進めます。

コントローラにて、リクエストを受け付けるアクションメソッドが題材です。自分で定義したモデルにリクエストの内容をバインドすることができます。
https://learn.microsoft.com/ja-jp/aspnet/core/mvc/models/model-binding?view=aspnetcore-6.0

classしかなかった時代は特に不都合はありませんでした。以下はBodyにapplication/jsonで来た時と、multipart/form-dataで来た時それぞれのバインド実装の例で、おなじみですね。リクエストではsnake_caseになっている例としており、名前の食い違いを埋めるようマッピングしています。

Request.cs
# モデル定義
public class BodyRequest
{
    [JsonPropertyName("id")] 
    public string Id { get; set; }
    [JsonPropertyName("first_name")] 
    public string FirstName { get; set; }
    [JsonPropertyName("last_name")] 
    public string LastName { get; set; }
}

public class FormRequest
{
    [FromForm(Name = "id")] 
    public string Id { get; set; }
    [FromForm(Name = "first_name")] 
    public string FirstName { get; set; }
    [FromForm(Name = "last_name")] 
    public string LastName { get; set; }
}
FooController.cs
# コントローラ
using System.Net.Mime;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class FooController : ControllerBase
{
    [Route("body")]
    [Consumes(MediaTypeNames.Application.Json)]
    [Produces(MediaTypeNames.Text.Plain)]
    [HttpPost]
    public IActionResult PostBody([FromBody] BodyRequest request)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);
        return Ok(request.ToString());
    }
    
    [Route("form")]
    [Consumes("multipart/form-data")]
    [Produces(MediaTypeNames.Text.Plain)]
    [HttpPost]
    public IActionResult PostForm([FromForm] FormRequest request)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);
        return Ok(request.ToString());
    }
}

recordによるバインド用モデル定義 (FromBody)

最近recordが出現し、モデルをどう書いたら良いかつまづきました。[1]

FromBody 向けのモデルの場合は以下のようにします。property:を付けないと(コンストラクタの)パラメータ向けの属性とみなされてしまい、JsonPropertyNameはパラメータには許可されないのでエラーになります。従来、個人的には滅多にproperty:は使うことがなかったですが、record出現以来頻出になりました。

public record BodyRequest(
    [property: JsonPropertyName("id")] string Id,
    [property: JsonPropertyName("first_name")] string FirstName,
    [property: JsonPropertyName("last_name")] string LastName);  

[JsonPropertyName]以外でも、JSON.NETの[JsonProperty]や、System.Runtime.Serialization[DataMember]といったあたりも同様です。

まだここまでは問題ありません。

recordによるバインド用モデル定義 (FromForm, FromQuery等)

続いて FromForm です。これも同じだろうとproperty:を使って定義してみます。問題なくビルドは通ります。

public record FormRequest(
    [property: FromForm(Name = "id")] string Id,
    [property: FromForm(Name = "first_name")] string FirstName,
    [property: FromForm(Name = "last_name")] string LastName);  

テストを作ってリクエストしてみます。

using System.Net.Http.Json;
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public class FooControllerMockTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> factory;
    
    public FooControllerMockTests(WebApplicationFactory<Program> factory)
        => this.factory = factory;

    [Fact]
    public async Task MultipartFormDataTest()
    {
        var httpClient = factory.CreateClient();
        
        using var formDataContent = new MultipartFormDataContent();
        formDataContent.Add(new StringContent("12345"), "id");
        formDataContent.Add(new StringContent("Taro"), "first_name");
        formDataContent.Add(new StringContent("Yamada"), "last_name");

        using var response = await httpClient.PostAsync("/foo/form", formDataContent).ConfigureAwait(false);
        response.EnsureSuccessStatusCode();
        var responseContent = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        Assert.Equal("FormRequest { Id = 12345, FirstName = Taro, LastName = Yamada }", responseContent);
    }
}

そうすると400 Bad Requestになります。リクエストのfirst_nameからモデルのFirstNameへのマッピングに失敗してnullが入るのが原因です。LastNameも同様です。

あれ?[property: FromForm("first_name")]ってちゃんと書いたのに?

なんと効いていないのです。調べた結果、property:を無くすとうまくいくことがわかりました。無くすとここではparameter:とみなされます。

public record FormRequest(
    [FromForm(Name = "id")] string Id,
    [FromForm(Name = "first_name")] string FirstName,
    [FromForm(Name = "last_name")] string LastName);  

これでテストは通るはずです。

原因はよくわかりませんでした...。

[FromForm]を書かなくてもデフォルトでマッピングする処理は走ります。idとIdのマッピングは、そのデフォルト挙動では大文字小文字の区別がないため成功します。snake_caseとのマッピングはデフォルト挙動にはありません。つまり、この問題はアンダースコア_が出てこない限り気づけません。試したところ[FromQuery]もこれと同様の挙動を示します。

[FromBody][FromForm]で食い違うこと、またrecordとclass({get;set;}のプロパティ定義)でも食い違うということで注意しなければなりません。[JsonPropertyName]は、property:を忘れるとビルドエラーになるので間違えようがないのですが、[FromForm]は付けても付けなくてもビルドは通ってしまう点も罠でした。

脚注
  1. recordでハマるのはだいたい属性関係な気がします。 ↩︎

Discussion