Start Debugging

Solución: [FromForm] Dictionary<string, string> siempre es null en una minimal API

Un Dictionary con [FromForm] en una minimal API se enlaza con prefijo vacío: las claves del formulario deben ser [key], no metadata[key]. Envuélvelo en una clase para conservar nombres legibles.

Un parámetro [FromForm] Dictionary<string, string> en una minimal API no usa el nombre del parámetro como prefijo de las claves del formulario. El mapeador de formularios empieza en la raíz del formulario, así que busca [author] y [env], no metadata[author] ni metadata.author. Envía claves entre corchetes sin prefijo o, mejor todavía, envuelve el diccionario en una clase y envía Metadata[author] para que el formato en el cable siga siendo legible. No se registra nada ni se devuelve un 400 cuando las claves no coinciden: el parámetro simplemente llega como null.

Todo lo que sigue se midió en ASP.NET Core 10.0.5 con el SDK 10.0.201. El código de enlace relevante es idéntico en la rama release/11.0, así que el comportamiento se mantiene en .NET 11.

El error en contexto

No hay ninguna excepción que buscar, y por eso mismo este problema quema una tarde entera. El handler se ejecuta, el archivo se enlaza y el diccionario es 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

El mismo null vuelve con metadata.author=marius, con un simple author=marius y con una solicitud que omite las claves por completo. El código de estado es 200 en todos los casos.

Solo ves una excepción cuando las claves se acercan lo suficiente como para que el mapeador empiece a leerlas. Con un Dictionary<string, int> y un valor que no se puede parsear:

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

Ese stack trace es la pista. El tipo que hace el trabajo vive en Microsoft.AspNetCore.Components.Endpoints.FormMapping, la misma capa de mapeo de formularios que usa Blazor, y sus convenciones de claves no son las que aprendiste con MVC.

Por qué ocurre

El enlace de formularios en minimal APIs tiene dos rutas de código completamente separadas, y cuál toma un parámetro lo decide un único predicado en 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);

El enlace simple lee HttpContext.Request.Form[key] donde key es el nombre del parámetro. Ese es el comportamiento que todo el mundo espera, y es el que obtienes para string, int, Guid, DateOnly y cualquier otro tipo con un TryParse.

Dictionary<string, string> no tiene TryParse, así que cae en BindComplexParameterFromFormItem, que entrega el formulario completo al mapeador compartido:

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

Mira los argumentos: el lector y las opciones. No hay prefijo. La key calculada en la línea anterior solo se usa como clave de diccionario en factoryContext.TrackedParameters, nunca se coloca en la pila de prefijos del lector. Por eso el mapeador lee el diccionario desde la raíz del formulario, y una entrada de diccionario en la raíz se escribe [author].

Ese es todo el problema: el parámetro se llama metadata, pero al mapeador de formularios nunca le dijeron ese nombre.

Esto también explica por qué el comportamiento parece una regresión cuando mueves un endpoint desde controladores. El model binder de MVC prueba el nombre del parámetro como prefijo y luego cae al prefijo vacío, así que una acción de controlador acepta las dos formas:

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

Las minimal APIs solo aceptan la segunda. Si estás sopesando los dos modelos de hosting en general, minimal APIs vs controladores en ASP.NET Core 11 cubre los demás puntos en los que su semántica de enlace diverge.

Repro mínima

Una aplicación completa, más las formas de solicitud que funcionan y las que no:

// .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 esa aplicación:

SolicitudResultado
-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"]

El patrón es consistente: un parámetro de colección [FromForm] de nivel superior se direcciona con prefijo vacío, así que los diccionarios usan [key] y las listas usan [0], [1], y así sucesivamente. El nombre del parámetro es peso muerto.

La solución, en detalle

Cuatro opciones, en el orden en que yo las tomaría.

1. Envuelve el diccionario en una clase

Esta es la solución que vale la pena llevar a producción. Una propiedad de una clase sí obtiene prefijo, porque el mapeador coloca el nombre de la propiedad en su pila de prefijos mientras desciende, así que el formato en el cable vuelve a ser algo que una persona puede leer y que una librería cliente puede generar.

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

La coincidencia de claves no distingue mayúsculas de minúsculas, así que metadata[author] también se enlaza a la propiedad Metadata. El diccionario anidado puede estar incluso más profundo: Meta.Tags[a]=1 se enlaza bien si Meta es a su vez una propiedad.

Puedes meter el archivo en la misma clase, lo que deja la firma del endpoint en un solo 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" enlaza ambos. La propiedad del archivo se empareja por nombre de propiedad, la misma regla que aplica a un parámetro IFormFile de nivel superior.

2. Conserva el parámetro diccionario y arregla el cliente

Si el cliente es tuyo y la firma del endpoint está fija, envía simplemente claves entre corchetes en la raíz:

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

Funciona, y es un carácter de cambio por clave. También es la forma que nadie va a adivinar cuando lea el handler dentro de seis meses, y no sobrevive a un segundo parámetro diccionario (mira las trampas más abajo). Tómalo como un parche temporal.

3. Lee el formulario tú mismo

La opción más explícita, y la única que sobrevive al Request Delegate Generator. IFormCollection se enlaza como parámetro de formulario completo sin ninguna capa de mapeo de por medio, así que la convención de claves es tuya:

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

Es verboso, pero acepta metadata[author] directamente y te da una ruta de error real cuando una clave está mal formada, en lugar de un null silencioso.

4. Envía los metadatos como un único campo JSON

Si los metadatos son realmente abiertos, deja de modelarlos como claves de formulario. Un único campo de formulario que contenga un documento JSON se enlaza por la ruta simple, porque string cortocircuita el predicado de arriba:

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

Es la única opción que te da valores anidados, arreglos y tipos que no son cadenas sin pelear con la sintaxis de claves, y funciona igual bajo AOT.

Trampas y variantes

La regla que hay que recordar es corta: en una minimal API, un parámetro [FromForm] se direcciona por nombre solo si su tipo se puede parsear desde una única cadena. Todo lo demás pasa por el mapeador de formularios de Blazor, que empieza en la raíz del formulario y no sabe cómo se llama tu parámetro. Dale una clase por la que descender y los nombres vuelven.

Relacionados

Fuentes

Comments

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

< Volver