Share via


Liaison de paramètres dans les applications API minimales

La liaison de paramètres est le processus de conversion des données de requête en paramètres fortement typés qui sont exprimés par les gestionnaires de routes. Une source de liaison détermine à partir d’où les paramètres sont liés. Les sources de liaison peuvent être explicites ou déduites en fonction de la méthode HTTP et du type de paramètre.

Sources de liaison prises en charge :

  • Valeurs d’itinéraire
  • Chaîne de requête
  • Header
  • Corps (JSON)
  • Valeurs du formulaire
  • Services fournis par l’injection de dépendances
  • Personnalisé

Le gestionnaire de routage GET suivant utilise certaines de ces sources de liaison de paramètres :

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 { }

Le tableau suivant montre la relation entre les paramètres utilisés dans l’exemple précédent et les sources de liaison associées.

Paramètre Source de liaison
id valeur de route
page chaîne de requête
customHeader en-tête
service Fourni par l’injection de dépendances

Les méthodes HTTP GET, HEAD, OPTIONS et DELETE ne sont pas implicitement liées à partir du corps. Pour lier à partir du corps (JSON) pour ces méthodes HTTP, liez explicitement avec [FromBody] ou lisez à partir de HttpRequest.

L’exemple de gestionnaire de routage POST suivant utilise une source de liaison de corps (JSON) pour le paramètre person :

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

record Person(string Name, int Age);

Les paramètres des exemples précédents sont tous liés automatiquement à partir des données de requête. Pour illustrer la commodité de la liaison de paramètres, les gestionnaires de routage suivants montrent comment lire les données de requête directement à partir de la requête :

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

    // ...
});

Liaison de paramètre explicite

Les attributs peuvent être utilisés pour déclarer explicitement à partir d’où les paramètres sont liés.

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);
Paramètre Source de liaison
id valeur de routage avec le nom id
page chaîne de requête avec le nom "p"
service Fourni par l’injection de dépendances
contentType en-tête avec le nom "Content-Type"

Liaison explicite à partir des valeurs de formulaire

L’attribut [FromForm] lie les valeurs de formulaire :

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.

Une alternative consiste à utiliser l’attribut [AsParameters] avec un type personnalisé qui a des propriétés annotées avec [FromForm]. Par exemple, le code suivant lie des valeurs de formulaire aux propriétés du struct d’enregistrement 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);

Pour plus d'informations, voir la section AsParameters plus loin dans cet article.

L’exemple de code complet dans le référentiel AspNetCore.Docs.Samples.

Sécurisez la liaison à partir d’IFormFile et d’IFormFileCollection

La liaison de formulaire complexe est prise en charge à l’aide de IFormFile et IFormFileCollection à l’aide de [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) =>
{
    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();

Les paramètres liés à la requête avec [FromForm] incluent un jeton anti-falsification. Le jeton anti-falsification est validé lors du traitement de la requête. Pour plus d’informations, consultez Anti-falsification avec des API minimales.

Pour plus d’informations, consultez Liaison de formulaire dans les API minimales.

L’exemple de code complet dans le référentiel AspNetCore.Docs.Samples.

Liaison de paramètre avec injection de dépendances

La liaison de paramètre pour les API minimales lie des paramètres via l’injection de dépendance quand le type est configuré en tant que service. Il n’est pas nécessaire d’appliquer explicitement l’attribut [FromServices] à un paramètre. Dans le code suivant, les deux actions retournent l’heure :

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

Paramètres optionnels

Les paramètres déclarés dans les gestionnaires d’itinéraire sont traités comme requis :

  • Si une requête correspond à l’itinéraire, le gestionnaire d’itinéraire s’exécute uniquement si tous les paramètres requis sont fournis dans la requête.
  • Le fait de ne pas fournir tous les paramètres requis entraîne une erreur.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.Run();
URI result
/products?pageNumber=3 3 retournés
/products BadHttpRequestException : le paramètre obligatoire « int pageNumber » n’a pas été fourni à partir de la chaîne de requête.
/products/1 Erreur HTTP 404, aucune route correspondante

Pour rendre pageNumber facultatif, définissez le type comme facultatif ou fournissez une valeur par défaut :

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 result
/products?pageNumber=3 3 retournés
/products 1 retourné
/products2 1 retourné

La valeur nullable et la valeur par défaut précédentes s’appliquent à toutes les sources :

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

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

app.Run();

Le code précédent appelle la méthode avec un produit null si aucun corps de requête n’est envoyé.

REMARQUE : Si des données non valides sont fournies et que le paramètre est nullable, le gestionnaire de routage n’est pas exécuté.

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

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

app.Run();
URI result
/products?pageNumber=3 3 retourné
/products 1 retourné
/products?pageNumber=two BadHttpRequestException : Échec de la liaison du paramètre "Nullable<int> pageNumber" à partir de « two ».
/products/two Erreur HTTP 404, aucune route correspondante

Pour plus d’informations, consultez la section Échecs de liaison.

Types spéciaux

Les types suivants sont liés sans attributs explicites :

  • HttpContext : contexte qui contient toutes les informations sur la requête ou la réponse HTTP actuelle :

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequest et HttpResponse : requête HTTP et réponse HTTP :

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken : jeton d’annulation associé à la requête HTTP actuelle :

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal : utilisateur associé à la requête, lié à partir de HttpContext.User :

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

Lier le corps de la requête en tant que Stream ou PipeReader

Le corps de la requête peut être lié en tant que Stream ou PipeReader pour prendre en charge efficacement les scénarios où l’utilisateur doit traiter des données et :

  • Stockez les données dans le stockage d’objets blob ou placez les données en file d’attente dans un fournisseur de file d’attente.
  • Traitez les données stockées avec un processus Worker ou une fonction cloud.

Par exemple, les données peuvent être mises en file d’attente pour le Stockage File d’attente Azure ou stockées dans le Stockage Blob Azure.

Le code suivant implémente une file d’attente en arrière-plan :

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

Le code suivant lie le corps de la requête à un Stream :

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);
});

Le code suivant montre l’intégralité du fichier 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();
  • Lors de la lecture de données, le Stream est le même objet que HttpRequest.Body.
  • Le corps de la requête n’est pas mis en mémoire tampon par défaut. Une fois le corps lu, il n’est pas rembobinable. Le flux ne peut pas être lu plusieurs fois.
  • Les Stream et PipeReader ne sont pas utilisables en dehors du gestionnaire d’actions minimal, car les mémoires tampons sous-jacentes seront supprimées ou réutilisées.

Chargements de fichiers à l’aide d’IFormFile et IFormFileCollection

Le code suivant utilise IFormFile et IFormFileCollection pour charger le fichier :

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

Les requêtes de chargement de fichiers authentifiés sont prises en charge à l’aide d’un en-tête d’autorisation, d’un certificat client ou d’un en-tête cookie.

Liaison aux formulaires avec IFormCollection, IFormFile et IFormFileCollection

La liaison à partir de paramètres basés sur un formulaire à l’aide de IFormCollection, de IFormFileet de IFormFileCollection est prise en charge. Les métadonnées OpenAPI sont inférées pour les paramètres de formulaire afin de prendre en charge l’intégration à l’interface utilisateur Swagger.

Le code suivant télécharge des fichiers en utilisant la liaison déduite du type IFormFile :

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

Avertissement : lors de l’implémentation de formulaires, l’application doit empêcher lesattaques par falsification de requête intersites (XSRF/CSRF). Dans le code précédent, le service IAntiforgery est utilisé pour prévenir les attaques XSRF en générant et en validant un jeton anti-falsification :

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

Pour plus d'informations sur les attaques XSRF, consultez Antiforgerie avec des API minimales

Pour plus d’informations, consultez l’article Liaison de formulaire dans des API minimales;

Liaison à des collections et à des types complexes à partir de formulaires

La liaison est prise en charge pour :

  • Les collections, par exemple Liste et Dictionnaire
  • Les types complexes, par exemple, Todo ou Project

L'exemple de code suivant montre :

  • Un point de terminaison minimal qui lie une entrée de formulaire à plusieurs parties à un objet complexe.
  • Comment utiliser les services anti-falsification pour prendre en charge la génération et la validation des jetons anti-falsification.
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 anti-forgery 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));
}

Dans le code précédent :

  • Le paramètre cible doit être annoté avec l’attribut [FromForm] pour lever l’ambiguïté des paramètres qui doivent être lus à partir du corps JSON.
  • La liaison à partir de types complexes ou de collections n'est pas prise en charge pour les API minimales compilées avec le générateur de délégués de requête.
  • Le balisage présente une entrée masquée supplémentaire nommée isCompleted dont la valeur est false. Si la case isCompleted est cochée lorsque le formulaire est envoyé, les deux valeurs true et false sont envoyées en tant que valeurs. Si la case à cocher est décochée, seule la valeur d’entrée masquée false est envoyée. Le processus de liaison de modèle ASP.NET Core lit uniquement la première valeur lors de la liaison à une valeur bool, ce qui donne true pour les cases à cocher cochées et false pour les cases à cocher non cochées.

Voici un exemple des données de formulaire soumises au point de terminaison précédent :

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

Lier des tableaux et des valeurs de chaîne à partir d’en-têtes et de chaînes de requête

Le code suivant illustre la liaison de chaînes de requête à un tableau de types primitifs, de tableaux de chaînes et de 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]}");

La liaison de chaînes de requête ou de valeurs d’en-tête à un tableau de types complexes est prise en charge lorsque le type implémente TryParse. Le code suivant est lié à un tableau de chaînes et retourne tous les éléments avec les balises spécifiées :

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

Le code suivant montre le modèle et l’implémentation TryParse requise :

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

Le code suivant lie un tableau int :

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

Pour tester le code précédent, ajoutez le point de terminaison suivant pour remplir la base de données avec des éléments Todo :

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

    return Results.Ok(todos);
});

Utilisez un outil comme HttpRepl pour passer les données suivantes au point de terminaison précédent :

[
    {
        "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"
        }
    }
]

Le code suivant est lié à la clé d’en-tête X-Todo-Id et retourne les éléments Todo avec des valeurs Id correspondantes :

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

Remarque

Lors de la liaison d’un string[] à partir d’une chaîne de requête, l’absence d’une valeur de chaîne de requête correspondante entraîne un tableau vide au lieu d’une valeur Null.

Liaison de paramètres pour les listes d’arguments avec [AsParameters]

AsParametersAttribute active la liaison de paramètres simples aux types et non la liaison de modèle complexe ou récursive.

Examinons le code ci-dessous.

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.

Considérez le point de terminaison GET suivant :

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

Le struct suivant peut être utilisé pour remplacer les paramètres mis en évidence précédents :

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

Le point de terminaison GET refactorisé utilise le struct précédent avec l’attribut 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());

Le code suivant montre des points de terminaison supplémentaires dans l’application :

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();
});

Les classes suivantes sont utilisées pour refactoriser les listes de paramètres :

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!;
}

Le code suivant montre les points de terminaison refactorisés avec AsParameters et le struct et les classes qui précèdent :

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();
});

Les types record suivants peuvent être utilisés pour remplacer les paramètres précédents :

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

L’utilisation de struct avec AsParameters peut être plus performante que l’utilisation d’un type record.

L’exemple de code complet dans le référentiel AspNetCore.Docs.Samples.

Liaison personnalisée

Il existe deux façons de personnaliser la liaison de paramètres :

  1. Pour les sources de liaison de routage, de requête et d’en-tête, liez des types personnalisés en ajoutant une méthode statique TryParse pour le type.
  2. Contrôlez le processus de liaison en implémentant une méthode BindAsync sur un type.

TryParse

TryParse a deux API :

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

Le code suivant affiche Point: 12.3, 10.1 avec 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 possède les API suivantes :

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

Le code suivant affiche SortBy:xyz, SortDirection:Desc, CurrentPage:99 avec 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
}

Échecs de liaison

En cas d’échec de la liaison, le framework enregistre un message de débogage et retourne différents codes d’état au client en fonction du mode d’échec.

Mode d’échec Type de paramètre nullable Source de liaison Code d’état
{ParameterType}.TryParse retourne « false » oui itinéraire/requête/en-tête 400
{ParameterType}.BindAsync retourne « null » oui personnalisé 400
Exceptions {ParameterType}.BindAsync n’a pas d’importance custom 500
Échec de la désérialisation du corps JSON n’a pas d’importance corps 400
Type de contenu incorrect (pas application/json) n’a pas d’importance corps 415

Priorité de liaison

Règles permettant de déterminer une source de liaison à partir d’un paramètre :

  1. Attribut explicite défini sur le paramètre (attributs From*) dans l’ordre suivant :
    1. Valeurs d’itinéraire : [FromRoute]
    2. Chaîne de requête : [FromQuery]
    3. En-tête : [FromHeader]
    4. Corps : [FromBody]
    5. Formulaire : [FromForm]
    6. Service : [FromServices]
    7. Valeurs du paramètre : [AsParameters]
  2. Types spéciaux
    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. Le type de paramètre a une méthode BindAsync statique valide.
  4. Le type de paramètre est une chaîne ou a une méthode TryParse statique valide.
    1. Si le nom du paramètre existe dans le modèle d’itinéraire, par exemple, app.Map("/todo/{id}", (int id) => {});, il est lié à partir de l’itinéraire.
    2. Lié à partir de la chaîne de requête.
  5. Si le type de paramètre est un service fourni par l’injection de dépendances, il utilise ce service comme source.
  6. Le paramètre provient du corps.

Configurer les options de désérialisation JSON pour la liaison de corps

La source de liaison de corps utilise System.Text.Json pour la désérialisation. Il n’est pas possible de modifier cette valeur par défaut, mais les options de sérialisation et de désérialisation JSON peuvent être configurées.

Configurer globalement les options de désérialisation JSON

Les options qui s’appliquent globalement à une application peuvent être configurées en appelant ConfigureHttpJsonOptions. L’exemple suivant inclut des champs publics et des formats de sortie 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
// }

Étant donné que l’exemple de code configure à la fois la sérialisation et la désérialisation, il peut lire NameField et inclure NameField dans la sortie JSON.

Configurer les options de désérialisation JSON pour un point de terminaison

ReadFromJsonAsync a des surcharges qui acceptent un objet JsonSerializerOptions. L’exemple suivant inclut des champs publics et des formats de sortie 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
// }

Étant donné que le code précédent applique les options personnalisées uniquement à la désérialisation, la sortie JSON exclut NameField.

Lire le corps de la requête

Lisez le corps de la requête directement à l’aide d’un paramètre HttpContext ou 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();

Le code précédent :

  • Accède au corps de la requête à l’aide de HttpRequest.BodyReader.
  • Copie le corps de la requête dans un fichier local.

La liaison de paramètres est le processus de conversion des données de requête en paramètres fortement typés qui sont exprimés par les gestionnaires de routes. Une source de liaison détermine à partir d’où les paramètres sont liés. Les sources de liaison peuvent être explicites ou déduites en fonction de la méthode HTTP et du type de paramètre.

Sources de liaison prises en charge :

  • Valeurs d’itinéraire
  • Chaîne de requête
  • Header
  • Corps (JSON)
  • Services fournis par l’injection de dépendances
  • Personnalisé

La liaison à partir de valeurs de formulaire n’est pas prise en charge en mode natif dans .NET 6 et 7.

Le gestionnaire de routage GET suivant utilise certaines de ces sources de liaison de paramètres :

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 { }

Le tableau suivant montre la relation entre les paramètres utilisés dans l’exemple précédent et les sources de liaison associées.

Paramètre Source de liaison
id valeur de route
page chaîne de requête
customHeader en-tête
service Fourni par l’injection de dépendances

Les méthodes HTTP GET, HEAD, OPTIONS et DELETE ne sont pas implicitement liées à partir du corps. Pour lier à partir du corps (JSON) pour ces méthodes HTTP, liez explicitement avec [FromBody] ou lisez à partir de HttpRequest.

L’exemple de gestionnaire de routage POST suivant utilise une source de liaison de corps (JSON) pour le paramètre person :

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

record Person(string Name, int Age);

Les paramètres des exemples précédents sont tous liés automatiquement à partir des données de requête. Pour illustrer la commodité de la liaison de paramètres, les gestionnaires de routage suivants montrent comment lire les données de requête directement à partir de la requête :

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

    // ...
});

Liaison de paramètre explicite

Les attributs peuvent être utilisés pour déclarer explicitement à partir d’où les paramètres sont liés.

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);
Paramètre Source de liaison
id valeur de routage avec le nom id
page chaîne de requête avec le nom "p"
service Fourni par l’injection de dépendances
contentType en-tête avec le nom "Content-Type"

Remarque

La liaison à partir de valeurs de formulaire n’est pas prise en charge en mode natif dans .NET 6 et 7.

Liaison de paramètre avec injection de dépendances

La liaison de paramètre pour les API minimales lie des paramètres via l’injection de dépendance quand le type est configuré en tant que service. Il n’est pas nécessaire d’appliquer explicitement l’attribut [FromServices] à un paramètre. Dans le code suivant, les deux actions retournent l’heure :

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

Paramètres optionnels

Les paramètres déclarés dans les gestionnaires d’itinéraire sont traités comme requis :

  • Si une requête correspond à l’itinéraire, le gestionnaire d’itinéraire s’exécute uniquement si tous les paramètres requis sont fournis dans la requête.
  • Le fait de ne pas fournir tous les paramètres requis entraîne une erreur.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.Run();
URI result
/products?pageNumber=3 3 retournés
/products BadHttpRequestException : le paramètre obligatoire « int pageNumber » n’a pas été fourni à partir de la chaîne de requête.
/products/1 Erreur HTTP 404, aucune route correspondante

Pour rendre pageNumber facultatif, définissez le type comme facultatif ou fournissez une valeur par défaut :

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 result
/products?pageNumber=3 3 retournés
/products 1 retourné
/products2 1 retourné

La valeur nullable et la valeur par défaut précédentes s’appliquent à toutes les sources :

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

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

app.Run();

Le code précédent appelle la méthode avec un produit null si aucun corps de requête n’est envoyé.

REMARQUE : Si des données non valides sont fournies et que le paramètre est nullable, le gestionnaire de routage n’est pas exécuté.

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

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

app.Run();
URI result
/products?pageNumber=3 3 retourné
/products 1 retourné
/products?pageNumber=two BadHttpRequestException : Échec de la liaison du paramètre "Nullable<int> pageNumber" à partir de « two ».
/products/two Erreur HTTP 404, aucune route correspondante

Pour plus d’informations, consultez la section Échecs de liaison.

Types spéciaux

Les types suivants sont liés sans attributs explicites :

  • HttpContext : contexte qui contient toutes les informations sur la requête ou la réponse HTTP actuelle :

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequest et HttpResponse : requête HTTP et réponse HTTP :

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken : jeton d’annulation associé à la requête HTTP actuelle :

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal : utilisateur associé à la requête, lié à partir de HttpContext.User :

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

Lier le corps de la requête en tant que Stream ou PipeReader

Le corps de la requête peut être lié en tant que Stream ou PipeReader pour prendre en charge efficacement les scénarios où l’utilisateur doit traiter des données et :

  • Stockez les données dans le stockage d’objets blob ou placez les données en file d’attente dans un fournisseur de file d’attente.
  • Traitez les données stockées avec un processus Worker ou une fonction cloud.

Par exemple, les données peuvent être mises en file d’attente pour le Stockage File d’attente Azure ou stockées dans le Stockage Blob Azure.

Le code suivant implémente une file d’attente en arrière-plan :

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

Le code suivant lie le corps de la requête à un Stream :

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);
});

Le code suivant montre l’intégralité du fichier 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();
  • Lors de la lecture de données, le Stream est le même objet que HttpRequest.Body.
  • Le corps de la requête n’est pas mis en mémoire tampon par défaut. Une fois le corps lu, il n’est pas rembobinable. Le flux ne peut pas être lu plusieurs fois.
  • Les Stream et PipeReader ne sont pas utilisables en dehors du gestionnaire d’actions minimal, car les mémoires tampons sous-jacentes seront supprimées ou réutilisées.

Chargements de fichiers à l’aide d’IFormFile et IFormFileCollection

Le code suivant utilise IFormFile et IFormFileCollection pour charger le fichier :

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

Les requêtes de chargement de fichiers authentifiés sont prises en charge à l’aide d’un en-tête d’autorisation, d’un certificat client ou d’un en-tête cookie.

Il n’existe pas de prise en charge intégrée de l’antifalsification dans ASP.NET Core 7.0. Antiforgery est disponible dans ASP.NET Core 8,0 et versions ultérieures. Toutefois, elle peut être implémentée à l’aide du service IAntiforgery.

Lier des tableaux et des valeurs de chaîne à partir d’en-têtes et de chaînes de requête

Le code suivant illustre la liaison de chaînes de requête à un tableau de types primitifs, de tableaux de chaînes et de 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]}");

La liaison de chaînes de requête ou de valeurs d’en-tête à un tableau de types complexes est prise en charge lorsque le type implémente TryParse. Le code suivant est lié à un tableau de chaînes et retourne tous les éléments avec les balises spécifiées :

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

Le code suivant montre le modèle et l’implémentation TryParse requise :

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

Le code suivant lie un tableau int :

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

Pour tester le code précédent, ajoutez le point de terminaison suivant pour remplir la base de données avec des éléments Todo :

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

    return Results.Ok(todos);
});

Utilisez un outil de test d’API comme HttpRepl pour passer les données suivantes au point de terminaison précédent :

[
    {
        "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"
        }
    }
]

Le code suivant est lié à la clé d’en-tête X-Todo-Id et retourne les éléments Todo avec des valeurs Id correspondantes :

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

Remarque

Lors de la liaison d’un string[] à partir d’une chaîne de requête, l’absence d’une valeur de chaîne de requête correspondante entraîne un tableau vide au lieu d’une valeur Null.

Liaison de paramètres pour les listes d’arguments avec [AsParameters]

AsParametersAttribute active la liaison de paramètres simples aux types et non la liaison de modèle complexe ou récursive.

Examinons le code ci-dessous.

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.

Considérez le point de terminaison GET suivant :

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

Le struct suivant peut être utilisé pour remplacer les paramètres mis en évidence précédents :

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

Le point de terminaison GET refactorisé utilise le struct précédent avec l’attribut 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());

Le code suivant montre des points de terminaison supplémentaires dans l’application :

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();
});

Les classes suivantes sont utilisées pour refactoriser les listes de paramètres :

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!;
}

Le code suivant montre les points de terminaison refactorisés avec AsParameters et le struct et les classes qui précèdent :

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();
});

Les types record suivants peuvent être utilisés pour remplacer les paramètres précédents :

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

L’utilisation de struct avec AsParameters peut être plus performante que l’utilisation d’un type record.

L’exemple de code complet dans le référentiel AspNetCore.Docs.Samples.

Liaison personnalisée

Il existe deux façons de personnaliser la liaison de paramètres :

  1. Pour les sources de liaison de routage, de requête et d’en-tête, liez des types personnalisés en ajoutant une méthode statique TryParse pour le type.
  2. Contrôlez le processus de liaison en implémentant une méthode BindAsync sur un type.

TryParse

TryParse a deux API :

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

Le code suivant affiche Point: 12.3, 10.1 avec 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 possède les API suivantes :

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

Le code suivant affiche SortBy:xyz, SortDirection:Desc, CurrentPage:99 avec 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
}

Échecs de liaison

En cas d’échec de la liaison, le framework enregistre un message de débogage et retourne différents codes d’état au client en fonction du mode d’échec.

Mode d’échec Type de paramètre nullable Source de liaison Code d’état
{ParameterType}.TryParse retourne « false » oui itinéraire/requête/en-tête 400
{ParameterType}.BindAsync retourne « null » oui personnalisé 400
Exceptions {ParameterType}.BindAsync n’a pas d’importance personnalisé 500
Échec de la désérialisation du corps JSON n’a pas d’importance body 400
Type de contenu incorrect (pas application/json) n’a pas d’importance body 415

Priorité de liaison

Règles permettant de déterminer une source de liaison à partir d’un paramètre :

  1. Attribut explicite défini sur le paramètre (attributs From*) dans l’ordre suivant :
    1. Valeurs d’itinéraire : [FromRoute]
    2. Chaîne de requête : [FromQuery]
    3. En-tête : [FromHeader]
    4. Corps : [FromBody]
    5. Service : [FromServices]
    6. Valeurs du paramètre : [AsParameters]
  2. Types spéciaux
    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. Le type de paramètre a une méthode BindAsync statique valide.
  4. Le type de paramètre est une chaîne ou a une méthode TryParse statique valide.
    1. Si le nom du paramètre existe dans le modèle d’itinéraire, par exemple app.Map("/todo/{id}", (int id) => {});, il est lié à partir de l’itinéraire.
    2. Lié à partir de la chaîne de requête.
  5. Si le type de paramètre est un service fourni par l’injection de dépendances, il utilise ce service comme source.
  6. Le paramètre provient du corps.

Configurer les options de désérialisation JSON pour la liaison de corps

La source de liaison de corps utilise System.Text.Json pour la désérialisation. Il n’est pas possible de modifier cette valeur par défaut, mais les options de sérialisation et de désérialisation JSON peuvent être configurées.

Configurer globalement les options de désérialisation JSON

Les options qui s’appliquent globalement à une application peuvent être configurées en appelant ConfigureHttpJsonOptions. L’exemple suivant inclut des champs publics et des formats de sortie 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
// }

Étant donné que l’exemple de code configure à la fois la sérialisation et la désérialisation, il peut lire NameField et inclure NameField dans la sortie JSON.

Configurer les options de désérialisation JSON pour un point de terminaison

ReadFromJsonAsync a des surcharges qui acceptent un objet JsonSerializerOptions. L’exemple suivant inclut des champs publics et des formats de sortie 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
// }

Étant donné que le code précédent applique les options personnalisées uniquement à la désérialisation, la sortie JSON exclut NameField.

Lire le corps de la requête

Lisez le corps de la requête directement à l’aide d’un paramètre HttpContext ou 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();

Le code précédent :

  • Accède au corps de la requête à l’aide de HttpRequest.BodyReader.
  • Copie le corps de la requête dans un fichier local.