Start Debugging

Fix: [FromForm] Dictionary<string, string> ist in einer Minimal API immer null

Ein Dictionary mit [FromForm] bindet in einer Minimal API mit leerem Präfix: die Formularschlüssel müssen [key] heißen, nicht metadata[key]. Kapseln Sie es in einer Klasse für lesbare Namen.

Ein Parameter [FromForm] Dictionary<string, string> in einer Minimal API verwendet den Parameternamen nicht als Präfix für die Formularschlüssel. Der Form-Mapper beginnt an der Wurzel des Formulars und sucht deshalb nach [author] und [env], nicht nach metadata[author] oder metadata.author. Senden Sie Schlüssel in eckigen Klammern ohne Präfix oder, besser, kapseln Sie das Dictionary in einer Klasse und senden Sie Metadata[author], damit das Format auf der Leitung lesbar bleibt. Wenn die Schlüssel nicht passen, wird nichts protokolliert und kein 400 zurückgegeben: der Parameter kommt schlicht als null an.

Alles Folgende wurde mit ASP.NET Core 10.0.5 und SDK 10.0.201 gemessen. Der relevante Bindungscode ist im Branch release/11.0 identisch, das Verhalten gilt also auch in .NET 11.

Der Fehler im Kontext

Es gibt keine Exception, nach der man suchen könnte, und genau deshalb kostet dieser Fall einen ganzen Nachmittag. Der Handler läuft, die Datei wird gebunden, und das Dictionary ist 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

Dasselbe null kommt bei metadata.author=marius zurück, bei einem einfachen author=marius und bei einer Anfrage, die die Schlüssel ganz weglässt. Der Statuscode ist jedes Mal 200.

Eine Exception sehen Sie erst, wenn die Schlüssel nahe genug sind, dass der Mapper sie überhaupt liest. Mit einem Dictionary<string, int> und einem Wert, der sich nicht parsen lässt:

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

Dieser Stack Trace ist der Hinweis. Der Typ, der die Arbeit macht, liegt in Microsoft.AspNetCore.Components.Endpoints.FormMapping, derselben Form-Mapping-Schicht, die auch Blazor nutzt, und ihre Schlüsselkonventionen sind nicht die, die Sie von MVC kennen.

Warum das passiert

Die Formularbindung in Minimal APIs hat zwei vollständig getrennte Codepfade, und welchen ein Parameter nimmt, entscheidet ein einziges Prädikat in 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);

Die einfache Bindung liest HttpContext.Request.Form[key], wobei key der Parametername ist. Das ist das Verhalten, das alle erwarten, und Sie bekommen es für string, int, Guid, DateOnly und jeden anderen Typ mit einem TryParse.

Dictionary<string, string> hat kein TryParse und landet daher in BindComplexParameterFromFormItem, das das gesamte Formular an den gemeinsamen Mapper übergibt:

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

Sehen Sie sich die Argumente an: den Reader und die Optionen. Es gibt kein Präfix. Der key aus der Zeile darüber dient nur als Dictionary-Schlüssel in factoryContext.TrackedParameters und wird nie auf den Präfix-Stack des Readers gelegt. Der Mapper liest das Dictionary deshalb ab der Wurzel des Formulars, und ein Dictionary-Eintrag auf Wurzelebene schreibt sich [author].

Das ist der gesamte Fehler: der Parameter heißt metadata, aber dem Form-Mapper wurde dieser Name nie mitgeteilt.

Das erklärt auch, warum sich das Verhalten wie eine Regression anfühlt, wenn Sie einen Endpunkt von Controllern umziehen. Der Model Binder von MVC probiert zuerst den Parameternamen als Präfix und fällt dann auf das leere Präfix zurück, eine Controller-Action akzeptiert also beide Schreibweisen:

// .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 APIs akzeptieren nur die zweite. Wenn Sie die beiden Hosting-Modelle grundsätzlicher abwägen, behandelt Minimal APIs vs Controller in ASP.NET Core 11 die weiteren Stellen, an denen ihre Bindungssemantik auseinandergeht.

Minimale Reproduktion

Eine vollständige Anwendung, dazu die Anfrageformen, die funktionieren, und die, die es nicht tun:

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

Gemessene Ergebnisse gegen diese Anwendung:

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

Das Muster ist konsistent: ein [FromForm]-Sammlungsparameter auf oberster Ebene wird mit leerem Präfix adressiert, Dictionaries verwenden also [key] und Listen [0], [1] und so weiter. Der Parametername ist totes Gewicht.

Der Fix im Detail

Vier Optionen, in der Reihenfolge, in der ich zu ihnen greifen würde.

1. Kapseln Sie das Dictionary in einer Klasse

Das ist der Fix, den man ausliefern sollte. Eine Eigenschaft einer Klasse bekommt sehr wohl ein Präfix, weil der Mapper den Eigenschaftsnamen beim Abstieg auf seinen Präfix-Stack legt. Das Format auf der Leitung wird dadurch wieder etwas, das ein Mensch lesen und eine Client-Bibliothek erzeugen kann.

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

Der Schlüsselabgleich ist unabhängig von Groß- und Kleinschreibung, metadata[author] bindet also ebenfalls an die Eigenschaft Metadata. Das verschachtelte Dictionary darf auch tiefer liegen: Meta.Tags[a]=1 bindet problemlos, wenn Meta selbst eine Eigenschaft ist.

Sie können die Datei in dieselbe Klasse ziehen, damit die Endpunktsignatur bei einem einzigen Parameter bleibt:

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

Ein Post mit -F "Metadata[author]=marius" -F "File=@a.txt" bindet beides. Die Datei-Eigenschaft wird über den Eigenschaftsnamen zugeordnet, dieselbe Regel gilt für einen IFormFile-Parameter auf oberster Ebene.

2. Dictionary-Parameter behalten und den Client anpassen

Wenn der Client Ihnen gehört und die Endpunktsignatur feststeht, senden Sie einfach Klammer-Schlüssel auf Wurzelebene:

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

Das funktioniert und kostet ein Zeichen pro Schlüssel. Es ist aber auch die Form, die niemand errät, der den Handler in sechs Monaten liest, und sie überlebt keinen zweiten Dictionary-Parameter (siehe die Fallstricke). Betrachten Sie es als Übergangslösung.

3. Lesen Sie das Formular selbst

Die expliziteste Option und die einzige, die den Request Delegate Generator überlebt. IFormCollection wird als Parameter für das gesamte Formular gebunden, ganz ohne Mapping-Schicht, die Schlüsselkonvention gehört also Ihnen:

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

Ausführlich, aber es akzeptiert metadata[author] direkt und liefert einen echten Fehlerpfad bei einem fehlerhaften Schlüssel statt eines stillen null.

4. Die Metadaten als ein einzelnes JSON-Feld senden

Wenn die Metadaten wirklich offen sind, modellieren Sie sie nicht länger als Formularschlüssel. Ein einzelnes Formularfeld mit einem JSON-Dokument bindet über den einfachen Pfad, weil string das obige Prädikat kurzschließt:

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

Nur diese Option liefert verschachtelte Werte, Arrays und Nicht-String-Typen, ohne mit der Schlüsselsyntax zu kämpfen, und sie funktioniert unter AOT identisch.

Fallstricke und Varianten

Die Regel, die man behalten sollte, ist kurz: in einer Minimal API wird ein [FromForm]-Parameter nur dann über seinen Namen adressiert, wenn sein Typ aus einer einzelnen Zeichenkette geparst werden kann. Alles andere geht durch den Blazor-Form-Mapper, der an der Wurzel des Formulars beginnt und nicht weiß, wie Ihr Parameter heißt. Geben Sie ihm eine Klasse, in die er absteigen kann, und die Namen kommen zurück.

Verwandte Beiträge

Quellen

Comments

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

< Zurück