请求体
路由参数和查询参数适合传递少量的简单值。要创建一个待办事项,客户端需要发送一整块结构化数据,这时就要用到请求体(request body):放在 HTTP 请求正文中的内容,Web API 中通常是 JSON。
本节的新概念:用一个 record 声明请求体的结构,框架会把 JSON 自动转换成这个类型的对象。
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [];
var nextId = 1;
app.MapGet("/todos", () => todos);
app.MapPost("/todos", (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, input.Priority, Done: false);
todos.Add(todo);
return todo;
});
app.Run();
record CreateTodo(string Title, int Priority);
record Todo(int Id, string Title, int Priority, bool Done);从本章开始,示例会围绕一个待办事项(Todo)API 逐步扩展,到「完整 CRUD」一章时,它会成为一个连接数据库的完整应用。
运行与验证
dotnet run用 POST 方法发送一个 JSON 请求体:
curl -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"title":"Buy milk","priority":2}'-X POST:使用 POST 方法;-H "Content-Type: application/json":告诉服务器请求体是 JSON;-d '...':请求体的内容。
预期响应:
{"id":1,"title":"Buy milk","priority":2,"done":false}再查看列表,刚才创建的项已经在里面了:
curl http://localhost:5080/todos[{"id":1,"title":"Buy milk","priority":2,"done":false}]提示
Windows PowerShell 对引号和反斜杠换行的处理与 bash 不同,上面的多行命令可能无法直接运行。最省事的做法是打开 /scalar 页面,找到 POST /todos,在请求体编辑框中填入 JSON 后点击发送。Scalar 会根据 OpenAPI 文档自动生成一个请求体模板。
声明请求体的结构
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [];
var nextId = 1;
app.MapGet("/todos", () => todos);
app.MapPost("/todos", (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, input.Priority, Done: false);
todos.Add(todo);
return todo;
});
app.Run();
record CreateTodo(string Title, int Priority);
record Todo(int Id, string Title, int Priority, bool Done);第 29 行用一个 record 描述了"创建待办事项时需要提供什么":一个字符串 Title 和一个整数 Priority。第 20 行处理程序的参数类型就是 CreateTodo。
本例是 POST 端点,CreateTodo 是一个没有注册为服务、没有自定义绑定的复杂类型(不是 int、string 这类简单值),因此框架推断它来自请求体——这正是上一章参数来源表格中的第三条规则。然后它会读取请求正文,用 System.Text.Json(.NET 内置的 JSON 库)反序列化成 CreateTodo 对象。
进入处理程序时,input 已经是一个完整的、强类型的对象:input.Title 是 string,input.Priority 是 int。编辑器能补全这些属性,拼错属性名会直接编译失败。
提示
JSON 属性名匹配不区分大小写。发送 {"TITLE":"Case test","Priority":1} 同样能正确绑定。响应中的属性名则统一是 camelCase,这是「第一步」中提到的 Web 默认设置。
FastAPI 对照
这和 FastAPI 中用 Pydantic 模型作为参数类型一样:def create(todo: CreateTodo)。不同之处是 record 只描述数据的形状,默认不做校验,下面马上会看到它的后果。
为什么要单独定义 CreateTodo
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [];
var nextId = 1;
app.MapGet("/todos", () => todos);
app.MapPost("/todos", (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, input.Priority, Done: false);
todos.Add(todo);
return todo;
});
app.Run();
record CreateTodo(string Title, int Priority);
record Todo(int Id, string Title, int Priority, bool Done);文件里有两个 record:第 29 行的 CreateTodo 是输入模型,描述客户端能提交什么;第 31 行的 Todo 是数据模型,描述系统里保存的是什么。为什么不直接用 Todo 作为参数类型?
因为 Todo 里有 Id 和 Done,这两个值不应该由客户端决定:Id 由服务器分配,新建的待办事项一定是未完成的。如果直接接收 Todo,客户端就可以自己指定 id 和 done。
用 CreateTodo 接收输入后,客户端根本无法设置这两个字段。试着多传几个属性:
curl -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"title":"Extra","priority":1,"id":999,"done":true}'{"id":2,"title":"Extra","priority":1,"done":false}id 和 done 被忽略了,因为 CreateTodo 里没有对应的属性。第 22 行由服务器决定这两个值。这种把输入和存储分开的做法,能避免一类叫做过度提交(over-posting)的安全问题。
注意
本章示例把数据存在一个内存中的 List 里,程序重启后数据就会消失;而且 List 和 nextId++ 都不是线程安全的,多个请求同时写入时可能出错。这只是为了让示例保持简单,「EF Core 入门」一章会换成真正的数据库。
请求体有问题时
格式错误
JSON 语法错误,或者值的类型对不上,框架会直接返回 400,处理程序不会执行:
curl -i -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"title":"x","priority":"high"}'HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "CreateTodo input" from the request body as JSON.
---> System.Text.Json.JsonException: The JSON value could not be converted to CreateTodo. Path: $.priority | LineNumber: 0 | BytePositionInLine: 30.错误信息指出了出问题的位置:$.priority,因为 "high" 不能转换成 int。
忘了 Content-Type
本教程用 Content-Type: application/json 声明 JSON 请求体;框架也接受 application/*+json 这类带 +json 后缀的媒体类型。如果携带请求体,却没有提供受支持的 JSON 媒体类型,就会返回 415 Unsupported Media Type(不支持的媒体类型):
HTTP/1.1 415 Unsupported Media Type
Content-Length: 0框架只会把声明为 JSON 的请求体当作 JSON 解析。这是一个常见的坑:请求体明明是正确的 JSON,却因为少了请求头而失败。
缺少字段
最值得注意的是这种情况:
curl -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"priority":3}'{"id":3,"title":null,"priority":3,"done":false}请求成功了,title 却是 null——尽管 CreateTodo 中 Title 的类型是不可为 null 的 string。发送空对象 {} 更离谱,会得到 title 为 null、priority 为 0 的待办事项。
原因是:「C# 速览」中提到,可空引用类型是编译期检查。JSON 反序列化发生在运行时,缺少的字符串属性会被设为 null,缺少的数字会被设为 0,没有任何报错。
反序列化只保证"格式对",不保证"内容合理"。标题不能为空、优先级只能是 1 到 5,这些业务规则需要另外检查,这就是下一章的主题。
技术细节
System.Text.Json 提供了 RespectNullableAnnotations 等选项,可以让反序列化在遇到 null 时报错。但它只能检查"是否为 null",表达不了"长度不超过 50"这类规则。下一章的校验机制能统一处理这些情况,并返回结构化的错误信息。
总结
- 在本章的 POST 端点中,普通的复杂类型参数(如
CreateTodo)默认从请求体读取 JSON;这项推断不适用于 GET 等方法或已注册为服务的类型。 - 携带 JSON 请求体时,应提供受支持的
Content-Type,本教程使用application/json;媒体类型不支持返回 415,JSON 格式错误或类型不匹配返回 400。 - 用单独的输入模型(
CreateTodo)接收请求体,与数据模型(Todo)分开,服务器控制的字段(如Id)无法被客户端篡改。 - 反序列化不检查业务规则:缺少的字段会变成
null或0,需要额外的校验。
