Liên kết tham số (Parameter Binding) trong ứng dụng Minimal API
Parameter binding (liên kết tham số) là quá trình chuyển đổi dữ liệu từ request (yêu cầu) thành các tham số có kiểu dữ liệu mạnh (strongly typed) được khai báo trong route handler (bộ xử lý tuyến đường). Binding source (nguồn liên kết) xác định nơi các tham số được lấy từ đó. Binding source có thể được khai báo tường minh hoặc được suy ra dựa trên HTTP method (phương thức HTTP) và kiểu tham số.
Các binding source được hỗ trợ:
- Route values (giá trị route)
- Query string (chuỗi truy vấn)
- Header (tiêu đề)
- Body (nội dung) (dạng JSON)
- Form values (giá trị form)
- Services (dịch vụ) được cung cấp bởi dependency injection (tiêm phụ thuộc)
- Custom (tùy chỉnh)
Route handler GET sau đây sử dụng một số binding source này:
var builder = WebApplication.CreateBuilder(args);
// Thêm như 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 { }Bảng sau thể hiện mối quan hệ giữa các tham số trong ví dụ trên và binding source tương ứng:
| Tham số | Binding Source |
|---|---|
id | route value |
page | query string |
customHeader | header |
service | Được cung cấp bởi dependency injection |
Các HTTP method GET, HEAD, OPTIONS, và DELETE không tự động liên kết từ body. Để liên kết từ body (dạng JSON) cho các HTTP method này, hãy khai báo tường minh với [[FromBody]] hoặc đọc từ HttpRequest.
Route handler POST sau đây sử dụng binding source body (dạng JSON) cho tham số person:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/", (Person person) => { });
record Person(string Name, int Age);Các tham số trong ví dụ trên đều được liên kết tự động từ dữ liệu request. Để thấy sự tiện lợi của parameter binding, các route handler sau cho thấy cách đọc dữ liệu request trực tiếp:
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>();
// ...
});Liên kết tham số tường minh (Explicit Parameter Binding)
Có thể dùng attribute (thuộc tính) để khai báo tường minh nơi tham số được liên kết:
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
// Thêm như 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);| Tham số | Binding Source |
|---|---|
id | route value với tên id |
page | query string với tên "p" |
service | Được cung cấp bởi dependency injection |
contentType | header với tên "Content-Type" |
Liên kết tường minh từ form values
Attribute [FromForm] liên kết form values:
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();
});
// Phần còn lại đã được lược bỏ.Một cách khác là dùng attribute [AsParameters] với kiểu tùy chỉnh có các thuộc tính được gắn [FromForm]. Ví dụ sau liên kết từ form values sang các thuộc tính của record struct NewTodoRequest:
app.MapPost("/ap/todos", async ([AsParameters] NewTodoRequest request, TodoDb db) =>
{
var todo = new Todo
{
Name = request.Name,
Visibility = request.Visibility
};
if (request.Attachment is not null)
{
var attachmentName = Path.GetRandomFileName();
using var stream = File.Create(Path.Combine("wwwroot", attachmentName));
await request.Attachment.CopyToAsync(stream);
todo.Attachment = attachmentName;
}
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Ok();
});
// Phần còn lại đã được lược bỏ.public record struct NewTodoRequest([FromForm] string Name,
[FromForm] Visibility Visibility, IFormFile? Attachment);Liên kết an toàn từ IFormFile và IFormFileCollection
Liên kết form phức tạp được hỗ trợ dùng IFormFile và IFormFileCollection với [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();
// Tạo form với antiforgery token và endpoint /upload
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($"File của bạn với mô tả:" +
$" {fileUploadForm.Description} đã được upload thành công");
});
app.Run();Các tham số liên kết với request bằng [FromForm] bao gồm antiforgery token (mã chống giả mạo). Antiforgery token được kiểm tra khi xử lý request.
Liên kết tham số với dependency injection
Parameter binding cho Minimal APIs liên kết tham số thông qua dependency injection khi kiểu đó được cấu hình như service. Không cần phải khai báo tường minh attribute [FromServices] cho tham số. Trong đoạn code sau, cả hai action đều trả về thời gian:
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();Tham số tùy chọn (Optional Parameters)
Các tham số được khai báo trong route handler được coi là bắt buộc:
- Nếu request khớp với route, route handler chỉ chạy nếu tất cả tham số bắt buộc được cung cấp.
- Không cung cấp tất cả tham số bắt buộc sẽ dẫn đến lỗi.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/products", (int pageNumber) => $"Requesting page {pageNumber}");
app.Run();| URI | Kết quả |
|---|---|
/products?pageNumber=3 | Trả về 3 |
/products | BadHttpRequestException: Required parameter "int pageNumber" wasn't provided from query string. |
/products/1 | Lỗi HTTP 404, không khớp route |
Để làm pageNumber tùy chọn, định nghĩa kiểu là nullable hoặc cung cấp giá trị mặc định:
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 | Kết quả |
|---|---|
/products?pageNumber=3 | Trả về 3 |
/products | Trả về 1 |
/products2 | Trả về 1 |
LƯU Ý: Nếu dữ liệu không hợp lệ được cung cấp và tham số là nullable, route handler sẽ không chạy.
Các kiểu đặc biệt (Special Types)
Các kiểu sau được liên kết mà không cần attribute tường minh:
HttpContext: Context chứa tất cả thông tin về HTTP request hoặc response hiện tại.HttpRequestvàHttpResponse: HTTP request và HTTP response.CancellationToken: Cancellation token liên kết với HTTP request hiện tại.ClaimsPrincipal: Người dùng liên kết với request, lấy từHttpContext.User.
Liên kết request body dưới dạng Stream hoặc PipeReader
Request body có thể được liên kết dưới dạng Stream hoặc PipeReader để hỗ trợ hiệu quả các tình huống xử lý dữ liệu như lưu vào blob storage hoặc đẩy vào queue provider.
app.MapPost("/register", async (HttpRequest req, Stream body,
Channel<ReadOnlyMemory<byte>> queue) =>
{
if (req.ContentLength is not null && req.ContentLength > maxMessageSize)
{
return Results.BadRequest();
}
var readSize = (int?)req.ContentLength ?? (maxMessageSize + 1);
var buffer = new byte[readSize];
var read = await body.ReadAtLeastAsync(buffer, readSize, throwOnEndOfStream: false);
if (read > maxMessageSize)
{
return Results.BadRequest();
}
if (queue.Writer.TryWrite(buffer.AsMemory(0..read)))
{
return Results.Accepted();
}
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
});Upload file dùng IFormFile và IFormFileCollection
Upload file dùng IFormFile và IFormFileCollection trong Minimal APIs yêu cầu encoding multipart/form-data. Tên tham số trong route handler phải khớp với tên form field trong request.
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();Hành vi liên kết IFormFile collection
| Kiểu tham số | Giá trị được liên kết | Tuân theo tên tham số? |
|---|---|---|
IFormFileCollection | Tất cả file trong HttpContext.Request.Form.Files | Không |
IFormFile | File duy nhất có tên form field khớp với tên tham số | Có |
IReadOnlyList<IFormFile> | Tất cả file có tên form field khớp với tên tham số | Có |
Các kiểu IFormFile collection khác | Không hỗ trợ | N/A |
Liên kết arrays và string từ headers và query strings
// Liên kết query string values vào mảng kiểu nguyên thủy.
// GET /tags?q=1&q=2&q=3
app.MapGet("/tags", (int[] q) =>
$"tag1: {q[0]} , tag2: {q[1]}, tag3: {q[2]}");
// Liên kết vào string array.
// GET /tags2?names=john&names=jack&names=jane
app.MapGet("/tags2", (string[] names) =>
$"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");
// Liên kết vào StringValues.
// GET /tags3?names=john&names=jack&names=jane
app.MapGet("/tags3", (StringValues names) =>
$"tag1: {names[0]} , tag2: {names[1]}, tag3: {names[2]}");Liên kết tham số cho danh sách đối số với [AsParameters]
AsParametersAttribute cho phép liên kết tham số đơn giản vào kiểu dữ liệu mà không cần model binding phức tạp.
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 sau có thể thay thế các tham số trên:
struct TodoItemRequest
{
public int Id { get; set; }
public TodoDb Db { get; set; }
}Endpoint GET được tái cấu trúc dùng struct với attribute [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());Liên kết tùy chỉnh (Custom Binding)
Có ba cách để tùy chỉnh parameter binding:
- Với route, query và header binding source, liên kết kiểu tùy chỉnh bằng cách thêm phương thức static
TryParsecho kiểu đó. - Kiểm soát quá trình liên kết bằng cách triển khai phương thức
BindAsynctrên kiểu. - Với các tình huống nâng cao, triển khai interface
IBindableFromHttpContext<TSelf>.
TryParse
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)
{
// Định dạng là "(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
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
}Lỗi liên kết (Binding Failures)
Khi binding thất bại, framework ghi log debug message và trả về status code khác nhau tùy theo loại lỗi:
| Loại lỗi | Kiểu tham số nullable | Binding Source | Status code |
|---|---|---|---|
{ParameterType}.TryParse trả về false | có | route/query/header | 400 |
{ParameterType}.BindAsync trả về null | có | custom | 400 |
{ParameterType}.BindAsync ném exception | bất kỳ | custom | 500 |
| Lỗi deserialize JSON body | bất kỳ | body | 400 |
Sai content type (không phải application/json) | bất kỳ | body | 415 |
Thứ tự ưu tiên liên kết (Binding Precedence)
Các quy tắc xác định binding source từ tham số:
- Attribute tường minh được định nghĩa trên tham số (From\* attributes) theo thứ tự sau:
- Route values:
[FromRoute] - Query string:
[FromQuery] - Header:
[FromHeader] - Body:
[FromBody] - Form:
[FromForm] - Service:
[FromServices] - Parameter values:
[AsParameters] - Các kiểu đặc biệt
HttpContextHttpRequestHttpResponseClaimsPrincipalCancellationTokenIFormCollectionIFormFileCollectionIFormFileStreamPipeReader- Kiểu tham số có phương thức
BindAsyncstatic hợp lệ. - Kiểu tham số là string hoặc có phương thức
TryParsestatic hợp lệ. - Nếu kiểu tham số là service được cung cấp bởi dependency injection, sử dụng service đó.
- Tham số lấy từ body.
Cấu hình JSON deserialization options cho body binding
Cấu hình JSON deserialization options toàn cục
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; }
}Cấu hình JSON deserialization options cho endpoint
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();Đọc request body
Đọc request body trực tiếp dùng tham số HttpContext hoặc 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();