Share via


Enlace de parámetros en aplicaciones de API mínimas

El enlace de parámetros es el proceso de convertir los datos de solicitud en parámetros fuertemente tipados que se expresan mediante controladores de ruta. Un origen de enlace determina desde dónde se enlazan los parámetros. Los orígenes de enlace pueden ser explícitos o inferidos en función del método HTTP y el tipo de parámetro.

Orígenes de enlace admitidos:

  • Valores de ruta
  • Cadena de consulta
  • Encabezado
  • Cuerpo (como JSON)
  • Valores de formulario
  • Servicios proporcionados por la inserción de dependencias
  • Personalizado

En el siguiente GET, el controlador de ruta usa algunos de estos orígenes de enlace de parámetros:

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

En la tabla siguiente se muestra la relación entre los parámetros utilizados en el ejemplo anterior y los orígenes de enlace asociados.

Parámetro Origen de enlace
id valor de ruta
page cadena de consulta
customHeader header
service Proporcionado por la inserción de dependencias

Los métodos HTTP GET, HEAD, OPTIONS y DELETE no se enlazan implícitamente desde el cuerpo. Para enlazar desde el cuerpo (como JSON) para estos métodos HTTP, enlace explícitamente con [FromBody] o lea desde HttpRequest.

En el siguiente ejemplo, el controlador de ruta POST usa un origen de cuerpo de enlace (como JSON) para el parámetro person:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

record Person(string Name, int Age);

Los parámetros de los ejemplos anteriores se enlazan automáticamente a partir de los datos de solicitud. Para demostrar la comodidad que proporciona el enlace de parámetros, los siguientes controladores de ruta muestran cómo leer los datos de solicitud directamente desde la solicitud:

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

    // ...
});

Enlace de parámetros explícitos

Los atributos se pueden usar para declarar explícitamente desde dónde se enlazan los parámetros.

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);
Parámetro Origen de enlace
id valor de ruta con el nombre id
page cadena de consulta con el nombre "p"
service Proporcionado por la inserción de dependencias
contentType encabezado con el nombre "Content-Type"

Enlace explícito a partir de valores de formulario

El atributo [FromForm] enlaza los valores de formulario:

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.

Una alternativa es usar el atributo [AsParameters] con un tipo personalizado que tenga propiedades anotadas con [FromForm]. Por ejemplo, el siguiente código enlaza valores de formulario a propiedades de la estructura de registro 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);

Para obtener más información, vea la sección AsParameters más adelante en este artículo.

El código de ejemplo completo está en el repositorio AspNetCore.Docs.Samples.

Enlace seguro desde IFormFile e IFormFileCollection

El enlace de formulario complejo se admite mediante IFormFile y IFormFileCollection mediante el [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();

Los parámetros enlazados a la solicitud con [FromForm] incluyen un token anti-falsificación. El token antifalsificación se valida cuando se procesa la solicitud. Para más información, consulte Antifalsificación con API mínima.

Para obtener más información, consulte Enlace de formularios en API mínimas.

El código de ejemplo completo está en el repositorio AspNetCore.Docs.Samples.

Enlace de parámetros con inserción de dependencias

El enlace de parámetros para las API mínimas enlaza parámetros mediante la inserción de dependencias cuando el tipo está configurado como servicio. No es necesario aplicar explícitamente el atributo [FromServices] a un parámetro. En el código siguiente, ambas acciones devuelven la hora:

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

Parámetros opcionales

Los parámetros declarados en controladores de ruta se tratan como obligatorios:

  • Si una solicitud coincide con la ruta, el controlador de rutas solo se ejecuta si se proporcionan todos los parámetros necesarios en la solicitud.
  • Si no se proporcionan todos los parámetros necesarios, se producirá un error.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.Run();
URI resultado
/products?pageNumber=3 3 devuelto
/products BadHttpRequestException: no se proporcionó el parámetro necesario "int pageNumber" de la cadena de consulta.
/products/1 Error HTTP 404, no se encuentra ninguna ruta coincidente

Para que pageNumber sea opcional, defina el tipo como opcional o proporcione un valor predeterminado:

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 resultado
/products?pageNumber=3 3 devuelto
/products 1 devuelto
/products2 1 devuelto

El valor predeterminado y que admite un valor NULL anterior se aplica a todos los orígenes:

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

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

app.Run();

El código anterior llama al método con un producto NULL si no se envía ningún cuerpo de la solicitud.

NOTA: Si se proporcionan datos no válidos y el parámetro admite un valor NULL, el controlador de rutas no se ejecuta.

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

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

app.Run();
URI resultado
/products?pageNumber=3 3 devuelto
/products 1 devuelto
/products?pageNumber=two BadHttpRequestException: error al enlazar el parámetro "Nullable<int> pageNumber" a partir de "two".
/products/two Error HTTP 404, no se encuentra ninguna ruta coincidente

Vea la sección Errores de enlace para más información.

Tipos especiales

Los siguientes tipos se enlazan sin atributos explícitos:

  • HttpContext: contexto que contiene toda la información sobre la solicitud o respuesta HTTP actual:

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequest y HttpResponse: solicitud HTTP y respuesta HTTP:

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken: token de cancelación asociado a la solicitud HTTP actual:

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal: usuario asociado a la solicitud, enlazado desde HttpContext.User:

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

Enlazar el cuerpo de la solicitud como Stream o PipeReader

El cuerpo de la solicitud puede enlazarse como Stream o PipeReader para admitir sin problemas los escenarios en los que el usuario tiene que procesar datos y:

  • Almacenar los datos en Blob Storage o ponerlos en cola en un proveedor de colas.
  • Procesar los datos almacenados con un proceso de trabajo o una función en la nube.

Por ejemplo, los datos pueden ponerse en cola en Azure Queue Storage o almacenarse en Azure Blob Storage.

El código siguiente implementa una cola en segundo plano:

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

El código siguiente enlaza el cuerpo de la solicitud a un objeto 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);
});

El código siguiente muestra el archivo Program.cs completo:

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();
  • Cuando se leen datos, Stream es el mismo objeto que HttpRequest.Body.
  • El cuerpo de la solicitud no se almacena en búfer de forma predeterminada. Una vez leído el cuerpo, no se puede rebobinar. La secuencia no se puede leer varias veces.
  • Stream y PipeReader no se pueden usar fuera del controlador de acciones mínimas, ya que los búferes subyacentes se eliminarán o reutilizarán.

Cargas de archivos mediante IFormFile e IFormFileCollection

El código siguiente usa IFormFile y IFormFileCollection para cargar el archivo:

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

Las solicitudes de carga de archivos autenticadas se admiten mediante un encabezado de autorización, un certificado de cliente o un cookie encabezado.

Enlace a formularios con IFormCollection, IFormFile e IFormFileCollection

Se admite el enlace desde los parámetros basados en formularios mediante IFormCollection, IFormFile y IFormFileCollection. Los metadatos de OpenAPI se deducen para que los parámetros de formulario admitan la integración con la interfaz de usuario de Swagger.

El código siguiente carga archivos mediante el enlace inferido del tipo 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();

Advertencia: Al implementar formularios, la aplicación debe evitar losataques de falsificación de solicitudes entre sitios (XSRF/CSRF). En el código anterior, se usa el servicio IAntiforgery para evitar los ataques XSRF mediante la generación y validación de un token antifalsificación:

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

Para más información sobre los ataques XSRF, consulte Antifalsificación con API mínimas

Para obtener más información, consulte Enlace de formularios en API mínimas;

Enlazar a colecciones y tipos complejos desde los formularios

El enlace es compatible con:

  • Colecciones, por ejemplo, Lista y Diccionario
  • Tipos complejos, por ejemplo, Todo o Project

El código siguiente muestra:

  • Un punto de conexión mínimo que enlaza una entrada de formulario de varias partes a un objeto complejo.
  • Cómo usar los servicios antifalsificación para admitir la generación y validación de tokens antifalsificación.
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));
}

En el código anterior:

  • El parámetro de destino debe anotarse con el atributo [FromForm] para eliminar la ambigüedad de los parámetros que se deben leer del JScuerpo ON.
  • El enlace de tipos complejos o de colección no es compatible con las API mínimas que se compilan con el generador de delegados de solicitud.
  • El marcado muestra una entrada oculta adicional con un nombre de isCompleted y un valor de false. Si la casilla isCompleted está activada cuando se envía el formulario, los valores true y false se envían como valores. Si la casilla de verificación no está activada, solo se envía el valor de entrada oculto false. El proceso de vinculación de modelos de ASP.NET Core solo lee el primer valor cuando se vincula a un valor bool, lo que da como resultado true para casillas de verificación marcadas y false para casillas de verificación sin marcar.

Un ejemplo de los datos de formulario enviados al punto de conexión anterior tiene el siguiente aspecto:

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

Enlace de matrices y valores de cadena desde encabezados y cadenas de consulta

En el código siguiente se muestra cómo enlazar cadenas de consulta a una matriz de tipos primitivos, matrices de cadena y 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]}");

El enlace de cadenas de consulta o valores de encabezado a una matriz de tipos complejos se admite cuando el tipo tiene TryParse implementado. El código siguiente se enlaza a una matriz de cadena y devuelve todos los elementos con las etiquetas especificadas:

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

En el código siguiente se muestra el modelo y la implementación de TryParse necesaria:

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

El código siguiente se enlaza a una matriz 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();
});

Para probar el código anterior, agregue el siguiente punto de conexión para rellenar la base de datos con elementos 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);
});

Use una herramienta como HttpRepl para pasar los datos siguientes al punto de conexión anterior:

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

El código siguiente se enlaza a la clave de encabezado X-Todo-Id y devuelve los elementos Todo con valores Id coincidentes:

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

Al enlazar un objeto string[] desde una cadena de consulta, la ausencia de cualquier valor de cadena de consulta coincidente dará como resultado una matriz vacía en lugar de un valor NULL.

Enlace de parámetros para listas de argumentos con [AsParameters]

AsParametersAttribute permite el enlace de parámetros simple a tipos y no a un enlace de modelo complejo o recursivo.

Observe el código siguiente:

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.

Tenga en cuenta el siguiente punto de conexión 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());

Se puede usar lo siguiente struct para reemplazar los parámetros resaltados anteriores:

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

El punto de conexión GET refactorizado usa el anterior struct con el atributo 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());

En el código siguiente se muestran puntos de conexión adicionales en la aplicación:

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

Las siguientes clases se usan para refactorizar las listas de parámetros:

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

En el código siguiente se muestran los puntos de conexión refactorizados mediante AsParameters y las clases y anteriores struct:

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

Los siguientes tipos record se pueden usar para reemplazar los parámetros anteriores:

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

El uso de struct con AsParameters puede ser más eficaz que usar un tipo record.

El código de ejemplo completo en el repositorio AspNetCore.Docs.Samples.

Enlace personalizado

Hay dos maneras de personalizar el enlace de parámetros:

  1. Para los orígenes de enlace de ruta, consulta y encabezado, enlace tipos personalizados mediante la adición de un método TryParse estático para el tipo.
  2. Controle el proceso de enlace mediante la implementación de un método BindAsync en un tipo.

TryParse

TryParse tiene dos API:

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

El código siguiente muestra Point: 12.3, 10.1 con el 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 tiene las siguientes API:

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

El código siguiente muestra SortBy:xyz, SortDirection:Desc, CurrentPage:99 con el 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
}

Errores de enlace

Cuando se produce un error en el enlace, el marco registra un mensaje de depuración y devuelve varios códigos de estado al cliente en función del modo de error.

Modo de error Tipo de parámetro que admite un valor NULL Origen de enlace status code
{ParameterType}.TryParse devuelve false. ruta/consulta/encabezado 400
{ParameterType}.BindAsync devuelve null. custom 400
{ParameterType}.BindAsync genera No importa custom 500
Error al deserializar el cuerpo JSON No importa body 400
Tipo de contenido incorrecto (no application/json) No importa body 415

Prioridad de enlace

Reglas para determinar un origen de enlace a partir de un parámetro:

  1. Atributo explícito definido en el parámetro (atributos From*) en el orden siguiente:
    1. Valores de ruta: [FromRoute]
    2. Cadena de consulta: [FromQuery]
    3. Encabezado: [FromHeader]
    4. Cuerpo: [FromBody]
    5. Formulario: [FromForm]
    6. Servicio: [FromServices]
    7. Valores de parámetros: [AsParameters]
  2. Tipos especiales
    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. El tipo de parámetro tiene un método BindAsync estático válido.
  4. El tipo de parámetro es una cadena o tiene un método TryParse estático válido.
    1. Si el nombre del parámetro existe en la plantilla de ruta, por ejemplo, app.Map("/todo/{id}", (int id) => {});, se enlaza desde la ruta.
    2. Se enlaza a partir de la cadena de consulta.
  5. Si el tipo de parámetro es un servicio proporcionado por la inserción de dependencias, usa ese servicio como origen.
  6. El parámetro procede del cuerpo.

Configuración de opciones de deserialización de JSON para enlace de cuerpo

El origen de enlace de cuerpo usa System.Text.Json para la deserialización. No es posible cambiar este valor predeterminado, pero se pueden configurar las opciones de serialización y deserialización de JSON.

Configuración global de las opciones de deserialización de JSON

Las opciones que se aplican globalmente a una aplicación se pueden configurar invocando ConfigureHttpJsonOptions. En el ejemplo siguiente se incluyen campos públicos y se da formato a la salida de 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
// }

Dado que el código de ejemplo configura la serialización y la deserialización, puede leer NameField e incluir NameField en el JSON de salida.

Configuración de opciones de deserialización de JSON para un punto de conexión

ReadFromJsonAsync tiene sobrecargas que aceptan un objeto JsonSerializerOptions. En el ejemplo siguiente se incluyen campos públicos y se da formato a la salida de 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
// }

Dado que el código anterior tan solo aplica las opciones personalizadas a la deserialización, el JSON de salida excluye NameField.

Lectura del cuerpo de la solicitud

Lea el cuerpo de la solicitud directamente mediante un parámetro HttpContext 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();

El código anterior:

  • Tiene acceso al cuerpo de la solicitud mediante HttpRequest.BodyReader.
  • Copia el cuerpo de la solicitud en un archivo local.

El enlace de parámetros es el proceso de convertir los datos de solicitud en parámetros fuertemente tipados que se expresan mediante controladores de ruta. Un origen de enlace determina desde dónde se enlazan los parámetros. Los orígenes de enlace pueden ser explícitos o inferidos en función del método HTTP y el tipo de parámetro.

Orígenes de enlace admitidos:

  • Valores de ruta
  • Cadena de consulta
  • Encabezado
  • Cuerpo (como JSON)
  • Servicios proporcionados por la inserción de dependencias
  • Personalizado

El enlace desde los valores de formularios no se admite de forma nativa en .NET 6 y 7.

En el siguiente GET, el controlador de ruta usa algunos de estos orígenes de enlace de parámetros:

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

En la tabla siguiente se muestra la relación entre los parámetros utilizados en el ejemplo anterior y los orígenes de enlace asociados.

Parámetro Origen de enlace
id valor de ruta
page cadena de consulta
customHeader header
service Proporcionado por la inserción de dependencias

Los métodos HTTP GET, HEAD, OPTIONS y DELETE no se enlazan implícitamente desde el cuerpo. Para enlazar desde el cuerpo (como JSON) para estos métodos HTTP, enlace explícitamente con [FromBody] o lea desde HttpRequest.

En el siguiente ejemplo, el controlador de ruta POST usa un origen de cuerpo de enlace (como JSON) para el parámetro person:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

record Person(string Name, int Age);

Los parámetros de los ejemplos anteriores se enlazan automáticamente a partir de los datos de solicitud. Para demostrar la comodidad que proporciona el enlace de parámetros, los siguientes controladores de ruta muestran cómo leer los datos de solicitud directamente desde la solicitud:

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

    // ...
});

Enlace de parámetros explícitos

Los atributos se pueden usar para declarar explícitamente desde dónde se enlazan los parámetros.

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);
Parámetro Origen de enlace
id valor de ruta con el nombre id
page cadena de consulta con el nombre "p"
service Proporcionado por la inserción de dependencias
contentType encabezado con el nombre "Content-Type"

Nota

El enlace desde los valores de formularios no se admite de forma nativa en .NET 6 y 7.

Enlace de parámetros con inserción de dependencias

El enlace de parámetros para las API mínimas enlaza parámetros mediante la inserción de dependencias cuando el tipo está configurado como servicio. No es necesario aplicar explícitamente el atributo [FromServices] a un parámetro. En el código siguiente, ambas acciones devuelven la hora:

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

Parámetros opcionales

Los parámetros declarados en controladores de ruta se tratan como obligatorios:

  • Si una solicitud coincide con la ruta, el controlador de rutas solo se ejecuta si se proporcionan todos los parámetros necesarios en la solicitud.
  • Si no se proporcionan todos los parámetros necesarios, se producirá un error.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.Run();
URI resultado
/products?pageNumber=3 3 devuelto
/products BadHttpRequestException: no se proporcionó el parámetro necesario "int pageNumber" de la cadena de consulta.
/products/1 Error HTTP 404, no se encuentra ninguna ruta coincidente

Para que pageNumber sea opcional, defina el tipo como opcional o proporcione un valor predeterminado:

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 resultado
/products?pageNumber=3 3 devuelto
/products 1 devuelto
/products2 1 devuelto

El valor predeterminado y que admite un valor NULL anterior se aplica a todos los orígenes:

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

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

app.Run();

El código anterior llama al método con un producto NULL si no se envía ningún cuerpo de la solicitud.

NOTA: Si se proporcionan datos no válidos y el parámetro admite un valor NULL, el controlador de rutas no se ejecuta.

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

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

app.Run();
URI resultado
/products?pageNumber=3 3 devuelto
/products 1 devuelto
/products?pageNumber=two BadHttpRequestException: error al enlazar el parámetro "Nullable<int> pageNumber" a partir de "two".
/products/two Error HTTP 404, no se encuentra ninguna ruta coincidente

Vea la sección Errores de enlace para más información.

Tipos especiales

Los siguientes tipos se enlazan sin atributos explícitos:

  • HttpContext: contexto que contiene toda la información sobre la solicitud o respuesta HTTP actual:

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequest y HttpResponse: solicitud HTTP y respuesta HTTP:

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken: token de cancelación asociado a la solicitud HTTP actual:

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal: usuario asociado a la solicitud, enlazado desde HttpContext.User:

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

Enlazar el cuerpo de la solicitud como Stream o PipeReader

El cuerpo de la solicitud puede enlazarse como Stream o PipeReader para admitir sin problemas los escenarios en los que el usuario tiene que procesar datos y:

  • Almacenar los datos en Blob Storage o ponerlos en cola en un proveedor de colas.
  • Procesar los datos almacenados con un proceso de trabajo o una función en la nube.

Por ejemplo, los datos pueden ponerse en cola en Azure Queue Storage o almacenarse en Azure Blob Storage.

El código siguiente implementa una cola en segundo plano:

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

El código siguiente enlaza el cuerpo de la solicitud a un objeto 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);
});

El código siguiente muestra el archivo Program.cs completo:

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();
  • Cuando se leen datos, Stream es el mismo objeto que HttpRequest.Body.
  • El cuerpo de la solicitud no se almacena en búfer de forma predeterminada. Una vez leído el cuerpo, no se puede rebobinar. La secuencia no se puede leer varias veces.
  • Stream y PipeReader no se pueden usar fuera del controlador de acciones mínimas, ya que los búferes subyacentes se eliminarán o reutilizarán.

Cargas de archivos mediante IFormFile e IFormFileCollection

El código siguiente usa IFormFile y IFormFileCollection para cargar el archivo:

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

Las solicitudes de carga de archivos autenticadas se admiten mediante un encabezado de autorización, un certificado de cliente o un cookie encabezado.

No hay compatibilidad integrada con la antifalsificación en ASP.NET Core 7.0. La antifalsificación está disponible en ASP.NET Core 8.0 y versiones posteriores. Sin embargo, se puede implementar mediante el servicioIAntiforgery.

Enlace de matrices y valores de cadena desde encabezados y cadenas de consulta

En el código siguiente se muestra cómo enlazar cadenas de consulta a una matriz de tipos primitivos, matrices de cadena y 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]}");

El enlace de cadenas de consulta o valores de encabezado a una matriz de tipos complejos se admite cuando el tipo tiene TryParse implementado. El código siguiente se enlaza a una matriz de cadena y devuelve todos los elementos con las etiquetas especificadas:

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

En el código siguiente se muestra el modelo y la implementación de TryParse necesaria:

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

El código siguiente se enlaza a una matriz 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();
});

Para probar el código anterior, agregue el siguiente punto de conexión para rellenar la base de datos con elementos 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);
});

Use una herramienta de prueba de API como HttpRepl para pasar los datos siguientes al punto de conexión anterior:

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

El código siguiente se enlaza a la clave de encabezado X-Todo-Id y devuelve los elementos Todo con valores Id coincidentes:

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

Al enlazar un objeto string[] desde una cadena de consulta, la ausencia de cualquier valor de cadena de consulta coincidente dará como resultado una matriz vacía en lugar de un valor NULL.

Enlace de parámetros para listas de argumentos con [AsParameters]

AsParametersAttribute permite el enlace de parámetros simple a tipos y no a un enlace de modelo complejo o recursivo.

Observe el código siguiente:

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.

Tenga en cuenta el siguiente punto de conexión 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());

Se puede usar lo siguiente struct para reemplazar los parámetros resaltados anteriores:

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

El punto de conexión GET refactorizado usa el anterior struct con el atributo 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());

En el código siguiente se muestran puntos de conexión adicionales en la aplicación:

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

Las siguientes clases se usan para refactorizar las listas de parámetros:

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

En el código siguiente se muestran los puntos de conexión refactorizados mediante AsParameters y las clases y anteriores struct:

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

Los siguientes tipos record se pueden usar para reemplazar los parámetros anteriores:

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

El uso de struct con AsParameters puede ser más eficaz que usar un tipo record.

El código de ejemplo completo en el repositorio AspNetCore.Docs.Samples.

Enlace personalizado

Hay dos maneras de personalizar el enlace de parámetros:

  1. Para los orígenes de enlace de ruta, consulta y encabezado, enlace tipos personalizados mediante la adición de un método TryParse estático para el tipo.
  2. Controle el proceso de enlace mediante la implementación de un método BindAsync en un tipo.

TryParse

TryParse tiene dos API:

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

El código siguiente muestra Point: 12.3, 10.1 con el 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 tiene las siguientes API:

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

El código siguiente muestra SortBy:xyz, SortDirection:Desc, CurrentPage:99 con el 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
}

Errores de enlace

Cuando se produce un error en el enlace, el marco registra un mensaje de depuración y devuelve varios códigos de estado al cliente en función del modo de error.

Modo de error Tipo de parámetro que admite un valor NULL Origen de enlace status code
{ParameterType}.TryParse devuelve false. ruta/consulta/encabezado 400
{ParameterType}.BindAsync devuelve null. custom 400
{ParameterType}.BindAsync genera no importa custom 500
Error al deserializar el cuerpo JSON no importa body 400
Tipo de contenido incorrecto (no application/json) no importa body 415

Prioridad de enlace

Reglas para determinar un origen de enlace a partir de un parámetro:

  1. Atributo explícito definido en el parámetro (atributos From*) en el orden siguiente:
    1. Valores de ruta: [FromRoute]
    2. Cadena de consulta: [FromQuery]
    3. Encabezado: [FromHeader]
    4. Cuerpo: [FromBody]
    5. Servicio: [FromServices]
    6. Valores de parámetros: [AsParameters]
  2. Tipos especiales
    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. El tipo de parámetro tiene un método BindAsync estático válido.
  4. El tipo de parámetro es una cadena o tiene un método TryParse estático válido.
    1. Si el nombre del parámetro existe en la plantilla de ruta, por ejemplo, app.Map("/todo/{id}", (int id) => {});, se enlaza desde la ruta.
    2. Se enlaza a partir de la cadena de consulta.
  5. Si el tipo de parámetro es un servicio proporcionado por la inserción de dependencias, usa ese servicio como origen.
  6. El parámetro procede del cuerpo.

Configuración de opciones de deserialización de JSON para enlace de cuerpo

El origen de enlace de cuerpo usa System.Text.Json para la deserialización. No es posible cambiar este valor predeterminado, pero se pueden configurar las opciones de serialización y deserialización de JSON.

Configuración global de las opciones de deserialización de JSON

Las opciones que se aplican globalmente a una aplicación se pueden configurar invocando ConfigureHttpJsonOptions. En el ejemplo siguiente se incluyen campos públicos y se da formato a la salida de 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
// }

Dado que el código de ejemplo configura la serialización y la deserialización, puede leer NameField e incluir NameField en el JSON de salida.

Configuración de opciones de deserialización de JSON para un punto de conexión

ReadFromJsonAsync tiene sobrecargas que aceptan un objeto JsonSerializerOptions. En el ejemplo siguiente se incluyen campos públicos y se da formato a la salida de 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
// }

Dado que el código anterior tan solo aplica las opciones personalizadas a la deserialización, el JSON de salida excluye NameField.

Lectura del cuerpo de la solicitud

Lea el cuerpo de la solicitud directamente mediante un parámetro HttpContext 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();

El código anterior:

  • Tiene acceso al cuerpo de la solicitud mediante HttpRequest.BodyReader.
  • Copia el cuerpo de la solicitud en un archivo local.