Привязка параметров в минимальных приложениях API

Примечание.

Это не последняя версия этой статьи. В текущей версии см. версию .NET 10 этой статьи.

Предупреждение

Эта версия ASP.NET Core больше не поддерживается. Дополнительные сведения см. в политике поддержки .NET и .NET Core. В текущей версии см. версию .NET 10 этой статьи.

Привязка параметров — это процесс преобразования данных запроса в строго типизированные параметры, выраженные обработчиками маршрутов. Источник привязки определяет, откуда будут привязаны параметры. Источники привязки могут быть явными или выведенными на основе метода HTTP и типа параметра.

Поддерживаемые источники привязки:

  • значения маршрута;
  • Строка запроса
  • Заголовок
  • текст (в формате JSON);
  • Значения формы
  • службы, предоставляемые через внедрение зависимостей
  • Пользовательское

Примечание.

Типы объединения C# поддерживаются только как текст (как JSON). Источники вне тела запроса — значения маршрута, строка запроса, заголовки и значения формы — связывают строковые значения без разбора JSON, поэтому не могут сопоставляться с вариантом объединения.

В следующем GET обработчике маршрутов используются некоторые из этих источников привязки параметров:

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

В следующей таблице показана связь между параметрами, используемыми в предыдущем примере, и связанными источниками привязки.

Параметр Источник привязки
id Значение маршрута
page строка запроса
customHeader заголовок
service Предоставляется путем внедрения зависимостей

Методы HTTP GET, HEAD, OPTIONS и DELETE не выполняют неявную привязку из текста запроса. Чтобы выполнить привязку из тела (в формате JSON) для этих HTTP-методов, нужно явно указать привязку с помощью [FromBody] или прочитать данные из HttpRequest.

В следующем примере обработчик маршрута POST использует источник привязки — тело запроса (в формате JSON) — для параметра person:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

record Person(string Name, int Age);

Все параметры в предыдущих примерах автоматически привязываются из данных запроса. Чтобы продемонстрировать удобство привязки параметров, в следующих обработчиках маршрутов показано, как считывать данные запроса непосредственно из запроса:

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

    // ...
});

Явная привязка параметров

Атрибуты можно использовать для явного объявления источника, из которого привязываются параметры.

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);
Параметр Источник привязки
id Значение маршрута с именем id
page Строка запроса с именем "p"
service Предоставляется путем внедрения зависимостей
contentType Заголовок с именем "Content-Type"

Явная привязка к значениям формы

Атрибут [FromForm] привязывает значения формы:

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.

Альтернативой является использование [AsParameters] атрибута с пользовательским типом, который имеет свойства, аннотированные с [FromForm]. Например, следующий код выполняет привязку значений формы к свойствам record-структуры 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);

Дополнительные сведения см. в разделе AsParameters далее в этой статье.

Полный пример кода находится в репозитории AspNetCore.Docs.Samples .

Безопасная привязка из IFormFile и IFormFileCollection

Поддерживается сложная привязка форм с использованием IFormFile и IFormFileCollection при помощи [FromForm]:

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

var builder = WebApplication.CreateBuilder();

builder.Services.AddAntiforgery();

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

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

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

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

app.Run();

Параметры запроса, связанные с [FromForm], включают маркер защиты от подделки. Маркер защиты проверяется при обработке запроса. Дополнительные сведения см. в разделе Защита от подделки в минимальных API.

Дополнительные сведения см. в статье "Привязка формы" в минимальных API.

Полный пример кода находится в репозитории AspNetCore.Docs.Samples .

Привязка параметров при внедрении зависимостей

Привязка параметров для минимальных API привязывает параметры путем внедрения зависимостей при настройке типа в качестве службы. Явное применение атрибута [FromServices] для параметра не является обязательным. В следующем коде оба действия возвращают время:

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

Необязательные параметры

Параметры, объявленные в обработчиках маршрутов, считаются обязательными:

  • Если запрос соответствует маршруту, то обработчик маршрутов выполняется только в том случае, если в запросе есть все обязательные параметры.
  • Отсутствие любого из обязательных параметров приводит к ошибке.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.Run();
URI результат
/products?pageNumber=3 Возвращено: 3
/products BadHttpRequestException: обязательный параметр "int pageNumber" не был предоставлен из строки запроса.
/products/1 Ошибка HTTP 404, нет соответствующего маршрута

Чтобы сделать pageNumber необязательным, определите для него тип optional (необязательный) или укажите значение по умолчанию:

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 результат
/products?pageNumber=3 Возвращено: 3
/products Возвращено: 1
/products2 Возвращено: 1

Приведенные выше значения nullable и default применяется ко всем источникам:

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

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

app.Run();

Предыдущий код вызывает метод с параметром product, равным null, если тело запроса не передано.

ПРИМЕЧАНИЕ: если предоставлены недопустимые данные и параметр допускает значение NULL, обработчик маршрутов не выполняется.

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

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

app.Run();
URI результат
/products?pageNumber=3 3 вернулся
/products 1 вернулся
/products?pageNumber=two BadHttpRequestException: Не удалось выполнить привязку параметра "Nullable<int> pageNumber" для "two".
/products/two Ошибка HTTP 404, нет соответствующего маршрута

Дополнительные сведения см. в разделе Ошибки привязки.

Специальные типы

Следующие типы привязываются без явно заданных атрибутов:

  • HttpContext — контекст, содержащий все сведения о текущем HTTP-запросе или ответе:

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequest и HttpResponse — HTTP-запрос и ответ HTTP:

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken — маркер отмены, связанный с текущим HTTP-запросом:

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal — пользователь, связанный с запросом, привязанным из HttpContext.User:

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

Свяжите тело запроса как Stream или PipeReader

Тело запроса может привязываться как Stream или PipeReader для эффективной поддержки сценариев, в которых пользователю необходимо обрабатывать данные и:

  • Сохраните данные в хранилище BLOB-объектов или поместите данные в очередь в службе очередей.
  • Обрабатывать хранимые данные с помощью рабочего процесса или облачной функции.

Например, данные могут быть помещены в очередь в Хранилище очередей Azure или храниться в Хранилище BLOB-объектов Azure.

Следующий код реализует фоновую очередь:

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

Следующий код привязывает текст запроса к 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);
});

Следующий код демонстрирует полный файл 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();
  • При чтении данных Stream — это тот же объект, что и HttpRequest.Body.
  • Текст запроса по умолчанию не буферизуется. После чтения тело нельзя перемотать назад. Поток не может быть прочитан несколько раз.
  • Stream и PipeReader нельзя использовать за пределами обработчика минимального действия, так как базовые буферы будут удалены или использованы повторно.

Отправка файлов с помощью IFormFile и IFormFileCollection

Для загрузки файлов с использованием IFormFile и IFormFileCollection в минимальных API требуется кодировка multipart/form-data. Имя параметра в обработчике маршрутов должно соответствовать имени поля формы в запросе. Минимальные API не поддерживают привязку всего текста запроса непосредственно к параметру IFormFile без кодирования формы.

Если необходимо привязать весь текст запроса, например при работе с JSON, двоичными данными или другими типами контента, см. следующее:

Следующий код использует IFormFile и IFormFileCollection, чтобы отправить файл:

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

Поддерживаются запросы на отправку файлов с проверкой подлинности посредством заголовка авторизации, сертификата клиента или заголовка cookie.

Привязка к формам с помощью IFormCollection, IFormFile и IFormFileCollection

Поддерживается привязка с использованием параметров на основе форм с помощью IFormCollection, IFormFile и IFormFileCollection. Метаданные OpenAPI выводятся для параметров формы для поддержки интеграции с пользовательским интерфейсом Swagger.

Следующий код загружает файлы с использованием автоматически определяемой привязки типа 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();

Предупреждение: При реализации форм приложение должно предотвращать атаки межсайтовой подделки запросов (XSRF/CSRF). В приведенном выше коде IAntiforgery служба используется для предотвращения атак XSRF путем создания и проверки маркера защиты от подделки:

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

Дополнительные сведения о атаках XSRF см. в статье "Антифоргерия с минимальными API"

Дополнительные сведения см. по теме "Связывание форм" в контексте минимальных API;

Поведение привязки коллекции IFormFile

В следующей таблице показано, как разные IFormFile типы параметров коллекции привязаны к минимальным API. Общее правило о том, что имя параметра в обработчике маршрута должно совпадать с именем поля формы, относится к IFormFile и поддерживаемым именованным коллекциям файлов, таким как IReadOnlyList<T>, но не к IFormFileCollection, который связывает все отправленные файлы.

Тип параметра Привязанное значение Учитывает имя параметра?
IFormFileCollection Все файлы в HttpContext.Request.Form.Files Нет
IFormFile Один файл, имя поля формы которого соответствует имени параметра. Yes
IReadOnlyList<IFormFile> Все файлы, имя поля формы которых соответствует имени параметра Yes
Другие IFormFile типы коллекций (IEnumerable<IFormFile>, List<IFormFile>, IFormFile[]и т. д.) Не поддерживается— параметр не заполняется N/A

Используйте IFormFileCollection , если вам нужны все отправленные файлы независимо от имени поля формы. Используйте IReadOnlyList<T> , если вам нужны только файлы, имя поля формы которого соответствует имени параметра.

Примечание.

Правила привязки коллекций IFormFile, приведённые в предыдущей таблице, также применяются к свойствам типов [AsParameters] и сложных типов, сопоставленных с формой, поскольку слой сопоставления с формой является общим для Blazor.

Привязка к коллекциям и сложным типам из форм

Привязка поддерживается для:

  • Коллекции, например list and Dictionary
  • Сложные типы, например Todo или Project

В коде демонстрируется следующее.

  • Минимальная конечная точка, которая привязывает многокомпонентные входные данные формы к сложному объекту.
  • Как использовать службы антифоргерии для поддержки создания и проверки маркеров антифоргерии.
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAntiforgery();

var app = builder.Build();

app.UseAntiforgery();

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

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

app.Run();

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

В предыдущем коде:

  • Целевой параметр должен быть аннотирован атрибутом [FromForm], чтобы отличать его от параметров, которые следует считывать из тела JSON.
  • Привязка из сложных типов или типов коллекций не поддерживается для минимальных API, скомпилированных с помощью генератора запросов делегатов.
  • В разметке показаны дополнительные скрытые входные данные с именем isCompleted и значением false. isCompleted Если флажок установлен при отправке формы, оба значения true и false отправляются в виде значений. Если флажок снят, отправляется только значение скрытого поля ввода false. Процесс привязки модели в ASP.NET Core считывает только первое значение при привязке к значению bool, в результате чего для установленных флажков используется true, а для снятых — false.

Пример данных формы, отправленных в предыдущую конечную точку, выглядит следующим образом:

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

Привязка массивов и строковых значений из заголовков и строк запроса

Следующий код демонстрирует привязку строк запроса к массиву примитивных типов, массивам строк и 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]}");

Привязка строк запроса или значений заголовков к массиву сложных типов поддерживается, если для типа реализовать TryParse. Следующий код выполняет привязку к массиву строк и возвращает все элементы с указанными тегами.

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

В следующем коде показана модель и требуемая реализация TryParse.

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

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

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

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

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

В следующем коде выполняется привязка к массиву 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();
});

Чтобы протестировать предыдущий код, добавьте следующую конечную точку, чтобы заполнить базу данных элементами 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);
});

Используйте такой инструмент, как HttpRepl, чтобы передать следующие данные в предыдущий эндпоинт:

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

В следующем коде выполняется привязка к ключу заголовка X-Todo-Id и возвращаются элементы Todo с соответствующими значениями Id.

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

Примечание.

При привязке string[] из строки запроса отсутствие соответствующего значения строки запроса приведет к пустому массиву вместо значения NULL.

Привязка параметров для списков аргументов с помощью [AsParameters]

AsParametersAttribute обеспечивает простую привязку параметров к типам, а не сложную или рекурсивную привязку модели.

Рассмотрим следующий код:

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.

Рассмотрим следующую конечную точку 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());

Для замены указанных выше выделенных параметров можно использовать следующую структуру (struct):

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

Оптимизированная конечная точка GET использует приведенную выше структуру (struct) с атрибутом 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());

В приведенном ниже коде показаны дополнительные конечные точки в приложении:

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

Для рефакторинга списков параметров используются следующие классы:

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

В следующем фрагменте кода показаны эндпоинты, переработанные с помощью AsParameters, а также предшествующие им 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();
});

Для замены предыдущих параметров можно использовать следующие типы record:

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

Использование struct с AsParameters может обеспечить лучшую производительность, чем использование типа record.

Полный пример кода приведен в репозитории AspNetCore.Docs.Samples.

Настраиваемая привязка

Существует три способа настройки привязки параметров:

  1. Если в качестве источника привязки маршрутов используется маршрут, запрос или заголовок, привяжите пользовательские типы путем добавления статического метода TryParse для нужного типа.
  2. Управление процессом привязки осуществляется путем реализации метода BindAsync для этого типа.
  3. Для расширенных сценариев реализуйте интерфейс IBindableFromHttpContext<TSelf>, чтобы предоставить пользовательскую логику привязки непосредственно из HttpContext.

TryParse

TryParse имеет два API:

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

Следующий код отображает Point: 12.3, 10.1 с 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 имеет следующие API:

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

Следующий код отображает SortBy:xyz, SortDirection:Desc, CurrentPage:99 с 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
}

Настраиваемая привязка параметров с помощью IBindableFromHttpContext

ASP.NET Core обеспечивает поддержку пользовательской привязки параметров в минимальных API с помощью IBindableFromHttpContext<TSelf> интерфейса. Этот интерфейс, представленный статическими абстрактными элементами C# 11, позволяет создавать типы, которые могут быть привязаны из контекста HTTP непосредственно в параметрах обработчика маршрутов.

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

Реализуя IBindableFromHttpContext<TSelf>, вы можете создать пользовательские типы, которые обрабатывают собственную логику привязки из HttpContext. Если обработчик маршрута включает параметр этого типа, платформа автоматически вызывает статический BindAsync метод для создания экземпляра:

using CustomBindingExample;

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

app.UseHttpsRedirection();

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

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

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

Ниже приведен пример реализации пользовательского параметра, который привязывается из заголовка HTTP:

using System.Reflection;

namespace CustomBindingExample;

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

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

Вы также можете реализовать проверку в пользовательской логике привязки:

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

Просмотр или скачивание примера кода (как скачать)

Ошибки привязки

Если привязка выполняется неудачно, платформа регистрирует сообщение отладки и возвращает клиенту коды состояния, которые могут быть разными в зависимости от условий сбоя.

Режим сбоя Тип параметра, допускающий значение NULL Источник привязки Код состояния
{ParameterType}.TryParse возвращает false да Маршрут, запрос или заголовок 400
{ParameterType}.BindAsync возвращает null да пользовательский 400
{ParameterType}.BindAsync вызывает Всё равно пользовательский 500
Не удалось десериализовать текст JSON Всё равно тело 400
Неправильный тип содержимого (не application/json) Всё равно тело 415

Приоритет привязки

Правила для определения источника привязки на основе параметра:

  1. Явный атрибут, определенный для атрибутов параметра (From*) в следующем порядке:
    1. значения маршрута: [FromRoute];
    2. Строка запроса: [FromQuery]
    3. заголовок: [FromHeader];
    4. Текст: [FromBody]
    5. Форма: [FromForm]
    6. Служба: [FromServices]
    7. Значения параметров: [AsParameters]
  2. Специальные типы
    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. У типа параметра есть корректный статический метод BindAsync.
  4. Тип параметра является строкой или имеет допустимый статический TryParse метод.
    1. Если имя параметра существует в шаблоне маршрута, например, app.Map("/todo/{id}", (int id) => {});оно привязано к маршруту.
    2. Привязывается из строки запроса.
  5. Если тип параметра является службой, предоставляемой путем внедрения зависимостей, то в качестве источника он использует эту службу.
  6. Параметр извлекается из текста запроса.

Настройка параметров десериализации JSON для привязки текста

Источник привязки тела используется System.Text.Json для десериализации. Изменить этот параметр по умолчанию невозможно, но можно настроить параметры сериализации и десериализации JSON.

Настройка параметров десериализации JSON глобально

Параметры, применяемые глобально для приложения, можно настроить, вызвав ConfigureHttpJsonOptions. В следующем примере содержатся общедоступные поля и форматы выходных данных 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
// }

Так как пример кода настраивает сериализацию и десериализацию, он может считывать NameField и включать NameField в выходной КОД JSON.

Настройка параметров десериализации JSON для конечной точки

ReadFromJsonAsync имеет перегрузки, принимающие объект JsonSerializerOptions. В следующем примере содержатся общедоступные поля и форматы выходных данных 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
// }

Поскольку приведенный выше код применяет пользовательские параметры только к десериализации, выходной JSON не включает NameField.

Прочитать тело запроса

Считайте текст запроса напрямую, используя параметр HttpContext или 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();

Предыдущий код:

  • Обращается к тексту запроса с помощью HttpRequest.BodyReader.
  • Копирует текст запроса в локальный файл.

Привязка параметров — это процесс преобразования данных запроса в строго типизированные параметры, выраженные обработчиками маршрутов. Источник привязки определяет, откуда будут привязаны параметры. Источники привязки могут быть явными или выведенными на основе метода HTTP и типа параметра.

Поддерживаемые источники привязки:

  • значения маршрута;
  • Строка запроса
  • Заголовок
  • текст (в формате JSON);
  • службы, предоставляемые через внедрение зависимостей
  • Пользовательское

Привязка по значениям формы не поддерживается изначально в .NET 6 и 7.

В следующем GET обработчике маршрутов используются некоторые из этих источников привязки параметров:

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

В следующей таблице показана связь между параметрами, используемыми в предыдущем примере, и связанными источниками привязки.

Параметр Источник привязки
id Значение маршрута
page строка запроса
customHeader заголовок
service Предоставляется путем внедрения зависимостей

Методы HTTP GET, HEAD, OPTIONS и DELETE не выполняют неявную привязку из текста запроса. Чтобы выполнить привязку из тела (в формате JSON) для этих HTTP-методов, нужно явно указать привязку с помощью [FromBody] или прочитать данные из HttpRequest.

В следующем примере обработчик маршрута POST использует источник привязки — тело запроса (в формате JSON) — для параметра person:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

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

record Person(string Name, int Age);

Все параметры в предыдущих примерах автоматически привязываются из данных запроса. Чтобы продемонстрировать удобство привязки параметров, в следующих обработчиках маршрутов показано, как считывать данные запроса непосредственно из запроса:

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

    // ...
});

Явная привязка параметров

Атрибуты можно использовать для явного объявления источника, из которого привязываются параметры.

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);
Параметр Источник привязки
id Значение маршрута с именем id
page Строка запроса с именем "p"
service Предоставляется путем внедрения зависимостей
contentType Заголовок с именем "Content-Type"

Примечание.

Привязка по значениям формы не поддерживается изначально в .NET 6 и 7.

Привязка параметров при внедрении зависимостей

Привязка параметров для минимальных API привязывает параметры путем внедрения зависимостей при настройке типа в качестве службы. Явное применение атрибута [FromServices] для параметра не является обязательным. В следующем коде оба действия возвращают время:

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

Необязательные параметры

Параметры, объявленные в обработчиках маршрутов, считаются обязательными:

  • Если запрос соответствует маршруту, то обработчик маршрутов выполняется только в том случае, если в запросе есть все обязательные параметры.
  • Отсутствие любого из обязательных параметров приводит к ошибке.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

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

app.Run();
URI результат
/products?pageNumber=3 Возвращено: 3
/products BadHttpRequestException: Обязательный параметр "int pageNumber" не указан в строке запроса.
/products/1 Ошибка HTTP 404, нет соответствующего маршрута

Чтобы сделать pageNumber необязательным, определите для него тип optional (необязательный) или укажите значение по умолчанию:

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 результат
/products?pageNumber=3 Возвращено: 3
/products Возвращено: 1
/products2 Возвращено: 1

Приведенные выше значения nullable и default применяется ко всем источникам:

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

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

app.Run();

Предыдущий код вызывает метод с параметром product, равным null, если тело запроса не передано.

ПРИМЕЧАНИЕ: если предоставлены недопустимые данные и параметр допускает значение NULL, обработчик маршрутов не выполняется.

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

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

app.Run();
URI результат
/products?pageNumber=3 3 вернулся
/products 1 вернулся
/products?pageNumber=two BadHttpRequestException: Не удалось выполнить привязку параметра "Nullable<int> pageNumber" для "two".
/products/two Ошибка HTTP 404, нет соответствующего маршрута

Дополнительные сведения см. в разделе Ошибки привязки.

Специальные типы

Следующие типы привязываются без явно заданных атрибутов:

  • HttpContext — контекст, содержащий все сведения о текущем HTTP-запросе или ответе:

    app.MapGet("/", (HttpContext context) => context.Response.WriteAsync("Hello World"));
    
  • HttpRequest и HttpResponse — HTTP-запрос и ответ HTTP:

    app.MapGet("/", (HttpRequest request, HttpResponse response) =>
        response.WriteAsync($"Hello World {request.Query["name"]}"));
    
  • CancellationToken — маркер отмены, связанный с текущим HTTP-запросом:

    app.MapGet("/", async (CancellationToken cancellationToken) => 
        await MakeLongRunningRequestAsync(cancellationToken));
    
  • ClaimsPrincipal — пользователь, связанный с запросом, привязанным из HttpContext.User:

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

Свяжите тело запроса как Stream или PipeReader

Тело запроса может привязываться как Stream или PipeReader для эффективной поддержки сценариев, в которых пользователю необходимо обрабатывать данные и:

  • Сохраните данные в хранилище BLOB-объектов или поместите данные в очередь в службе очередей.
  • Обрабатывать хранимые данные с помощью рабочего процесса или облачной функции.

Например, данные могут быть помещены в очередь в Хранилище очередей Azure или храниться в Хранилище BLOB-объектов Azure.

Следующий код реализует фоновую очередь:

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

Следующий код привязывает текст запроса к 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);
});

Следующий код демонстрирует полный файл 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();
  • При чтении данных Stream — это тот же объект, что и HttpRequest.Body.
  • Текст запроса по умолчанию не буферизуется. После чтения тело нельзя перемотать назад. Поток не может быть прочитан несколько раз.
  • Stream и PipeReader нельзя использовать за пределами обработчика минимального действия, так как базовые буферы будут удалены или использованы повторно.

Отправка файлов с помощью IFormFile и IFormFileCollection

Следующий код использует IFormFile и IFormFileCollection, чтобы отправить файл:

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

Поддерживаются запросы на отправку файлов с проверкой подлинности посредством заголовка авторизации, сертификата клиента или заголовка cookie.

Отсутствует встроенная поддержка защиты от подделки в ASP.NET Core в .NET 7. Антифальсификация доступна в ASP.NET Core в версии .NET 8 или более поздней версии. Однако ее можно реализовать с помощью службы IAntiforgery.

Привязка массивов и строковых значений из заголовков и строк запроса

Следующий код демонстрирует привязку строк запроса к массиву примитивных типов, массивам строк и 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]}");

Привязка строк запроса или значений заголовков к массиву сложных типов поддерживается, если для типа реализовать TryParse. Следующий код выполняет привязку к массиву строк и возвращает все элементы с указанными тегами.

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

В следующем коде показана модель и требуемая реализация TryParse.

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

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

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

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

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

В следующем коде выполняется привязка к массиву 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();
});

Чтобы протестировать предыдущий код, добавьте следующую конечную точку, чтобы заполнить базу данных элементами 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);
});

Используйте инструмент для тестирования API, например HttpRepl, чтобы передать следующие данные в предыдущий эндпоинт:

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

В следующем коде выполняется привязка к ключу заголовка X-Todo-Id и возвращаются элементы Todo с соответствующими значениями Id.

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

Примечание.

При привязке string[] из строки запроса отсутствие соответствующего значения строки запроса приведет к пустому массиву вместо значения NULL.

Привязка параметров для списков аргументов с помощью [AsParameters]

AsParametersAttribute обеспечивает простую привязку параметров к типам, а не сложную или рекурсивную привязку модели.

Рассмотрим следующий код:

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.

Рассмотрим следующую конечную точку 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());

Для замены указанных выше выделенных параметров можно использовать следующую структуру (struct):

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

Оптимизированная конечная точка GET использует приведенную выше структуру (struct) с атрибутом 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());

В приведенном ниже коде показаны дополнительные конечные точки в приложении:

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

Для рефакторинга списков параметров используются следующие классы:

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

В следующем фрагменте кода показаны эндпоинты, переработанные с помощью AsParameters, а также предшествующие им 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();
});

Для замены предыдущих параметров можно использовать следующие типы record:

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

Использование struct с AsParameters может обеспечить лучшую производительность, чем использование типа record.

Полный пример кода приведен в репозитории AspNetCore.Docs.Samples.

Настраиваемая привязка

Существует три способа настройки привязки параметров:

  1. Если в качестве источника привязки маршрутов используется маршрут, запрос или заголовок, привяжите пользовательские типы путем добавления статического метода TryParse для нужного типа.
  2. Управление процессом привязки осуществляется путем реализации метода BindAsync для этого типа.
  3. Для расширенных сценариев реализуйте интерфейс IBindableFromHttpContext<TSelf>, чтобы предоставить пользовательскую логику привязки непосредственно из HttpContext.

TryParse

TryParse имеет два API:

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

Следующий код отображает Point: 12.3, 10.1 с 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 имеет следующие API:

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

Следующий код отображает SortBy:xyz, SortDirection:Desc, CurrentPage:99 с 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
}

Настраиваемая привязка параметров с помощью IBindableFromHttpContext

ASP.NET Core обеспечивает поддержку пользовательской привязки параметров в минимальных API с помощью IBindableFromHttpContext<TSelf> интерфейса. Этот интерфейс, представленный статическими абстрактными элементами C# 11, позволяет создавать типы, которые могут быть привязаны из контекста HTTP непосредственно в параметрах обработчика маршрутов.

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

Реализуя IBindableFromHttpContext<TSelf> интерфейс, можно создавать пользовательские типы, обрабатывающие собственную логику привязки из HttpContext. Если обработчик маршрута включает параметр этого типа, платформа автоматически вызывает статический метод BindAsync для создания экземпляра:

using CustomBindingExample;

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

app.UseHttpsRedirection();

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

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

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

Ниже приведен пример реализации пользовательского параметра, который привязывается из заголовка HTTP:

using System.Reflection;

namespace CustomBindingExample;

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

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

Вы также можете реализовать проверку в пользовательской логике привязки:

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

Просмотр или скачивание примера кода (как скачать)

Ошибки привязки

Если привязка выполняется неудачно, платформа регистрирует сообщение отладки и возвращает клиенту коды состояния, которые могут быть разными в зависимости от условий сбоя.

Режим сбоя Тип параметра, допускающий значение NULL Источник привязки Код состояния
{ParameterType}.TryParse возвращает false да Маршрут, запрос или заголовок 400
{ParameterType}.BindAsync возвращает null да пользовательский 400
{ParameterType}.BindAsync вызывает Не имеет значения пользовательский 500
Не удалось десериализовать текст JSON Не имеет значения тело 400
Неправильный тип содержимого (не application/json) Не имеет значения тело 415

Приоритет привязки

Правила для определения источника привязки на основе параметра:

  1. Явный атрибут, определенный для атрибутов параметра (From*) в следующем порядке:
    1. значения маршрута: [FromRoute];
    2. Строка запроса: [FromQuery]
    3. заголовок: [FromHeader];
    4. Текст: [FromBody]
    5. Служба: [FromServices]
    6. Значения параметров: [AsParameters]
  2. Специальные типы
    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. У типа параметра есть корректный статический метод BindAsync.
  4. Тип параметра является строкой или имеет допустимый статический TryParse метод.
    1. Если имя параметра существует в шаблоне маршрута. В app.Map("/todo/{id}", (int id) => {});, id привязана к маршруту.
    2. Привязывается из строки запроса.
  5. Если тип параметра является службой, предоставляемой путем внедрения зависимостей, то в качестве источника он использует эту службу.
  6. Параметр извлекается из текста запроса.

Настройка параметров десериализации JSON для привязки текста

Источник привязки тела используется System.Text.Json для десериализации. Изменить этот параметр по умолчанию невозможно, но можно настроить параметры сериализации и десериализации JSON.

Настройка параметров десериализации JSON глобально

Параметры, применяемые глобально для приложения, можно настроить, вызвав ConfigureHttpJsonOptions. В следующем примере содержатся общедоступные поля и форматы выходных данных 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
// }

Так как пример кода настраивает сериализацию и десериализацию, он может считывать NameField и включать NameField в выходной КОД JSON.

Настройка параметров десериализации JSON для конечной точки

ReadFromJsonAsync имеет перегрузки, принимающие объект JsonSerializerOptions. В следующем примере содержатся общедоступные поля и форматы выходных данных 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
// }

Поскольку приведенный выше код применяет пользовательские параметры только к десериализации, выходной JSON не включает NameField.

Прочитать тело запроса

Считайте текст запроса напрямую, используя параметр HttpContext или 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();

Предыдущий код:

  • Обращается к тексту запроса с помощью HttpRequest.BodyReader.
  • Копирует текст запроса в локальный файл.