Start Debugging

修正: minimal API で [FromForm] Dictionary<string, string> が常に null になる

minimal API の [FromForm] Dictionary は空のプレフィックスでバインドされるため、フォームキーは metadata[key] ではなく [key] にする必要があります。クラスで包めば読みやすい名前を保てます。

minimal API の [FromForm] Dictionary<string, string> パラメーターは、パラメーター名をフォームキーのプレフィックスとして使いません。フォームマッパーはフォームのルートから読み始めるため、metadata[author]metadata.author ではなく [author][env] を探します。プレフィックスなしの角かっこ付きキーを送るか、より良い方法として辞書をクラスで包み、Metadata[author] を送ってワイヤー上の形式を読みやすく保ってください。キーが一致しなくてもログには何も出力されず、400 も返りません。パラメーターは単に null として届きます。

以下の内容はすべて ASP.NET Core 10.0.5 と SDK 10.0.201 で計測しました。該当するバインド処理のコードは release/11.0 ブランチでも同一なので、この挙動は .NET 11 にも引き継がれます。

エラーの実際の様子

検索できる例外がまったく存在せず、それこそがこの問題で午後がまるごと溶ける理由です。ハンドラーは実行され、ファイルはバインドされ、辞書だけが null になります。

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/broken", ([FromForm] Dictionary<string, string> metadata, IFormFile file) =>
    Results.Text($"metadata={(metadata is null ? "null" : JsonSerializer.Serialize(metadata))}, file={file?.FileName}"))
   .DisableAntiforgery();
curl -X POST http://localhost:5222/broken \
  -F "metadata[author]=marius" -F "metadata[env]=prod" -F "file=@a.txt"
metadata=null, file=a.txt

同じ nullmetadata.author=marius でも、素の author=marius でも、キーをまったく含まないリクエストでも返ります。ステータスコードは毎回 200 です。

例外を目にするのは、キーがマッパーに読み取られるところまで近づいたときだけです。Dictionary<string, int> にパースできない値を渡すと次のようになります。

Microsoft.AspNetCore.Http.BadHttpRequestException: The value 'notanint' is not valid for 'b'.
 ---> Microsoft.AspNetCore.Components.Endpoints.FormMapping.FormDataMappingException
   at Microsoft.AspNetCore.Components.Endpoints.FormMapping.DictionaryConverter`5.TryRead(...)

このスタックトレースが手がかりです。実処理を行う型は Microsoft.AspNetCore.Components.Endpoints.FormMapping にあり、Blazor が使うのと同じフォームマッピング層です。そのキーの規約は MVC で身につけたものとは違います。

これが起きる理由

minimal API のフォームバインドには完全に別々のコードパスが 2 つあり、パラメーターがどちらを通るかは RequestDelegateFactory の 1 つの述語だけで決まります。

// dotnet/aspnetcore, src/Http/Http.Extensions/src/RequestDelegateFactory.cs, release/10.0
var useSimpleBinding = parameter.ParameterType == typeof(string) ||
    parameter.ParameterType == typeof(StringValues) ||
    parameter.ParameterType == typeof(StringValues?) ||
    ParameterBindingMethodCache.Instance.HasTryParseMethod(parameter.ParameterType) ||
    (parameter.ParameterType.IsArray && ParameterBindingMethodCache.Instance.HasTryParseMethod(parameter.ParameterType.GetElementType()!));
hasTryParse = useSimpleBinding;
return useSimpleBinding
    ? BindParameterFromFormItem(parameter, formAttribute.Name ?? parameter.Name, factoryContext)
    : BindComplexParameterFromFormItem(parameter, string.IsNullOrEmpty(formAttribute.Name) ? parameter.Name : formAttribute.Name, factoryContext);

シンプルなバインドは HttpContext.Request.Form[key] を読み、この key がパラメーター名です。誰もが期待するのはこの挙動で、stringintGuidDateOnly など TryParse を持つ型ではこれが得られます。

Dictionary<string, string> には TryParse がないため BindComplexParameterFromFormItem に落ち、そこでフォーム全体が共有マッパーに渡されます。

// FormDataMapper.Map<Dictionary<string, string>>(name_reader, FormDataMapperOptions);
var invokeMapMethodExpr = Expression.Call(
    FormDataMapperMapMethod.MakeGenericMethod(parameter.ParameterType),
    formReader,
    Expression.Constant(formDataMapperOptions));

引数を見てください。リーダーとオプションだけです。プレフィックスがありません。1 行上で計算された keyfactoryContext.TrackedParameters の辞書キーとして使われるだけで、リーダーのプレフィックススタックに積まれることは一度もありません。そのためマッパーはフォームのルートから辞書を読み、ルートレベルの辞書エントリは [author] と書かれます。

これがこの問題のすべてです。パラメーターの名前は metadata ですが、その名前はフォームマッパーに伝わっていないのです。

コントローラーからエンドポイントを移した際にこの挙動がリグレッションのように感じられるのも同じ理由です。MVC のモデルバインダーはパラメーター名をプレフィックスとして試し、その後で空のプレフィックスにフォールバックするため、コントローラーのアクションは両方の書き方を受け付けます。

// .NET 10.0.201, controller action, both curl shapes below return the same result
[HttpPost("dict")]
public IActionResult Dict([FromForm] Dictionary<string, string> metadata, IFormFile file)
    => Content($"count={metadata?.Count}");
curl -F "metadata[author]=marius" -F "file=@a.txt"   ->  count=1
curl -F "[author]=marius"         -F "file=@a.txt"   ->  count=1

minimal API が受け付けるのは 2 番目だけです。2 つのホスティングモデルをより広く比較したい場合は、ASP.NET Core 11 における minimal API とコントローラーの比較で、バインドの意味論が分かれるその他の箇所を扱っています。

最小再現

完全なアプリケーションと、動くリクエスト形式と動かないリクエスト形式です。

// .NET 10.0.201, ASP.NET Core 10.0.5
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery();
var app = builder.Build();
app.UseAntiforgery();

app.MapPost("/dict", ([FromForm] Dictionary<string, string> metadata, IFormFile file) =>
    Results.Text($"metadata={(metadata is null ? "null" : JsonSerializer.Serialize(metadata))}, file={file?.FileName}"))
   .DisableAntiforgery();

app.MapPost("/list", ([FromForm] List<string> tags, IFormFile file) =>
    Results.Text($"tags={(tags is null ? "null" : JsonSerializer.Serialize(tags))}"))
   .DisableAntiforgery();

app.Run();

このアプリケーションに対する実測結果です。

リクエスト結果
-F "metadata[author]=marius"metadata=null
-F "metadata.author=marius"metadata=null
-F "author=marius"metadata=null
-F "[author]=marius" -F "[env]=prod"metadata={"author":"marius","env":"prod"}
-F "tags=a" -F "tags=b"tags=null
-F "tags[0]=a" -F "tags[1]=b"tags=null
-F "[0]=a" -F "[1]=b"tags=["a","b"]

パターンは一貫しています。トップレベルの [FromForm] コレクションパラメーターは空のプレフィックスでアドレス指定されるため、辞書は [key]、リストは [0][1] のように書きます。パラメーター名は死に荷物です。

修正方法の詳細

私が手を伸ばす順に、4 つの選択肢を挙げます。

1. 辞書をクラスで包む

これが本番に投入する価値のある修正です。クラスのプロパティにはプレフィックスが付きます。マッパーは下降しながらプロパティ名をプレフィックススタックに積むからです。おかげでワイヤー上の形式は、人間が読めてクライアントライブラリが生成できるものに戻ります。

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/upload", ([FromForm] UploadRequest request, IFormFile file) =>
    Results.Text($"request={JsonSerializer.Serialize(request)}, file={file?.FileName}"))
   .DisableAntiforgery();

public class UploadRequest
{
    public Dictionary<string, string> Metadata { get; set; } = new();
}
curl -X POST http://localhost:5222/upload \
  -F "Metadata[author]=marius" -F "Metadata[env]=prod" -F "file=@a.txt"
request={"Metadata":{"author":"marius","env":"prod"}}, file=a.txt

キーの照合は大文字と小文字を区別しないため、metadata[author]Metadata プロパティにバインドされます。ネストした辞書はさらに深い位置に置くこともでき、Meta 自体がプロパティであれば Meta.Tags[a]=1 も問題なくバインドされます。

ファイルを同じクラスに取り込めば、エンドポイントのシグネチャーをパラメーター 1 つに保てます。

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/upload", ([FromForm] UploadWithFile request) =>
    Results.Text($"metadata={JsonSerializer.Serialize(request.Metadata)}, file={request.File?.FileName}"))
   .DisableAntiforgery();

public class UploadWithFile
{
    public Dictionary<string, string> Metadata { get; set; } = new();
    public IFormFile? File { get; set; }
}

-F "Metadata[author]=marius" -F "File=@a.txt" を送れば両方がバインドされます。ファイルのプロパティはプロパティ名で照合され、これはトップレベルの IFormFile パラメーターに適用されるのと同じ規則です。

2. 辞書パラメーターを残してクライアント側を直す

クライアントが自分の管理下にあり、エンドポイントのシグネチャーを変えられない場合は、ルートレベルの角かっこ付きキーを送るだけで済みます。

curl -X POST http://localhost:5222/dict \
  -F "[author]=marius" -F "[env]=prod" -F "file=@a.txt"

これは動作しますし、変更はキーあたり 1 文字です。ただし半年後にハンドラーを読む人が誰も推測できない形式でもあり、辞書パラメーターが 2 つになると成立しません (落とし穴の節を参照してください)。応急処置として扱ってください。

3. フォームを自分で読む

もっとも明示的で、Request Delegate Generator を通過できる唯一の選択肢です。IFormCollection はマッピング層をまったく介さずフォーム全体のパラメーターとしてバインドされるので、キーの規約は自分で決められます。

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/upload", (IFormCollection form) =>
{
    var metadata = form
        .Where(kv => kv.Key.StartsWith("metadata[", StringComparison.Ordinal) && kv.Key.EndsWith(']'))
        .ToDictionary(kv => kv.Key[9..^1], kv => kv.Value.ToString());

    return Results.Text($"metadata={JsonSerializer.Serialize(metadata)}, files={form.Files.Count}");
}).DisableAntiforgery();
metadata={"author":"marius","env":"prod"}, files=1

冗長ではありますが、metadata[author] をそのまま受け付けますし、キーが不正なときには黙った null ではなく本物のエラー経路が得られます。

4. メタデータを 1 つの JSON フィールドとして送る

メタデータが本当に自由形式なら、フォームキーとしてモデリングするのをやめましょう。JSON ドキュメントを保持する 1 つのフォームフィールドは、string が上記の述語を短絡させるため、シンプルな経路でバインドされます。

// .NET 10.0.201, ASP.NET Core 10.0.5
app.MapPost("/upload", ([FromForm] string metadata, IFormFile file) =>
{
    var parsed = JsonSerializer.Deserialize<Dictionary<string, string>>(metadata);
    return Results.Text($"metadata={JsonSerializer.Serialize(parsed)}, file={file?.FileName}");
}).DisableAntiforgery();
curl -X POST http://localhost:5222/upload \
  -F 'metadata={"author":"marius","env":"prod"}' -F "file=@a.txt"

キーの構文と格闘せずにネストした値、配列、文字列以外の型を扱えるのはこの方法だけで、AOT 下でも同じように動作します。

落とし穴と派生ケース

覚えておくべき規則は短いものです。minimal API では、[FromForm] パラメーターが名前でアドレス指定されるのは、その型が 1 つの文字列からパースできる場合だけです。それ以外はすべて Blazor のフォームマッパーを通り、マッパーはフォームのルートから読み始め、あなたのパラメーターの名前を知りません。降りていけるクラスを与えれば、名前は戻ってきます。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る