Binding dei parametri nelle app Minimal API

Nota

Questa non è la versione più recente di questo articolo. Per la versione corrente, vedere la versione .NET 10 di questo articolo.

Avviso

Questa versione di ASP.NET Core non è più supportata. Per altre informazioni, vedere i criteri di supporto di .NET e .NET Core. Per la versione corrente, vedere la versione .NET 10 di questo articolo.

Il binding dei parametri è il processo di conversione dei dati della richiesta in parametri fortemente tipizzati definiti dagli handler di route. Un'origine di associazione determina la posizione da cui sono associati i parametri. Le origini di associazione possono essere esplicite o dedotte in base al metodo HTTP e al tipo di parametro.

Origini di associazione supportate:

  • Valori di route
  • Stringa di query
  • Intestazione
  • Corpo della richiesta (in formato JSON)
  • Valori del modulo
  • Servizi forniti dall'iniezione delle dipendenze
  • Personalizzazione

Nota

I tipi di unione C# sono supportati solo come corpo (come JSON). Le origini esterne al corpo della richiesta — valori di route, query string, intestazioni e valori di modulo — associano valori di tipo stringa senza eseguire il parsing JSON, quindi non possono essere mappati a un caso di unione.

Il gestore di route seguente GET usa alcune di queste origini di associazione di parametri:

var builder = WebApplication.CreateBuilder(args);

// Added as service
builder.Services.AddSingleton<Service>();

var app = builder.Build();

app.MapGet("/{id}", (int id,
                     int page,
                     [FromHeader(Name = "X-CUSTOM-HEADER")] string customHeader,
                     Service service) => { });

class Service { }

Nella tabella seguente viene illustrata la relazione tra i parametri usati nell'esempio precedente e le origini di associazione associate.

Parametro Origine del binding
id valore del percorso
page stringa di query
customHeader intestazione
service Fornito da iniezione delle dipendenze

Ad esempio, data la richiesta HTTP seguente:

GET /42?page=3 HTTP/1.1
Host: example.com
X-CUSTOM-HEADER: hello
  • 42 in /42 corrisponde a {id} nel modello di route /{id} e si associa al parametro di route id.
  • page=3 in ?page=3 corrisponde alla chiave della stringa di query page e si associa al parametro page.
  • X-CUSTOM-HEADER: hello corrisponde all'attributo dell'intestazione [FromHeader(Name = "X-CUSTOM-HEADER")] e si collega a customHeader.
  • service viene risolto dal contenitore di iniezione delle dipendenze anziché dalla richiesta HTTP in ingresso.

I metodi GETHTTP , HEAD, OPTIONSe DELETE non si associano in modo implicito dal corpo. Per eseguire il binding dal corpo della richiesta (come JSON) per questi metodi HTTP, eseguire il binding in modo esplicito con [FromBody] oppure leggere da HttpRequest.

Il seguente gestore di routing POST di esempio utilizza un'origine di binding del corpo della richiesta (in formato JSON) per il parametro person:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapPost("/", (Person person) => { });

record Person(string Name, int Age);

I parametri negli esempi precedenti sono associati automaticamente dai dati della richiesta. Per illustrare la praticità fornita dall'associazione di parametri, i gestori di route seguenti illustrano come leggere i dati delle richieste direttamente dalla richiesta:

app.MapGet("/{id}", (HttpRequest request) =>
{
    var id = request.RouteValues["id"];
    var page = request.Query["page"];
    var customHeader = request.Headers["X-CUSTOM-HEADER"];

    // ...
});

app.MapPost("/", async (HttpRequest request) =>
{
    var person = await request.ReadFromJsonAsync<Person>();

    // ...
});

Associazione di parametri esplicita

Gli attributi possono essere usati per dichiarare in modo esplicito il percorso da cui sono associati i parametri.

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Added as service
builder.Services.AddSingleton<Service>();

var app = builder.Build();


app.MapGet("/{id}", ([FromRoute] int id,
                     [FromQuery(Name = "p")] int page,
                     [FromServices] Service service,
                     [FromHeader(Name = "Content-Type")] string contentType) 
                     => {});

class Service { }

record Person(string Name, int Age);
Parametro Origine del binding
id valore del percorso con il nome id
page stringa di query con il nome "p"
service Fornito da iniezione delle dipendenze
contentType intestazione con il nome "Content-Type"

Associazione esplicita dai valori del modulo

L'attributo [FromForm] associa i valori del modulo:

app.MapPost("/todos", async ([FromForm] string name,
    [FromForm] Visibility visibility, IFormFile? attachment, TodoDb db) =>
{
    var todo = new Todo
    {
        Name = name,
        Visibility = visibility
    };

    if (attachment is not null)
    {
        var attachmentName = Path.GetRandomFileName();

        using var stream = File.Create(Path.Combine("wwwroot", attachmentName));
        await attachment.CopyToAsync(stream);
    }

    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Ok();
});

// Remaining code removed for brevity.

Un'alternativa consiste nell'usare l'attributo [AsParameters] con un tipo personalizzato con proprietà annotate con [FromForm]. Ad esempio, il codice seguente esegue il binding dei valori del form alle proprietà del record struct NewTodoRequest:

app.MapPost("/ap/todos", async ([AsParameters] NewTodoRequest request, TodoDb db) =>
{
    var todo = new Todo
    {
        Name = request.Name,
        Visibility = request.Visibility
    };

    if (request.Attachment is not null)
    {
        var attachmentName = Path.GetRandomFileName();

        using var stream = File.Create(Path.Combine("wwwroot", attachmentName));
        await request.Attachment.CopyToAsync(stream);

        todo.Attachment = attachmentName;
    }

    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Ok();
});

// Remaining code removed for brevity.
public record struct NewTodoRequest([FromForm] string Name,
    [FromForm] Visibility Visibility, IFormFile? Attachment);

Per altre informazioni, vedere la sezione asParameters più avanti in questo articolo.

Il codice di esempio completo si trova nel repository AspNetCore.Docs.Samples .

Associazione sicura da IFormFile e IFormFileCollection

L'associazione di moduli complessi è supportata tramite IFormFile e IFormFileCollection usando :[FromForm]

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder();

builder.Services.AddAntiforgery();

var app = builder.Build();
app.UseAntiforgery();

// Generate a form with an anti-forgery token and an /upload endpoint.
app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = MyUtils.GenerateHtmlForm(token.FormFieldName, token.RequestToken!);
    return Results.Content(html, "text/html");
});

app.MapPost("/upload", async Task<Results<Ok<string>, BadRequest<string>>>
    ([FromForm] FileUploadForm fileUploadForm, HttpContext context,
                                                IAntiforgery antiforgery) =>
{
    // Server-side validation. The client-side pattern can be bypassed, so the
    // name must be validated here too.
    if (!MyUtils.IsValidFileName(fileUploadForm.Name))
    {
        return TypedResults.BadRequest("Invalid file name.");
    }

    await MyUtils.SaveFileWithName(fileUploadForm.FileDocument!,
              fileUploadForm.Name!, app.Environment.ContentRootPath);
    return TypedResults.Ok($"Your file with the description:" +
        $" {fileUploadForm.Description} has been uploaded successfully");
});

app.Run();

I parametri collegati alla richiesta con [FromForm] includono un token antiforgery. Il token di protezione dalla contraffazione viene validato quando la richiesta viene elaborata. Per altre informazioni, vedi Antiforgery con API minime.

Per altre informazioni, vedere Associazione di moduli nelle API minime.

Il codice di esempio completo si trova nel repository AspNetCore.Docs.Samples .

Associazione di parametri con inserimento delle dipendenze

Il binding dei parametri per le API minime associa i parametri tramite dependency injection quando il tipo è configurato come servizio. Non è necessario applicare in modo esplicito l'attributo [FromServices] a un parametro. Nel codice seguente entrambe le azioni restituiscono l'ora:

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IDateTime, SystemDateTime>();

var app = builder.Build();

app.MapGet("/",   (               IDateTime dateTime) => dateTime.Now);
app.MapGet("/fs", ([FromServices] IDateTime dateTime) => dateTime.Now);
app.Run();

Parametri facoltativi

I parametri dichiarati nei gestori di route vengono considerati come obbligatori:

  • Se una richiesta corrisponde alla route, il gestore di route viene eseguito solo se nella richiesta vengono forniti tutti i parametri obbligatori.
  • Se non si specificano tutti i parametri obbligatori, viene generato un errore.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/products", (int pageNumber) => $"Requesting page {pageNumber}");

app.Run();
URI (Identificatore Uniforme delle Risorse) risultato
/products?pageNumber=3 3 restituiti
/products BadHttpRequestException: il parametro obbligatorio "int pageNumber" non è stato fornito dalla stringa di query.
/products/1 Errore HTTP 404, nessuna route corrispondente

Per rendere pageNumber facoltativo, definire il tipo come facoltativo o specificare un valore predefinito:

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

app.MapGet("/products", (int? pageNumber) => $"Requesting page {pageNumber ?? 1}");

string ListProducts(int pageNumber = 1) => $"Requesting page {pageNumber}";

app.MapGet("/products2", ListProducts);

app.Run();
URI (Identificatore Uniforme delle Risorse) risultato
/products?pageNumber=3 3 restituiti
/products 1 restituito
/products2 1 restituito

L'impostazione precedente relativa al valore nullo e al valore predefinito si applica a tutte le origini:

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

app.MapPost("/products", (Product? product) => { });

app.Run();

Il codice precedente chiama il metodo con un prodotto null se non viene inviato alcun contenuto nel corpo della richiesta.

NOTA: Se vengono forniti dati non validi e il parametro può assumere il valore null, il gestore della route non viene eseguito.

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

app.MapGet("/products", (int? pageNumber) => $"Requesting page {pageNumber ?? 1}");

app.Run();
URI (Identificatore Uniforme delle Risorse) risultato
/products?pageNumber=3 3 restituito
/products 1 restituito
/products?pageNumber=two BadHttpRequestException: impossibile associare il parametro "Nullable<int> pageNumber" da "two".
/products/two Errore HTTP 404, nessuna route corrispondente

Per altre informazioni, vedere la sezione Errori di binding .

Tipi speciali

I tipi seguenti sono associati senza attributi espliciti:

  • HttpContext: contesto che contiene tutte le informazioni sulla richiesta o la risposta HTTP corrente:

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequest e HttpResponse: la richiesta HTTP e la risposta HTTP:

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken: token di annullamento associato alla richiesta HTTP corrente:

    app.MapGet("/", async (CancellationToken cancellationToken) =>
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal: L'utente associato alla richiesta, vincolato da HttpContext.User:

    app.MapGet("/", (ClaimsPrincipal user) => user.Identity.Name);
    

Associa il corpo della richiesta come Stream o PipeReader

Il corpo della richiesta può essere associato come Stream o PipeReader per supportare in modo efficiente gli scenari in cui l'utente deve elaborare dati e:

  • Archiviare i dati nell'archiviazione BLOB o inserire i dati in una coda presso un provider di code.
  • Elaborare i dati archiviati con un processo di lavoro o una funzione cloud.

Ad esempio, i dati potrebbero essere accodati in Archiviazione code di Azure o archiviati in Archiviazione BLOB di Azure.

Il codice seguente implementa una coda in background:

using System.Text.Json;
using System.Threading.Channels;

namespace BackgroundQueueService;

class BackgroundQueue : BackgroundService
{
    private readonly Channel<ReadOnlyMemory<byte>> _queue;
    private readonly ILogger<BackgroundQueue> _logger;

    public BackgroundQueue(Channel<ReadOnlyMemory<byte>> queue,
                               ILogger<BackgroundQueue> logger)
    {
        _queue = queue;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var dataStream in _queue.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                var person = JsonSerializer.Deserialize<Person>(dataStream.Span)!;
                _logger.LogInformation($"{person.Name} is {person.Age} " +
                                       $"years and from {person.Country}");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message);
            }
        }
    }
}

class Person
{
    public string Name { get; set; } = String.Empty;
    public int Age { get; set; }
    public string Country { get; set; } = String.Empty;
}

Il codice seguente associa il corpo della richiesta a un Streamoggetto :

app.MapPost("/register", async (HttpRequest req, Stream body,
                                 Channel<ReadOnlyMemory<byte>> queue) =>
{
    if (req.ContentLength is not null && req.ContentLength > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // We're not above the message size and we have a content length, or
    // we're a chunked request and we're going to read up to the maxMessageSize + 1. 
    // We add one to the message size so that we can detect when a chunked request body
    // is bigger than our configured max.
    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);

    var buffer = new byte[readSize];

    // Read at least that many bytes from the body.
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    // We read more than the max, so this is a bad request.
    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // Attempt to send the buffer to the background queue.
    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    // We couldn't accept the message since we're overloaded.
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

Il codice seguente mostra il file completo Program.cs :

using System.Threading.Channels;
using BackgroundQueueService;

var builder = WebApplication.CreateBuilder(args);
// The max memory to use for the upload endpoint on this instance.
var maxMemory = 500 * 1024 * 1024;

// The max size of a single message, staying below the default LOH size of 85K.
var maxMessageSize = 80 * 1024;

// The max size of the queue based on those restrictions
var maxQueueSize = maxMemory / maxMessageSize;

// Create a channel to send data to the background queue.
builder.Services.AddSingleton<Channel<ReadOnlyMemory<byte>>>((_) =>
                     Channel.CreateBounded<ReadOnlyMemory<byte>>(maxQueueSize));

// Create a background queue service.
builder.Services.AddHostedService<BackgroundQueue>();
var app = builder.Build();

// curl --request POST 'https://localhost:<port>/register' --header 'Content-Type: application/json' --data-raw '{ "Name":"Samson", "Age": 23, "Country":"Nigeria" }'
// curl --request POST "https://localhost:<port>/register" --header "Content-Type: application/json" --data-raw "{ \"Name\":\"Samson\", \"Age\": 23, \"Country\":\"Nigeria\" }"
app.MapPost("/register", async (HttpRequest req, Stream body,
                                 Channel<ReadOnlyMemory<byte>> queue) =>
{
    if (req.ContentLength is not null && req.ContentLength > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // We're not above the message size and we have a content length, or
    // we're a chunked request and we're going to read up to the maxMessageSize + 1. 
    // We add one to the message size so that we can detect when a chunked request body
    // is bigger than our configured max.
    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);

    var buffer = new byte[readSize];

    // Read at least that many bytes from the body.
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    // We read more than the max, so this is a bad request.
    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // Attempt to send the buffer to the background queue.
    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    // We couldn't accept the message since we're overloaded.
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

app.Run();
  • Durante la lettura dei dati, è Stream lo stesso oggetto di HttpRequest.Body.
  • Il corpo della richiesta non viene memorizzato nel buffer per impostazione predefinita. Dopo aver letto il contenuto, non può essere riavvolto. Il flusso non può essere letto più volte.
  • e StreamPipeReader non sono utilizzabili al di fuori del gestore di azioni minimo perché i buffer sottostanti verranno eliminati o riutilizzati.

Caricamenti di file con IFormFile e IFormFileCollection

I caricamenti di file con IFormFile e IFormFileCollection nelle API minime richiedono la codifica con multipart/form-data. Il nome del parametro nel gestore di route deve corrispondere al nome del campo modulo nella richiesta. Le API minime non supportano l'associazione diretta dell'intero corpo della richiesta a un IFormFile parametro senza codifica del modulo.

Se è necessario associare l'intero corpo della richiesta, ad esempio quando si usa JSON, dati binari o altri tipi di contenuto, vedere:

Il codice seguente usa IFormFile e IFormFileCollection per caricare il file:

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

app.MapGet("/", () => "Hello World!");

app.MapPost("/upload", async (IFormFile file) =>
{
    var tempFile = Path.GetTempFileName();
    app.Logger.LogInformation(tempFile);
    using var stream = File.OpenWrite(tempFile);
    await file.CopyToAsync(stream);
});

app.MapPost("/upload_many", async (IFormFileCollection myFiles) =>
{
    foreach (var file in myFiles)
    {
        var tempFile = Path.GetTempFileName();
        app.Logger.LogInformation(tempFile);
        using var stream = File.OpenWrite(tempFile);
        await file.CopyToAsync(stream);
    }
});

app.Run();

Le richieste di caricamento di file autenticate sono supportate tramite un'intestazione di autorizzazione, un certificato client o un'intestazionecookie.

Associazione ai moduli con IFormCollection, IFormFile e IFormFileCollection

L'associazione da parametri basati su form tramite IFormCollection, IFormFilee IFormFileCollection è supportata. I metadati OpenAPI vengono dedotti per i parametri del modulo per supportare l'integrazione con l'interfaccia utente di Swagger.

Il codice seguente carica i file usando l'associazione dedotta dal IFormFile tipo :

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder();

builder.Services.AddAntiforgery();

var app = builder.Build();
app.UseAntiforgery();

string GetOrCreateFilePath(string fileName, string filesDirectory = "uploadFiles")
{
    var directoryPath = Path.Combine(app.Environment.ContentRootPath, filesDirectory);
    Directory.CreateDirectory(directoryPath);
    return Path.Combine(directoryPath, fileName);
}

async Task UploadFileWithName(IFormFile file, string fileSaveName)
{
    var filePath = GetOrCreateFilePath(fileSaveName);
    await using var fileStream = new FileStream(filePath, FileMode.Create);
    await file.CopyToAsync(fileStream);
}

app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = $"""
      <html>
        <body>
          <form action="/upload" method="POST" enctype="multipart/form-data">
            <input name="{token.FormFieldName}" type="hidden" value="{token.RequestToken}"/>
            <input type="file" name="file" placeholder="Upload an image..." accept=".jpg, 
                                                                            .jpeg, .png" />
            <input type="submit" />
          </form> 
        </body>
      </html>
    """;

    return Results.Content(html, "text/html");
});

app.MapPost("/upload", async Task<Results<Ok<string>,
   BadRequest<string>>> (IFormFile file, HttpContext context, IAntiforgery antiforgery) =>
{
    var fileSaveName = Guid.NewGuid().ToString("N") + Path.GetExtension(file.FileName);
    await UploadFileWithName(file, fileSaveName);
    return TypedResults.Ok("File uploaded successfully!");
});

app.Run();

Avviso: quando si implementano moduli, l'app deve prevenire attacchi XSRF/CSRF (Cross-Site Request Forgery). Nel codice precedente il IAntiforgery servizio viene usato per evitare attacchi XSRF generando e convalidando un token antiforgery:

using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder();

builder.Services.AddAntiforgery();

var app = builder.Build();
app.UseAntiforgery();

string GetOrCreateFilePath(string fileName, string filesDirectory = "uploadFiles")
{
    var directoryPath = Path.Combine(app.Environment.ContentRootPath, filesDirectory);
    Directory.CreateDirectory(directoryPath);
    return Path.Combine(directoryPath, fileName);
}

async Task UploadFileWithName(IFormFile file, string fileSaveName)
{
    var filePath = GetOrCreateFilePath(fileSaveName);
    await using var fileStream = new FileStream(filePath, FileMode.Create);
    await file.CopyToAsync(fileStream);
}

app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = $"""
      <html>
        <body>
          <form action="/upload" method="POST" enctype="multipart/form-data">
            <input name="{token.FormFieldName}" type="hidden" value="{token.RequestToken}"/>
            <input type="file" name="file" placeholder="Upload an image..." accept=".jpg, 
                                                                            .jpeg, .png" />
            <input type="submit" />
          </form> 
        </body>
      </html>
    """;

    return Results.Content(html, "text/html");
});

app.MapPost("/upload", async Task<Results<Ok<string>,
   BadRequest<string>>> (IFormFile file, HttpContext context, IAntiforgery antiforgery) =>
{
    var fileSaveName = Guid.NewGuid().ToString("N") + Path.GetExtension(file.FileName);
    await UploadFileWithName(file, fileSaveName);
    return TypedResults.Ok("File uploaded successfully!");
});

app.Run();

Per altre informazioni sugli attacchi XSRF, vedere Antiforgery con API minime

Per altre informazioni, vedere Associazione di moduli nelle API minime;

Comportamento di associazione della raccolta IFormFile

Nella tabella seguente viene riepilogato il modo in cui i diversi IFormFile tipi di parametri di raccolta sono associati nelle API minime. L’indicazione generale secondo cui il nome del parametro nell’handler di route deve corrispondere al nome del campo del modulo si applica a IFormFile e alle raccolte di file denominate supportate, come IReadOnlyList<T>, ma non a IFormFileCollection, che associa tutti i file caricati.

Tipo di parametro Valore associato Rispetta il nome del parametro?
IFormFileCollection Tutti i file in HttpContext.Request.Form.Files No
IFormFile Singolo file il cui nome del campo modulo corrisponde al nome del parametro Yes
IReadOnlyList<IFormFile> Tutti i file il cui nome del campo modulo corrisponde al nome del parametro Yes
Altri IFormFile tipi di raccolta (IEnumerable<IFormFile>, List<IFormFile>, IFormFile[]e così via) Non supportato: il parametro non viene popolato N/A

Usare IFormFileCollection quando sono necessari tutti i file caricati indipendentemente dal nome del campo modulo. Usare IReadOnlyList<T> quando sono necessari solo i file il cui nome del campo modulo corrisponde al nome del parametro.

Nota

Le regole di associazione delle raccolte di IFormFile nella tabella precedente si applicano anche alle proprietà dei tipi [AsParameters] e ai tipi complessi mappati dai form, poiché il livello di mapping dei form è condiviso con Blazor.

Associare collezioni e tipi complessi nei moduli

L'associazione è supportata per:

  • Raccolte, ad esempio List e Dictionary
  • Tipi complessi, ad esempio, Todo oppure Project

Avviso

Il mapping dei form di tipo complesso con [FromForm] nelle API minime non usa il model binding di MVC. Gli attributi nello spazio dei nomi Microsoft.AspNetCore.Mvc.ModelBinding, ad esempio [BindNever] e [BindRequired], non sono supportati. Non usare questi attributi per impedire l'overposting. Usare invece un modello di input dedicato o un oggetto di trasferimento dati (DTO) che includa solo le proprietà che i client sono autorizzati a modificare.

Il codice seguente mostra:

  • Un endpoint minimale che collega l'input di un modulo multipart a un oggetto complesso.
  • Come usare i servizi antiforgery per supportare la generazione e la convalida dei token antiforgery.
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAntiforgery();

var app = builder.Build();

app.UseAntiforgery();

app.MapGet("/", (HttpContext context, IAntiforgery antiforgery) =>
{
    var token = antiforgery.GetAndStoreTokens(context);
    var html = $"""
        <html><body>
           <form action="/todo" method="POST" enctype="multipart/form-data">
               <input name="{token.FormFieldName}" 
                                type="hidden" value="{token.RequestToken}" />
               <input type="text" name="name" />
               <input type="date" name="dueDate" />
               <input type="checkbox" name="isCompleted" value="true" />
               <input type="submit" />
               <input name="isCompleted" type="hidden" value="false" /> 
           </form>
        </body></html>
    """;
    return Results.Content(html, "text/html");
});

app.MapPost("/todo", async Task<Results<Ok<Todo>, BadRequest<string>>> 
               ([FromForm] Todo todo, HttpContext context, IAntiforgery antiforgery) =>
{
    try
    {
        await antiforgery.ValidateRequestAsync(context);
        return TypedResults.Ok(todo);
    }
    catch (AntiforgeryValidationException e)
    {
        return TypedResults.BadRequest("Invalid antiforgery token");
    }
});

app.Run();

class Todo
{
    public string Name { get; set; } = string.Empty;
    public bool IsCompleted { get; set; } = false;
    public DateTime DueDate { get; set; } = DateTime.Now.Add(TimeSpan.FromDays(1));
}

Nel codice precedente:

  • Il parametro di destinazione deve essere annotato con l'attributo [FromForm] per evitare ambiguità con i parametri che devono essere letti dal corpo JSON.
  • L'associazione da tipi complessi o di raccolta non è supportata per le API minime compilate con il generatore di delegati di richiesta.
  • Il markup mostra un input nascosto aggiuntivo con un nome pari a isCompleted e un valore pari a false. Se la isCompleted casella di controllo viene selezionata quando il modulo viene inviato, entrambi i valori true e false vengono inviati come valori. Se la casella di controllo è deselezionata, viene inviato solo il valore false di input nascosto. Il processo di mapping dei campi del modulo in ASP.NET Core legge solo il primo valore durante l'associazione a un valore bool, con il risultato che true viene usato per le caselle di controllo selezionate e false per quelle deselezionate.

Un esempio dei dati del modulo inviati all'endpoint precedente è simile al seguente:

__RequestVerificationToken: CfDJ8Bveip67DklJm5vI2PF2VOUZ594RC8kcGWpTnVV17zCLZi1yrs-CSz426ZRRrQnEJ0gybB0AD7hTU-0EGJXDU-OaJaktgAtWLIaaEWMOWCkoxYYm-9U9eLV7INSUrQ6yBHqdMEE_aJpD4AI72gYiCqc
name: Walk the dog
dueDate: 2024-04-06
isCompleted: true
isCompleted: false

Associare array e stringhe dalle intestazioni e dalle stringhe di query

Il codice seguente illustra l'associazione di stringhe di query a una matrice di tipi primitivi, matrici di stringhe e StringValues:

// Bind query string values to a primitive type array.
// GET  /tags?q=1&q=2&q=3
app.MapGet("/tags", (int[] q) =>
                      $"tag1: {q[0]} , tag2: {q[1]}, tag3: {q[2]}");

// Bind to a string array.
// GET /tags2?names=john&names=jack&names=jane
app.MapGet("/tags2", (string[] names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

// Bind to StringValues.
// GET /tags3?names=john&names=jack&names=jane
app.MapGet("/tags3", (StringValues names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

L'associazione di stringhe di query o di valori dell'intestazione a un array di tipi complessi è supportata quando il tipo implementa TryParse. Il codice seguente viene associato a una matrice di stringhe e restituisce tutti gli elementi con i tag specificati:

// GET /todoitems/tags?tags=home&tags=work
app.MapGet("/todoitems/tags", async (Tag[] tags, TodoDb db) =>
{
    return await db.Todos
        .Where(t => tags.Select(i => i.Name).Contains(t.Tag.Name))
        .ToListAsync();
});

Il codice seguente illustra il modello e l'implementazione necessaria TryParse :

public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }

    // This is an owned entity. 
    public Tag Tag { get; set; } = new();
}

[Owned]
public class Tag
{
    public string? Name { get; set; } = "n/a";

    public static bool TryParse(string? name, out Tag tag)
    {
        if (name is null)
        {
            tag = default!;
            return false;
        }

        tag = new Tag { Name = name };
        return true;
    }
}

Il codice seguente viene associato a una int matrice:

// GET /todoitems/query-string-ids?ids=1&ids=3
app.MapGet("/todoitems/query-string-ids", async (int[] ids, TodoDb db) =>
{
    return await db.Todos
        .Where(t => ids.Contains(t.Id))
        .ToListAsync();
});

Per testare il codice precedente, aggiungere l'endpoint seguente per popolare il database con Todo elementi:

// POST /todoitems/batch
app.MapPost("/todoitems/batch", async (Todo[] todos, TodoDb db) =>
{
    await db.Todos.AddRangeAsync(todos);
    await db.SaveChangesAsync();

    return Results.Ok(todos);
});

Usare uno strumento come HttpRepl per passare i dati seguenti all'endpoint precedente:

[
    {
        "id": 1,
        "name": "Have Breakfast",
        "isComplete": true,
        "tag": {
            "name": "home"
        }
    },
    {
        "id": 2,
        "name": "Have Lunch",
        "isComplete": true,
        "tag": {
            "name": "work"
        }
    },
    {
        "id": 3,
        "name": "Have Supper",
        "isComplete": true,
        "tag": {
            "name": "home"
        }
    },
    {
        "id": 4,
        "name": "Have Snacks",
        "isComplete": true,
        "tag": {
            "name": "N/A"
        }
    }
]

Il codice seguente si associa alla chiave dell'intestazione X-Todo-Id e restituisce gli elementi Todo con valori Id corrispondenti:

// GET /todoitems/header-ids
// The keys of the headers should all be X-Todo-Id with different values
app.MapGet("/todoitems/header-ids", async ([FromHeader(Name = "X-Todo-Id")] int[] ids, TodoDb db) =>
{
    return await db.Todos
        .Where(t => ids.Contains(t.Id))
        .ToListAsync();
});

Nota

Quando si associa un string[] non nullable da una stringa di query, l'assenza di qualsiasi valore della stringa di query corrispondente restituisce un array vuoto anziché null. Se il parametro è dichiarato come nullable (ad esempio, string[]?) o ha un valore predefinito di null (ad esempio, [FromQuery] string[]? names = null), omettere il parametro di query comporta null.

Associazione di parametri per gli elenchi di argomenti con [AsParameters]

AsParametersAttribute consente l'associazione di parametri semplice ai tipi e non l'associazione di modelli complessi o ricorsivi.

Si consideri il seguente codice :

using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.Select(x => new TodoItemDTO(x)).ToListAsync());

app.MapGet("/todoitems/{id}",
                             async (int Id, TodoDb Db) =>
    await Db.Todos.FindAsync(Id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());
// Remaining code removed for brevity.

Si consideri l'endpoint seguente GET :

app.MapGet("/todoitems/{id}",
                             async (int Id, TodoDb Db) =>
    await Db.Todos.FindAsync(Id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());

Per sostituire i parametri evidenziati precedenti, è possibile usare quanto segue struct :

struct TodoItemRequest
{
    public int Id { get; set; }
    public TodoDb Db { get; set; }
}

L'endpoint rifattorizzato GET utilizza il precedente struct con l'attributo AsParameters:

app.MapGet("/ap/todoitems/{id}",
                                async ([AsParameters] TodoItemRequest request) =>
    await request.Db.Todos.FindAsync(request.Id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());

Il codice seguente mostra altri endpoint nell'app:

app.MapPost("/todoitems", async (TodoItemDTO Dto, TodoDb Db) =>
{
    var todoItem = new Todo
    {
        IsComplete = Dto.IsComplete,
        Name = Dto.Name
    };

    Db.Todos.Add(todoItem);
    await Db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/todoitems/{id}", async (int Id, TodoItemDTO Dto, TodoDb Db) =>
{
    var todo = await Db.Todos.FindAsync(Id);

    if (todo is null) return Results.NotFound();

    todo.Name = Dto.Name;
    todo.IsComplete = Dto.IsComplete;

    await Db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int Id, TodoDb Db) =>
{
    if (await Db.Todos.FindAsync(Id) is Todo todo)
    {
        Db.Todos.Remove(todo);
        await Db.SaveChangesAsync();
        return Results.Ok(new TodoItemDTO(todo));
    }

    return Results.NotFound();
});

Per effettuare il refactoring degli elenchi di parametri vengono usate le classi seguenti:

class CreateTodoItemRequest
{
    public TodoItemDTO Dto { get; set; } = default!;
    public TodoDb Db { get; set; } = default!;
}

class EditTodoItemRequest
{
    public int Id { get; set; }
    public TodoItemDTO Dto { get; set; } = default!;
    public TodoDb Db { get; set; } = default!;
}

Il codice seguente mostra gli endpoint refattorizzati che usano AsParameters e il precedente struct e le classi:

app.MapPost("/ap/todoitems", async ([AsParameters] CreateTodoItemRequest request) =>
{
    var todoItem = new Todo
    {
        IsComplete = request.Dto.IsComplete,
        Name = request.Dto.Name
    };

    request.Db.Todos.Add(todoItem);
    await request.Db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/ap/todoitems/{id}", async ([AsParameters] EditTodoItemRequest request) =>
{
    var todo = await request.Db.Todos.FindAsync(request.Id);

    if (todo is null) return Results.NotFound();

    todo.Name = request.Dto.Name;
    todo.IsComplete = request.Dto.IsComplete;

    await request.Db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/ap/todoitems/{id}", async ([AsParameters] TodoItemRequest request) =>
{
    if (await request.Db.Todos.FindAsync(request.Id) is Todo todo)
    {
        request.Db.Todos.Remove(todo);
        await request.Db.SaveChangesAsync();
        return Results.Ok(new TodoItemDTO(todo));
    }

    return Results.NotFound();
});

Per sostituire i parametri precedenti, è possibile usare i tipi seguenti record :

record TodoItemRequest(int Id, TodoDb Db);
record CreateTodoItemRequest(TodoItemDTO Dto, TodoDb Db);
record EditTodoItemRequest(int Id, TodoItemDTO Dto, TodoDb Db);

L'uso di un struct con AsParameters può essere più performante rispetto all'uso di un tipo record.

Codice di esempio completo nel repository AspNetCore.Docs.Samples .

Binding personalizzato

Esistono tre modi per personalizzare l'associazione di parametri:

  1. Per le origini di binding di route, query e header, eseguire il binding di tipi personalizzati aggiungendo al tipo un metodo statico TryParse.
  2. Controllare il processo di associazione implementando un BindAsync metodo su un tipo.
  3. Per gli scenari avanzati, implementare l'interfaccia IBindableFromHttpContext<TSelf> per fornire logica di associazione personalizzata direttamente da HttpContext.

TryParse

TryParse dispone di due API:

public static bool TryParse(string value, out T result);
public static bool TryParse(string value, IFormatProvider provider, out T result);

Il codice seguente viene visualizzato Point: 12.3, 10.1 con l'URI /map?Point=12.3,10.1:

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

// GET /map?Point=12.3,10.1
app.MapGet("/map", (Point point) => $"Point: {point.X}, {point.Y}");

app.Run();

public class Point
{
    public double X { get; set; }
    public double Y { get; set; }

    public static bool TryParse(string? value, IFormatProvider? provider,
                                out Point? point)
    {
        // Format is "(12.3,10.1)"
        var trimmedValue = value?.TrimStart('(').TrimEnd(')');
        var segments = trimmedValue?.Split(',',
                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (segments?.Length == 2
            && double.TryParse(segments[0], out var x)
            && double.TryParse(segments[1], out var y))
        {
            point = new Point { X = x, Y = y };
            return true;
        }

        point = null;
        return false;
    }
}

BindAsync

BindAsync include le API seguenti:

public static ValueTask<T?> BindAsync(HttpContext context, ParameterInfo parameter);
public static ValueTask<T?> BindAsync(HttpContext context);

Il codice seguente viene visualizzato SortBy:xyz, SortDirection:Desc, CurrentPage:99 con l'URI /products?SortBy=xyz&SortDir=Desc&Page=99:

using System.Reflection;

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

// GET /products?SortBy=xyz&SortDir=Desc&Page=99
app.MapGet("/products", (PagingData pageData) => $"SortBy:{pageData.SortBy}, " +
       $"SortDirection:{pageData.SortDirection}, CurrentPage:{pageData.CurrentPage}");

app.Run();

public class PagingData
{
    public string? SortBy { get; init; }
    public SortDirection SortDirection { get; init; }
    public int CurrentPage { get; init; } = 1;

    public static ValueTask<PagingData?> BindAsync(HttpContext context,
                                                   ParameterInfo parameter)
    {
        const string sortByKey = "sortBy";
        const string sortDirectionKey = "sortDir";
        const string currentPageKey = "page";

        Enum.TryParse<SortDirection>(context.Request.Query[sortDirectionKey],
                                     ignoreCase: true, out var sortDirection);
        int.TryParse(context.Request.Query[currentPageKey], out var page);
        page = page == 0 ? 1 : page;

        var result = new PagingData
        {
            SortBy = context.Request.Query[sortByKey],
            SortDirection = sortDirection,
            CurrentPage = page
        };

        return ValueTask.FromResult<PagingData?>(result);
    }
}

public enum SortDirection
{
    Default,
    Asc,
    Desc
}

Associazione di parametri personalizzata con IBindableFromHttpContext

ASP.NET Core fornisce il supporto per l'associazione di parametri personalizzati nelle API minime usando l'interfaccia IBindableFromHttpContext<TSelf> . Questa interfaccia, introdotta con i membri astratti statici di C# 11, consente di creare tipi che possono essere associati da un contesto HTTP direttamente nei parametri del gestore di route.

public interface IBindableFromHttpContext<TSelf>
    where TSelf : class, IBindableFromHttpContext<TSelf>
{
    static abstract ValueTask<TSelf?> BindAsync(HttpContext context, ParameterInfo parameter);
}

Implementando IBindableFromHttpContext<TSelf>, è possibile creare tipi personalizzati che gestiscono la propria logica di associazione da HttpContext. Quando un gestore di route include un parametro di questo tipo, il framework chiama automaticamente il metodo statico BindAsync per creare l'istanza:

using CustomBindingExample;

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

app.UseHttpsRedirection();

app.MapGet("/", () => "Hello, IBindableFromHttpContext example!");

app.MapGet("/custom-binding", (CustomBoundParameter param) =>
{
    return $"Value from custom binding: {param.Value}";
});

app.MapGet("/combined/{id}", (int id, CustomBoundParameter param) =>
{
    return $"ID: {id}, Custom Value: {param.Value}";
});

Di seguito è riportato un esempio di implementazione di un parametro personalizzato che viene associato da un'intestazione HTTP:

using System.Reflection;

namespace CustomBindingExample;

public class CustomBoundParameter : IBindableFromHttpContext<CustomBoundParameter>
{
    public string Value { get; init; } = default!;

    public static ValueTask<CustomBoundParameter?> BindAsync(HttpContext context, ParameterInfo parameter)
    {
        // Custom binding logic here
        // This example reads from a custom header
        var value = context.Request.Headers["X-Custom-Header"].ToString();
        
        // If no header was provided, you could fall back to a query parameter
        if (string.IsNullOrEmpty(value))
        {
            value = context.Request.Query["customValue"].ToString();
        }
        
        return ValueTask.FromResult<CustomBoundParameter?>(new CustomBoundParameter 
        {
            Value = value
        });
    }
}

È anche possibile implementare la convalida all'interno della logica di associazione personalizzata:

app.MapGet("/validated", (ValidatedParameter param) =>
{
    if (string.IsNullOrEmpty(param.Value))
    {
        return Results.BadRequest("Value cannot be empty");
    }
    
    return Results.Ok($"Validated value: {param.Value}");
});

Visualizzare o scaricare il codice di esempio (come scaricare)

Errori di binding

Quando l'associazione non riesce, il framework registra un messaggio di debug e restituisce vari codici di stato al client a seconda della modalità di errore.

Modalità di guasto Tipo di parametro annullabile Origine del binding Codice di stato
{ParameterType}.TryParse restituisce false Sì route/query/intestazione 400
{ParameterType}.BindAsync restituisce null Sì personalizzato 400
{ParameterType}.BindAsync genera Non importa personalizzato 500
Impossibile deserializzare il corpo JSON Non importa corpo 400
Tipo di contenuto errato (non application/json) Non importa corpo 415

Quando ThrowOnBadRequest è false, gli endpoint con una pipeline di filtro eseguono i filtri dopo un errore di controllo dei parametri (ad esempio analisi non riuscita di route, query o intestazione, o parametro obbligatorio mancante), consentendo a un filtro di osservare il codice di stato 400 e sostituire il corpo della risposta. I filtri degli endpoint non vengono eseguiti per errori di lettura del corpo (ad esempio JSON in formato non valido o tipi di contenuto non supportati) o per le eccezioni di binding. Per altre informazioni, vedere Filtri e errori di associazione dei parametri.

Precedenza di binding

Regole per determinare un'origine di associazione da un parametro:

  1. Attributo esplicito definito per il parametro (attributi From*) nell'ordine seguente:
    1. Valori di route: [FromRoute]
    2. Stringa di query: [FromQuery]
    3. Intestazione: [FromHeader]
    4. Corpo del testo: [FromBody]
    5. Modulo: [FromForm]
    6. Servizio: [FromServices]
    7. Valori dei parametri: [AsParameters]
  2. Tipi speciali
    1. HttpContext
    2. HttpRequest (HttpContext.Request)
    3. HttpResponse (HttpContext.Response)
    4. ClaimsPrincipal (HttpContext.User)
    5. CancellationToken (HttpContext.RequestAborted)
    6. IFormCollection (HttpContext.Request.Form)
    7. IFormFileCollection (HttpContext.Request.Form.Files)
    8. IFormFile (HttpContext.Request.Form.Files[paramName])
    9. Stream (HttpContext.Request.Body)
    10. PipeReader (HttpContext.Request.BodyReader)
  3. Il tipo di parametro ha un metodo statico BindAsync valido.
  4. Il tipo di parametro è una stringa o ha un metodo statico TryParse valido.
    1. Se il nome del parametro esiste nel modello di percorso, ad esempio, app.Map("/todo/{id}", (int id) => {});, viene associato dal percorso.
    2. Associato dalla stringa di query.
  5. Se il tipo di parametro è un servizio fornito dall'iniezione delle dipendenze, usa tale servizio come origine.
  6. Il parametro proviene dal corpo.

Configurare le opzioni di deserializzazione JSON per l'associazione del corpo

L'origine di associazione del corpo usa System.Text.Json per la deserializzazione. Non è possibile modificare questa impostazione predefinita, ma è possibile configurare le opzioni di serializzazione e deserializzazione JSON.

Configurare le opzioni di deserializzazione JSON a livello globale

Le opzioni applicabili a livello globale per un'app possono essere configurate richiamando ConfigureHttpJsonOptions. L'esempio seguente include campi pubblici e formatta l'output JSON.

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options => {
    options.SerializerOptions.WriteIndented = true;
    options.SerializerOptions.IncludeFields = true;
});

var app = builder.Build();

app.MapPost("/", (Todo todo) => {
    if (todo is not null) {
        todo.Name = todo.NameField;
    }
    return todo;
});

app.Run();

class Todo {
    public string? Name { get; set; }
    public string? NameField;
    public bool IsComplete { get; set; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "nameField":"Walk dog",
//    "isComplete":false
// }

Poiché il codice di esempio configura sia la serializzazione che la deserializzazione, può leggere NameField e includere NameField nel codice JSON di output.

Configurare le opzioni di deserializzazione JSON per un endpoint

ReadFromJsonAsync dispone di overload che accettano un JsonSerializerOptions oggetto . L'esempio seguente include campi pubblici e formatta l'output JSON.

using System.Text.Json;

var app = WebApplication.Create();

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { 
    IncludeFields = true, 
    WriteIndented = true
};

app.MapPost("/", async (HttpContext context) => {
    if (context.Request.HasJsonContentType()) {
        var todo = await context.Request.ReadFromJsonAsync<Todo>(options);
        if (todo is not null) {
            todo.Name = todo.NameField;
        }
        return Results.Ok(todo);
    }
    else {
        return Results.BadRequest();
    }
});

app.Run();

class Todo
{
    public string? Name { get; set; }
    public string? NameField;
    public bool IsComplete { get; set; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "isComplete":false
// }

Poiché il codice precedente applica le opzioni personalizzate solo alla deserializzazione, il codice JSON di output esclude NameField.

Leggere il corpo della richiesta

Leggere il corpo della richiesta direttamente usando un HttpContext parametro o HttpRequest :

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

app.MapPost("/uploadstream", async (IConfiguration config, HttpRequest request) =>
{
    var filePath = Path.Combine(config["StoredFilesPath"], Path.GetRandomFileName());

    await using var writeStream = File.Create(filePath);
    await request.BodyReader.CopyToAsync(writeStream);
});

app.Run();

Il codice precedente:

  • Accede al corpo della richiesta usando HttpRequest.BodyReader.
  • Copia il corpo della richiesta in un file locale.

Il binding dei parametri è il processo di conversione dei dati della richiesta in parametri fortemente tipizzati definiti dagli handler di route. Un'origine di associazione determina la posizione da cui sono associati i parametri. Le origini di associazione possono essere esplicite o dedotte in base al metodo HTTP e al tipo di parametro.

Origini di associazione supportate:

  • Valori di route
  • Stringa di query
  • Intestazione
  • Corpo della richiesta (in formato JSON)
  • Servizi forniti dall'iniezione delle dipendenze
  • Personalizzazione

Il binding dei valori del modulo non è supportato in modo nativo in .NET 6 e 7.

Il gestore di route seguente GET usa alcune di queste origini di associazione di parametri:

var builder = WebApplication.CreateBuilder(args);

// Added as service
builder.Services.AddSingleton<Service>();

var app = builder.Build();

app.MapGet("/{id}", (int id,
                     int page,
                     [FromHeader(Name = "X-CUSTOM-HEADER")] string customHeader,
                     Service service) => { });

class Service { }

Nella tabella seguente viene illustrata la relazione tra i parametri usati nell'esempio precedente e le origini di associazione associate.

Parametro Origine del binding
id valore del percorso
page stringa di query
customHeader intestazione
service Fornito da iniezione delle dipendenze

Ad esempio, data la richiesta HTTP seguente:

GET /42?page=3 HTTP/1.1
Host: example.com
X-CUSTOM-HEADER: hello
  • 42 in /42 corrisponde a {id} nel modello di route /{id} e si associa al parametro di route id.
  • page=3 in ?page=3 corrisponde alla chiave della stringa di query page e si associa al parametro page.
  • X-CUSTOM-HEADER: hello corrisponde all'attributo dell'intestazione [FromHeader(Name = "X-CUSTOM-HEADER")] e si collega a customHeader.
  • service viene risolto dal contenitore di iniezione delle dipendenze anziché dalla richiesta HTTP in ingresso.

I metodi GETHTTP , HEAD, OPTIONSe DELETE non si associano in modo implicito dal corpo. Per eseguire il binding dal corpo della richiesta (come JSON) per questi metodi HTTP, eseguire il binding in modo esplicito con [FromBody] oppure leggere da HttpRequest.

Il seguente gestore di routing POST di esempio utilizza un'origine di binding del corpo della richiesta (in formato JSON) per il parametro person:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapPost("/", (Person person) => { });

record Person(string Name, int Age);

I parametri negli esempi precedenti sono associati automaticamente dai dati della richiesta. Per illustrare la praticità fornita dall'associazione di parametri, i gestori di route seguenti illustrano come leggere i dati delle richieste direttamente dalla richiesta:

app.MapGet("/{id}", (HttpRequest request) =>
{
    var id = request.RouteValues["id"];
    var page = request.Query["page"];
    var customHeader = request.Headers["X-CUSTOM-HEADER"];

    // ...
});

app.MapPost("/", async (HttpRequest request) =>
{
    var person = await request.ReadFromJsonAsync<Person>();

    // ...
});

Associazione di parametri esplicita

Gli attributi possono essere usati per dichiarare in modo esplicito il percorso da cui sono associati i parametri.

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

// Added as service
builder.Services.AddSingleton<Service>();

var app = builder.Build();


app.MapGet("/{id}", ([FromRoute] int id,
                     [FromQuery(Name = "p")] int page,
                     [FromServices] Service service,
                     [FromHeader(Name = "Content-Type")] string contentType) 
                     => {});

class Service { }

record Person(string Name, int Age);
Parametro Origine del binding
id valore del percorso con il nome id
page stringa di query con il nome "p"
service Fornito da iniezione delle dipendenze
contentType intestazione con il nome "Content-Type"

Nota

Il binding dei valori del modulo non è supportato in modo nativo in .NET 6 e 7.

Associazione di parametri con inserimento delle dipendenze

Il binding dei parametri per le API minime associa i parametri tramite dependency injection quando il tipo è configurato come servizio. Non è necessario applicare in modo esplicito l'attributo [FromServices] a un parametro. Nel codice seguente entrambe le azioni restituiscono l'ora:

using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IDateTime, SystemDateTime>();

var app = builder.Build();

app.MapGet("/",   (               IDateTime dateTime) => dateTime.Now);
app.MapGet("/fs", ([FromServices] IDateTime dateTime) => dateTime.Now);
app.Run();

Parametri facoltativi

I parametri dichiarati nei gestori di route vengono considerati come obbligatori:

  • Se una richiesta corrisponde alla route, il gestore di route viene eseguito solo se nella richiesta vengono forniti tutti i parametri obbligatori.
  • Se non si specificano tutti i parametri obbligatori, viene generato un errore.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/products", (int pageNumber) => $"Requesting page {pageNumber}");

app.Run();
URI (Identificatore Uniforme delle Risorse) risultato
/products?pageNumber=3 3 restituiti
/products BadHttpRequestException: parametro obbligatorio "int pageNumber" non fornito dalla stringa di query.
/products/1 Errore HTTP 404, nessuna route corrispondente

Per rendere pageNumber facoltativo, definire il tipo come facoltativo o specificare un valore predefinito:

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

app.MapGet("/products", (int? pageNumber) => $"Requesting page {pageNumber ?? 1}");

string ListProducts(int pageNumber = 1) => $"Requesting page {pageNumber}";

app.MapGet("/products2", ListProducts);

app.Run();
URI (Identificatore Uniforme delle Risorse) risultato
/products?pageNumber=3 3 restituiti
/products 1 restituito
/products2 1 restituito

L'impostazione precedente relativa al valore nullo e al valore predefinito si applica a tutte le origini:

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

app.MapPost("/products", (Product? product) => { });

app.Run();

Il codice precedente chiama il metodo con un prodotto null se non viene inviato alcun contenuto nel corpo della richiesta.

NOTA: Se vengono forniti dati non validi e il parametro può assumere il valore null, il gestore della route non viene eseguito.

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

app.MapGet("/products", (int? pageNumber) => $"Requesting page {pageNumber ?? 1}");

app.Run();
URI (Identificatore Uniforme delle Risorse) risultato
/products?pageNumber=3 3 restituito
/products 1 restituito
/products?pageNumber=two BadHttpRequestException: impossibile associare il parametro "Nullable<int> pageNumber" da "two".
/products/two Errore HTTP 404, nessuna route corrispondente

Per altre informazioni, vedere la sezione Errori di binding .

Tipi speciali

I tipi seguenti sono associati senza attributi espliciti:

  • HttpContext: contesto che contiene tutte le informazioni sulla richiesta o la risposta HTTP corrente:

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequest e HttpResponse: la richiesta HTTP e la risposta HTTP:

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken: token di annullamento associato alla richiesta HTTP corrente:

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal: L'utente associato alla richiesta, vincolato da HttpContext.User:

    app.MapGet("/", (ClaimsPrincipal user) => user.Identity.Name);
    

Associa il corpo della richiesta come Stream o PipeReader

Il corpo della richiesta può essere associato come Stream o PipeReader per supportare in modo efficiente gli scenari in cui l'utente deve elaborare dati e:

  • Archiviare i dati nell'archiviazione BLOB o inserire i dati in una coda presso un provider di code.
  • Elaborare i dati archiviati con un processo di lavoro o una funzione cloud.

Ad esempio, i dati potrebbero essere accodati in Archiviazione code di Azure o archiviati in Archiviazione BLOB di Azure.

Il codice seguente implementa una coda in background:

using System.Text.Json;
using System.Threading.Channels;

namespace BackgroundQueueService;

class BackgroundQueue : BackgroundService
{
    private readonly Channel<ReadOnlyMemory<byte>> _queue;
    private readonly ILogger<BackgroundQueue> _logger;

    public BackgroundQueue(Channel<ReadOnlyMemory<byte>> queue,
                               ILogger<BackgroundQueue> logger)
    {
        _queue = queue;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var dataStream in _queue.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                var person = JsonSerializer.Deserialize<Person>(dataStream.Span)!;
                _logger.LogInformation($"{person.Name} is {person.Age} " +
                                       $"years and from {person.Country}");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex.Message);
            }
        }
    }
}

class Person
{
    public string Name { get; set; } = String.Empty;
    public int Age { get; set; }
    public string Country { get; set; } = String.Empty;
}

Il codice seguente associa il corpo della richiesta a un Streamoggetto :

app.MapPost("/register", async (HttpRequest req, Stream body,
                                 Channel<ReadOnlyMemory<byte>> queue) =>
{
    if (req.ContentLength is not null && req.ContentLength > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // We're not above the message size and we have a content length, or
    // we're a chunked request and we're going to read up to the maxMessageSize + 1. 
    // We add one to the message size so that we can detect when a chunked request body
    // is bigger than our configured max.
    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);

    var buffer = new byte[readSize];

    // Read at least that many bytes from the body.
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    // We read more than the max, so this is a bad request.
    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // Attempt to send the buffer to the background queue.
    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    // We couldn't accept the message since we're overloaded.
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

Il codice seguente mostra il file completo Program.cs :

using System.Threading.Channels;
using BackgroundQueueService;

var builder = WebApplication.CreateBuilder(args);
// The max memory to use for the upload endpoint on this instance.
var maxMemory = 500 * 1024 * 1024;

// The max size of a single message, staying below the default LOH size of 85K.
var maxMessageSize = 80 * 1024;

// The max size of the queue based on those restrictions
var maxQueueSize = maxMemory / maxMessageSize;

// Create a channel to send data to the background queue.
builder.Services.AddSingleton<Channel<ReadOnlyMemory<byte>>>((_) =>
                     Channel.CreateBounded<ReadOnlyMemory<byte>>(maxQueueSize));

// Create a background queue service.
builder.Services.AddHostedService<BackgroundQueue>();
var app = builder.Build();

// curl --request POST 'https://localhost:<port>/register' --header 'Content-Type: application/json' --data-raw '{ "Name":"Samson", "Age": 23, "Country":"Nigeria" }'
// curl --request POST "https://localhost:<port>/register" --header "Content-Type: application/json" --data-raw "{ \"Name\":\"Samson\", \"Age\": 23, \"Country\":\"Nigeria\" }"
app.MapPost("/register", async (HttpRequest req, Stream body,
                                 Channel<ReadOnlyMemory<byte>> queue) =>
{
    if (req.ContentLength is not null && req.ContentLength > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // We're not above the message size and we have a content length, or
    // we're a chunked request and we're going to read up to the maxMessageSize + 1. 
    // We add one to the message size so that we can detect when a chunked request body
    // is bigger than our configured max.
    var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);

    var buffer = new byte[readSize];

    // Read at least that many bytes from the body.
    var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);

    // We read more than the max, so this is a bad request.
    if (read > maxMessageSize)
    {
        return Results.BadRequest();
    }

    // Attempt to send the buffer to the background queue.
    if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
    {
        return Results.Accepted();
    }

    // We couldn't accept the message since we're overloaded.
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});

app.Run();
  • Durante la lettura dei dati, è Stream lo stesso oggetto di HttpRequest.Body.
  • Il corpo della richiesta non viene memorizzato nel buffer per impostazione predefinita. Dopo aver letto il contenuto, non può essere riavvolto. Il flusso non può essere letto più volte.
  • e StreamPipeReader non sono utilizzabili al di fuori del gestore di azioni minimo perché i buffer sottostanti verranno eliminati o riutilizzati.

Caricamenti di file con IFormFile e IFormFileCollection

Il codice seguente usa IFormFile e IFormFileCollection per caricare il file:

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

app.MapGet("/", () => "Hello World!");

app.MapPost("/upload", async (IFormFile file) =>
{
    var tempFile = Path.GetTempFileName();
    app.Logger.LogInformation(tempFile);
    using var stream = File.OpenWrite(tempFile);
    await file.CopyToAsync(stream);
});

app.MapPost("/upload_many", async (IFormFileCollection myFiles) =>
{
    foreach (var file in myFiles)
    {
        var tempFile = Path.GetTempFileName();
        app.Logger.LogInformation(tempFile);
        using var stream = File.OpenWrite(tempFile);
        await file.CopyToAsync(stream);
    }
});

app.Run();

Le richieste di caricamento di file autenticate sono supportate tramite un'intestazione di autorizzazione, un certificato client o un'intestazionecookie.

Non è disponibile alcun supporto predefinito per antifalsificazione in ASP.NET Core in .NET 7. L'anticontraffazione è disponibile in ASP.NET Core in .NET 8 o versione successiva. Tuttavia, può essere implementata usando il IAntiforgery servizio .

Associare array e stringhe dalle intestazioni e dalle stringhe di query

Il codice seguente illustra l'associazione di stringhe di query a una matrice di tipi primitivi, matrici di stringhe e StringValues:

// Bind query string values to a primitive type array.
// GET  /tags?q=1&q=2&q=3
app.MapGet("/tags", (int[] q) =>
                      $"tag1: {q[0]} , tag2: {q[1]}, tag3: {q[2]}");

// Bind to a string array.
// GET /tags2?names=john&names=jack&names=jane
app.MapGet("/tags2", (string[] names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

// Bind to StringValues.
// GET /tags3?names=john&names=jack&names=jane
app.MapGet("/tags3", (StringValues names) =>
            $"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");

L'associazione di stringhe di query o di valori dell'intestazione a un array di tipi complessi è supportata quando il tipo implementa TryParse. Il codice seguente viene associato a una matrice di stringhe e restituisce tutti gli elementi con i tag specificati:

// GET /todoitems/tags?tags=home&tags=work
app.MapGet("/todoitems/tags", async (Tag[] tags, TodoDb db) =>
{
    return await db.Todos
        .Where(t => tags.Select(i => i.Name).Contains(t.Tag.Name))
        .ToListAsync();
});

Il codice seguente illustra il modello e l'implementazione necessaria TryParse :

public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }

    // This is an owned entity. 
    public Tag Tag { get; set; } = new();
}

[Owned]
public class Tag
{
    public string? Name { get; set; } = "n/a";

    public static bool TryParse(string? name, out Tag tag)
    {
        if (name is null)
        {
            tag = default!;
            return false;
        }

        tag = new Tag { Name = name };
        return true;
    }
}

Il codice seguente viene associato a una int matrice:

// GET /todoitems/query-string-ids?ids=1&ids=3
app.MapGet("/todoitems/query-string-ids", async (int[] ids, TodoDb db) =>
{
    return await db.Todos
        .Where(t => ids.Contains(t.Id))
        .ToListAsync();
});

Per testare il codice precedente, aggiungere l'endpoint seguente per popolare il database con Todo elementi:

// POST /todoitems/batch
app.MapPost("/todoitems/batch", async (Todo[] todos, TodoDb db) =>
{
    await db.Todos.AddRangeAsync(todos);
    await db.SaveChangesAsync();

    return Results.Ok(todos);
});

Usare uno strumento di test api come HttpRepl per passare i dati seguenti all'endpoint precedente:

[
    {
        "id": 1,
        "name": "Have Breakfast",
        "isComplete": true,
        "tag": {
            "name": "home"
        }
    },
    {
        "id": 2,
        "name": "Have Lunch",
        "isComplete": true,
        "tag": {
            "name": "work"
        }
    },
    {
        "id": 3,
        "name": "Have Supper",
        "isComplete": true,
        "tag": {
            "name": "home"
        }
    },
    {
        "id": 4,
        "name": "Have Snacks",
        "isComplete": true,
        "tag": {
            "name": "N/A"
        }
    }
]

Il codice seguente si associa alla chiave dell'intestazione X-Todo-Id e restituisce gli elementi Todo con valori Id corrispondenti:

// GET /todoitems/header-ids
// The keys of the headers should all be X-Todo-Id with different values
app.MapGet("/todoitems/header-ids", async ([FromHeader(Name = "X-Todo-Id")] int[] ids, TodoDb db) =>
{
    return await db.Todos
        .Where(t => ids.Contains(t.Id))
        .ToListAsync();
});

Nota

Quando si associa un string[] non nullable da una stringa di query, l'assenza di qualsiasi valore della stringa di query corrispondente restituisce un array vuoto anziché null. Se il parametro è dichiarato come nullable (ad esempio, string[]?) o ha un valore predefinito di null (ad esempio, [FromQuery] string[]? names = null), omettere il parametro di query comporta null.

Associazione di parametri per gli elenchi di argomenti con [AsParameters]

AsParametersAttribute consente l'associazione di parametri semplice ai tipi e non l'associazione di modelli complessi o ricorsivi.

Si consideri il seguente codice :

using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.Select(x => new TodoItemDTO(x)).ToListAsync());

app.MapGet("/todoitems/{id}",
                             async (int Id, TodoDb Db) =>
    await Db.Todos.FindAsync(Id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());
// Remaining code removed for brevity.

Si consideri l'endpoint seguente GET :

app.MapGet("/todoitems/{id}",
                             async (int Id, TodoDb Db) =>
    await Db.Todos.FindAsync(Id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());

Per sostituire i parametri evidenziati precedenti, è possibile usare quanto segue struct :

struct TodoItemRequest
{
    public int Id { get; set; }
    public TodoDb Db { get; set; }
}

L'endpoint rifattorizzato GET utilizza il precedente struct con l'attributo AsParameters:

app.MapGet("/ap/todoitems/{id}",
                                async ([AsParameters] TodoItemRequest request) =>
    await request.Db.Todos.FindAsync(request.Id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());

Il codice seguente mostra altri endpoint nell'app:

app.MapPost("/todoitems", async (TodoItemDTO Dto, TodoDb Db) =>
{
    var todoItem = new Todo
    {
        IsComplete = Dto.IsComplete,
        Name = Dto.Name
    };

    Db.Todos.Add(todoItem);
    await Db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/todoitems/{id}", async (int Id, TodoItemDTO Dto, TodoDb Db) =>
{
    var todo = await Db.Todos.FindAsync(Id);

    if (todo is null) return Results.NotFound();

    todo.Name = Dto.Name;
    todo.IsComplete = Dto.IsComplete;

    await Db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int Id, TodoDb Db) =>
{
    if (await Db.Todos.FindAsync(Id) is Todo todo)
    {
        Db.Todos.Remove(todo);
        await Db.SaveChangesAsync();
        return Results.Ok(new TodoItemDTO(todo));
    }

    return Results.NotFound();
});

Per effettuare il refactoring degli elenchi di parametri vengono usate le classi seguenti:

class CreateTodoItemRequest
{
    public TodoItemDTO Dto { get; set; } = default!;
    public TodoDb Db { get; set; } = default!;
}

class EditTodoItemRequest
{
    public int Id { get; set; }
    public TodoItemDTO Dto { get; set; } = default!;
    public TodoDb Db { get; set; } = default!;
}

Il codice seguente mostra gli endpoint refattorizzati che usano AsParameters e il precedente struct e le classi:

app.MapPost("/ap/todoitems", async ([AsParameters] CreateTodoItemRequest request) =>
{
    var todoItem = new Todo
    {
        IsComplete = request.Dto.IsComplete,
        Name = request.Dto.Name
    };

    request.Db.Todos.Add(todoItem);
    await request.Db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/ap/todoitems/{id}", async ([AsParameters] EditTodoItemRequest request) =>
{
    var todo = await request.Db.Todos.FindAsync(request.Id);

    if (todo is null) return Results.NotFound();

    todo.Name = request.Dto.Name;
    todo.IsComplete = request.Dto.IsComplete;

    await request.Db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/ap/todoitems/{id}", async ([AsParameters] TodoItemRequest request) =>
{
    if (await request.Db.Todos.FindAsync(request.Id) is Todo todo)
    {
        request.Db.Todos.Remove(todo);
        await request.Db.SaveChangesAsync();
        return Results.Ok(new TodoItemDTO(todo));
    }

    return Results.NotFound();
});

Per sostituire i parametri precedenti, è possibile usare i tipi seguenti record :

record TodoItemRequest(int Id, TodoDb Db);
record CreateTodoItemRequest(TodoItemDTO Dto, TodoDb Db);
record EditTodoItemRequest(int Id, TodoItemDTO Dto, TodoDb Db);

L'uso di un struct con AsParameters può essere più performante rispetto all'uso di un tipo record.

Codice di esempio completo nel repository AspNetCore.Docs.Samples .

Binding personalizzato

Esistono tre modi per personalizzare l'associazione di parametri:

  1. Per le origini di binding di route, query e header, eseguire il binding di tipi personalizzati aggiungendo al tipo un metodo statico TryParse.
  2. Controllare il processo di associazione implementando un BindAsync metodo su un tipo.
  3. Per gli scenari avanzati, implementare l'interfaccia IBindableFromHttpContext<TSelf> per fornire logica di associazione personalizzata direttamente da HttpContext.

TryParse

TryParse dispone di due API:

public static bool TryParse(string value, out T result);
public static bool TryParse(string value, IFormatProvider provider, out T result);

Il codice seguente viene visualizzato Point: 12.3, 10.1 con l'URI /map?Point=12.3,10.1:

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

// GET /map?Point=12.3,10.1
app.MapGet("/map", (Point point) => $"Point: {point.X}, {point.Y}");

app.Run();

public class Point
{
    public double X { get; set; }
    public double Y { get; set; }

    public static bool TryParse(string? value, IFormatProvider? provider,
                                out Point? point)
    {
        // Format is "(12.3,10.1)"
        var trimmedValue = value?.TrimStart('(').TrimEnd(')');
        var segments = trimmedValue?.Split(',',
                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (segments?.Length == 2
            && double.TryParse(segments[0], out var x)
            && double.TryParse(segments[1], out var y))
        {
            point = new Point { X = x, Y = y };
            return true;
        }

        point = null;
        return false;
    }
}

BindAsync

BindAsync include le API seguenti:

public static ValueTask<T?> BindAsync(HttpContext context, ParameterInfo parameter);
public static ValueTask<T?> BindAsync(HttpContext context);

Il codice seguente viene visualizzato SortBy:xyz, SortDirection:Desc, CurrentPage:99 con l'URI /products?SortBy=xyz&SortDir=Desc&Page=99:

using System.Reflection;

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

// GET /products?SortBy=xyz&SortDir=Desc&Page=99
app.MapGet("/products", (PagingData pageData) => $"SortBy:{pageData.SortBy}, " +
       $"SortDirection:{pageData.SortDirection}, CurrentPage:{pageData.CurrentPage}");

app.Run();

public class PagingData
{
    public string? SortBy { get; init; }
    public SortDirection SortDirection { get; init; }
    public int CurrentPage { get; init; } = 1;

    public static ValueTask<PagingData?> BindAsync(HttpContext context,
                                                   ParameterInfo parameter)
    {
        const string sortByKey = "sortBy";
        const string sortDirectionKey = "sortDir";
        const string currentPageKey = "page";

        Enum.TryParse<SortDirection>(context.Request.Query[sortDirectionKey],
                                     ignoreCase: true, out var sortDirection);
        int.TryParse(context.Request.Query[currentPageKey], out var page);
        page = page == 0 ? 1 : page;

        var result = new PagingData
        {
            SortBy = context.Request.Query[sortByKey],
            SortDirection = sortDirection,
            CurrentPage = page
        };

        return ValueTask.FromResult<PagingData?>(result);
    }
}

public enum SortDirection
{
    Default,
    Asc,
    Desc
}

Associazione di parametri personalizzata con IBindableFromHttpContext

ASP.NET Core fornisce il supporto per l'associazione di parametri personalizzati nelle API minime usando l'interfaccia IBindableFromHttpContext<TSelf> . Questa interfaccia, introdotta con i membri astratti statici di C# 11, consente di creare tipi che possono essere associati da un contesto HTTP direttamente nei parametri del gestore di route.

public interface IBindableFromHttpContext<TSelf>
    where TSelf : class, IBindableFromHttpContext<TSelf>
{
    static abstract ValueTask<TSelf?> BindAsync(HttpContext context, ParameterInfo parameter);
}

Implementando l'interfaccia IBindableFromHttpContext<TSelf> , è possibile creare tipi personalizzati che gestiscono la propria logica di associazione da HttpContext. Quando un gestore di route include un parametro di questo tipo, il framework chiama automaticamente il metodo BindAsync statico per creare l'istanza:

using CustomBindingExample;

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

app.UseHttpsRedirection();

app.MapGet("/", () => "Hello, IBindableFromHttpContext example!");

app.MapGet("/custom-binding", (CustomBoundParameter param) =>
{
    return $"Value from custom binding: {param.Value}";
});

app.MapGet("/combined/{id}", (int id, CustomBoundParameter param) =>
{
    return $"ID: {id}, Custom Value: {param.Value}";
});

Di seguito è riportato un esempio di implementazione di un parametro personalizzato che viene associato da un'intestazione HTTP:

using System.Reflection;

namespace CustomBindingExample;

public class CustomBoundParameter : IBindableFromHttpContext<CustomBoundParameter>
{
    public string Value { get; init; } = default!;

    public static ValueTask<CustomBoundParameter?> BindAsync(HttpContext context, ParameterInfo parameter)
    {
        // Custom binding logic here
        // This example reads from a custom header
        var value = context.Request.Headers["X-Custom-Header"].ToString();
        
        // If no header was provided, you could fall back to a query parameter
        if (string.IsNullOrEmpty(value))
        {
            value = context.Request.Query["customValue"].ToString();
        }
        
        return ValueTask.FromResult<CustomBoundParameter?>(new CustomBoundParameter 
        {
            Value = value
        });
    }
}

È anche possibile implementare la convalida all'interno della logica di associazione personalizzata:

app.MapGet("/validated", (ValidatedParameter param) =>
{
    if (string.IsNullOrEmpty(param.Value))
    {
        return Results.BadRequest("Value cannot be empty");
    }
    
    return Results.Ok($"Validated value: {param.Value}");
});

Visualizzare o scaricare il codice di esempio (come scaricare)

Errori di binding

Quando l'associazione non riesce, il framework registra un messaggio di debug e restituisce vari codici di stato al client a seconda della modalità di errore.

Modalità di guasto Tipo di parametro annullabile Origine del binding Codice di stato
{ParameterType}.TryParse restituisce false Sì route/query/intestazione 400
{ParameterType}.BindAsync restituisce null Sì personalizzato 400
{ParameterType}.BindAsync genera non importa personalizzato 500
Impossibile deserializzare il corpo JSON non importa corpo 400
Tipo di contenuto errato (non application/json) non importa corpo 415

Precedenza di binding

Regole per determinare un'origine di associazione da un parametro:

  1. Attributo esplicito definito per il parametro (attributi From*) nell'ordine seguente:
    1. Valori di route: [FromRoute]
    2. Stringa di query: [FromQuery]
    3. Intestazione: [FromHeader]
    4. Corpo del testo: [FromBody]
    5. Servizio: [FromServices]
    6. Valori dei parametri: [AsParameters]
  2. Tipi speciali
    1. HttpContext
    2. HttpRequest (HttpContext.Request)
    3. HttpResponse (HttpContext.Response)
    4. ClaimsPrincipal (HttpContext.User)
    5. CancellationToken (HttpContext.RequestAborted)
    6. IFormFileCollection (HttpContext.Request.Form.Files)
    7. IFormFile (HttpContext.Request.Form.Files[paramName])
    8. Stream (HttpContext.Request.Body)
    9. PipeReader (HttpContext.Request.BodyReader)
  3. Il tipo di parametro ha un metodo statico BindAsync valido.
  4. Il tipo di parametro è una stringa o ha un metodo statico TryParse valido.
    1. Se il nome del parametro esiste nel modello di route. In app.Map("/todo/{id}", (int id) => {});, id viene associato tramite la route.
    2. Associato dalla stringa di query.
  5. Se il tipo di parametro è un servizio fornito dall'iniezione delle dipendenze, usa tale servizio come origine.
  6. Il parametro proviene dal corpo.

Configurare le opzioni di deserializzazione JSON per l'associazione del corpo

L'origine di associazione del corpo usa System.Text.Json per la deserializzazione. Non è possibile modificare questa impostazione predefinita, ma è possibile configurare le opzioni di serializzazione e deserializzazione JSON.

Configurare le opzioni di deserializzazione JSON a livello globale

Le opzioni applicabili a livello globale per un'app possono essere configurate richiamando ConfigureHttpJsonOptions. L'esempio seguente include campi pubblici e formatta l'output JSON.

var builder = WebApplication.CreateBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options => {
    options.SerializerOptions.WriteIndented = true;
    options.SerializerOptions.IncludeFields = true;
});

var app = builder.Build();

app.MapPost("/", (Todo todo) => {
    if (todo is not null) {
        todo.Name = todo.NameField;
    }
    return todo;
});

app.Run();

class Todo {
    public string? Name { get; set; }
    public string? NameField;
    public bool IsComplete { get; set; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "nameField":"Walk dog",
//    "isComplete":false
// }

Poiché il codice di esempio configura sia la serializzazione che la deserializzazione, può leggere NameField e includere NameField nel codice JSON di output.

Configurare le opzioni di deserializzazione JSON per un endpoint

ReadFromJsonAsync dispone di overload che accettano un JsonSerializerOptions oggetto . L'esempio seguente include campi pubblici e formatta l'output JSON.

using System.Text.Json;

var app = WebApplication.Create();

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { 
    IncludeFields = true, 
    WriteIndented = true
};

app.MapPost("/", async (HttpContext context) => {
    if (context.Request.HasJsonContentType()) {
        var todo = await context.Request.ReadFromJsonAsync<Todo>(options);
        if (todo is not null) {
            todo.Name = todo.NameField;
        }
        return Results.Ok(todo);
    }
    else {
        return Results.BadRequest();
    }
});

app.Run();

class Todo
{
    public string? Name { get; set; }
    public string? NameField;
    public bool IsComplete { get; set; }
}
// If the request body contains the following JSON:
//
// {"nameField":"Walk dog", "isComplete":false}
//
// The endpoint returns the following JSON:
//
// {
//    "name":"Walk dog",
//    "isComplete":false
// }

Poiché il codice precedente applica le opzioni personalizzate solo alla deserializzazione, il codice JSON di output esclude NameField.

Leggere il corpo della richiesta

Leggere il corpo della richiesta direttamente usando un HttpContext parametro o HttpRequest :

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

app.MapPost("/uploadstream", async (IConfiguration config, HttpRequest request) =>
{
    var filePath = Path.Combine(config["StoredFilesPath"], Path.GetRandomFileName());

    await using var writeStream = File.Create(filePath);
    await request.BodyReader.CopyToAsync(writeStream);
});

app.Run();

Il codice precedente:

  • Accede al corpo della richiesta usando HttpRequest.BodyReader.
  • Copia il corpo della richiesta in un file locale.