Start Debugging

Исправление: [FromForm] Dictionary<string, string> всегда null в минимальном API

Dictionary с [FromForm] в минимальном API связывается с пустым префиксом: ключи формы должны быть [key], а не metadata[key]. Оберните его в класс, чтобы имена остались читаемыми.

Параметр [FromForm] Dictionary<string, string> в минимальном API не использует имя параметра как префикс ключей формы. Маппер формы начинает с корня формы, поэтому он ищет [author] и [env], а не metadata[author] или metadata.author. Отправляйте ключи в квадратных скобках без префикса или, что лучше, оберните словарь в класс и отправляйте 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

Тот же null возвращается для metadata.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.

Почему это происходит

У связывания форм в минимальных API есть два полностью раздельных пути выполнения, и какой из них выберет параметр, решает единственный предикат в RequestDelegateFactory:

// 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 это имя параметра. Именно такого поведения все и ожидают, и именно его вы получаете для string, int, Guid, DateOnly и любого другого типа с 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));

Посмотрите на аргументы: ридер и опции. Префикса нет. Значение key, вычисленное строкой выше, используется только как ключ словаря в factoryContext.TrackedParameters и никогда не помещается в стек префиксов ридера. Поэтому маппер читает словарь от корня формы, а запись словаря на корневом уровне пишется как [author].

В этом и состоит вся проблема: параметр называется metadata, но мапперу формы это имя никто не сообщил.

Этим же объясняется, почему поведение выглядит как регрессия при переносе конечной точки с контроллеров. Model binder в MVC сначала пробует имя параметра как префикс, а затем откатывается к пустому префиксу, поэтому action контроллера принимает оба варианта записи:

// .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

Минимальные API принимают только второй. Если вы взвешиваете обе модели хостинга целиком, минимальные API против контроллеров в ASP.NET Core 11 разбирает остальные места, где их семантика связывания расходится.

Минимальное воспроизведение

Полное приложение плюс формы запроса, которые работают и не работают:

// .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] и так далее. Имя параметра оказывается мёртвым грузом.

Исправление в деталях

Четыре варианта в том порядке, в котором я бы к ним обращался.

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.Tags[a]=1 связывается нормально, если Meta сам является свойством.

Файл можно втянуть в тот же класс, и тогда сигнатура конечной точки останется с одним параметром:

// .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"

Это работает, и правка составляет один символ на ключ. Но это же и та форма, которую никто не угадает, читая обработчик через полгода, и она не переживёт второго параметра-словаря (см. подводные камни). Считайте это временной затычкой.

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. Отправьте метаданные одним полем JSON

Если метаданные действительно произвольные, перестаньте моделировать их ключами формы. Одно поле формы с документом JSON связывается по простому пути, потому что 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 он работает точно так же.

Подводные камни и разновидности

Правило, которое стоит запомнить, короткое: в минимальном API параметр [FromForm] адресуется по имени только тогда, когда его тип можно разобрать из одной строки. Всё остальное проходит через маппер форм Blazor, который начинает с корня формы и не знает, как называется ваш параметр. Дайте ему класс, внутрь которого можно спуститься, и имена вернутся.

Похожие материалы

Источники

Comments

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

< Назад