Start Debugging

Correção: [FromForm] Dictionary<string, string> é sempre null em uma minimal API

Um Dictionary com [FromForm] em uma minimal API faz binding com prefixo vazio: as chaves do formulário precisam ser [key], não metadata[key]. Envolva em uma classe para manter nomes legíveis.

Um parâmetro [FromForm] Dictionary<string, string> em uma minimal API não usa o nome do parâmetro como prefixo das chaves do formulário. O mapeador de formulário começa na raiz do formulário, então ele procura por [author] e [env], não por metadata[author] nem metadata.author. Envie chaves entre colchetes sem prefixo ou, melhor ainda, envolva o dicionário em uma classe e envie Metadata[author] para que o formato transmitido continue legível. Nada é registrado em log e nenhum 400 é retornado quando as chaves não batem: o parâmetro simplesmente chega como null.

Tudo abaixo foi medido no ASP.NET Core 10.0.5 com o SDK 10.0.201. O código de binding relevante é idêntico no branch release/11.0, então o comportamento continua no .NET 11.

O erro em contexto

Não há exceção nenhuma para pesquisar, e é exatamente por isso que esse problema queima uma tarde inteira. O handler executa, o arquivo faz binding e o dicionário é 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

O mesmo null volta com metadata.author=marius, com um simples author=marius e com uma requisição que omite as chaves por completo. O código de status é 200 em todos os casos.

Você só vê uma exceção quando as chaves ficam próximas o bastante para o mapeador começar a lê-las. Com um Dictionary<string, int> e um valor que não pode ser convertido:

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(...)

Esse stack trace é a pista. O tipo que faz o trabalho fica em Microsoft.AspNetCore.Components.Endpoints.FormMapping, a mesma camada de mapeamento de formulário que o Blazor usa, e as convenções de chave dela não são as que o MVC te ensinou.

Por que isso acontece

O binding de formulário em minimal APIs tem dois caminhos de código completamente separados, e qual deles um parâmetro segue é decidido por um único predicado em 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);

O binding simples lê HttpContext.Request.Form[key] onde key é o nome do parâmetro. Esse é o comportamento que todo mundo espera, e é o que você obtém para string, int, Guid, DateOnly e qualquer outro tipo com um TryParse.

Dictionary<string, string> não tem TryParse, então cai em BindComplexParameterFromFormItem, que entrega o formulário inteiro ao mapeador compartilhado:

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

Olhe os argumentos: o leitor e as opções. Não há prefixo. A key calculada na linha acima só é usada como chave de dicionário em factoryContext.TrackedParameters, nunca é empilhada na pilha de prefixos do leitor. Por isso o mapeador lê o dicionário a partir da raiz do formulário, e uma entrada de dicionário na raiz se escreve [author].

É esse o problema inteiro: o parâmetro se chama metadata, mas ninguém contou esse nome ao mapeador de formulário.

Isso também explica por que o comportamento parece uma regressão quando você move um endpoint de controllers. O model binder do MVC tenta o nome do parâmetro como prefixo e depois recorre ao prefixo vazio, então uma action de controller aceita as duas grafias:

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

As minimal APIs aceitam apenas a segunda. Se você está avaliando os dois modelos de forma mais ampla, minimal APIs vs controllers no ASP.NET Core 11 cobre os outros pontos em que a semântica de binding deles diverge.

Repro mínima

Uma aplicação completa, mais os formatos de requisição que funcionam e os que não funcionam:

// .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();

Resultados medidos contra essa aplicação:

RequisiçãoResultado
-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"]

O padrão é consistente: um parâmetro de coleção [FromForm] de nível superior é endereçado com prefixo vazio, então dicionários usam [key] e listas usam [0], [1], e assim por diante. O nome do parâmetro é peso morto.

A correção, em detalhe

Quatro opções, na ordem em que eu recorreria a elas.

1. Envolva o dicionário em uma classe

Essa é a correção que vale a pena colocar em produção. Uma propriedade de uma classe recebe prefixo, sim, porque o mapeador empilha o nome da propriedade na pilha de prefixos enquanto desce, então o formato transmitido volta a ser algo que uma pessoa consegue ler e que uma biblioteca cliente consegue gerar.

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

A comparação de chaves não diferencia maiúsculas de minúsculas, então metadata[author] também faz binding na propriedade Metadata. O dicionário aninhado pode ficar ainda mais fundo: Meta.Tags[a]=1 faz binding normalmente se Meta for, por sua vez, uma propriedade.

Você pode puxar o arquivo para dentro da mesma classe, o que deixa a assinatura do endpoint com um único parâmetro:

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

Enviar -F "Metadata[author]=marius" -F "File=@a.txt" faz binding nos dois. A propriedade do arquivo é casada pelo nome da propriedade, a mesma regra que vale para um parâmetro IFormFile de nível superior.

2. Mantenha o parâmetro dicionário e ajuste o cliente

Se o cliente é seu e a assinatura do endpoint está fixa, basta enviar chaves entre colchetes na raiz:

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

Funciona, e é um caractere de mudança por chave. Também é o formato que ninguém vai adivinhar ao ler o handler daqui a seis meses, e ele não sobrevive a um segundo parâmetro dicionário (veja as pegadinhas). Trate como paliativo.

3. Leia o formulário você mesmo

A opção mais explícita, e a única que sobrevive ao Request Delegate Generator. IFormCollection faz binding como parâmetro de formulário inteiro, sem nenhuma camada de mapeamento envolvida, então a convenção de chaves é sua:

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

É verboso, mas aceita metadata[author] diretamente e te dá um caminho de erro real quando uma chave está malformada, em vez de um null silencioso.

4. Envie os metadados como um único campo JSON

Se os metadados são realmente abertos, pare de modelá-los como chaves de formulário. Um único campo de formulário contendo um documento JSON faz binding pelo caminho simples, porque string curto-circuita o predicado acima:

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

É a única opção que te dá valores aninhados, arrays e tipos que não sejam string sem brigar com a sintaxe de chaves, e funciona igual sob AOT.

Pegadinhas e variantes

A regra a guardar é curta: em uma minimal API, um parâmetro [FromForm] é endereçado pelo nome apenas se o tipo dele puder ser convertido a partir de uma única string. Todo o resto passa pelo mapeador de formulário do Blazor, que começa na raiz do formulário e não sabe como o seu parâmetro se chama. Dê a ele uma classe para descer e os nomes voltam.

Relacionados

Fontes

Comments

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

< Voltar