关于 dotnet-webapi
为处理 ASP.NET Core HTTP API 的开发工作提供指导,面向实现或维护端点的开发者。它要求遵循项目既有风格,并涵盖 DTO、HTTP 语义、OpenAPI 文档与错误处理。
主要功能
- 01控制器与最小 API 风格判定
- 02请求和响应 DTO 设计
- 03HTTP 状态码与语义约定
- 04OpenAPI 端点文档
- 05集中式错误处理建议
适用场景
- 01为现有 API 添加或修改端点
- 02为端点配置 OpenAPI 元数据
- 03统一请求响应类型与状态码
- 04设置 API 错误处理中间件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill dotnet-webapi安装后在 Claude Code 中输入/dotnet-webapi确认已加载。
详细介绍
ASP.NET Core Web API
生成结构清晰的 ASP.NET Core Web API 端点,并确保遵循正确的 HTTP 语义,包含 OpenAPI 文档和错误处理。
适用场景
在处理 ASP.NET Core HTTP API 时使用此技能,包括:
- 添加或修改通过控制器或 minimal API 实现的 Web API 端点;
- 配置 OpenAPI/Swagger 元数据和端点文档;
- 定义请求/响应 DTO,并统一 HTTP 状态码行为;
- 添加
.http文件或类似的基于请求的 API 测试文件; - 配置集中式 API 错误处理中间件或异常映射。
不适用场景
以下情况不要使用此技能:
- 常规 C# 编码风格或非 API 重构;
- EF Core 数据建模或查询优化工作;请使用
optimizing-ef-core-queries; - 前端、Razor 或 Blazor UI 修改;
- gRPC 服务;
- SignalR 中心或实时消息流程。
输入 / 前提条件
应用此技能前,先收集项目上下文,以便匹配现有 API 风格和配置方式:
- ASP.NET Core 入口点,通常为
Program.cs; - 现有控制器,尤其是继承
ControllerBase或使用[ApiController]的类; - 现有的 minimal API 注册,例如
app.MapGet、app.MapPost、app.MapPut或app.MapDelete; - 项目已使用的相关 DTO、模型、验证和错误处理类型;
- 可用于验证更改的构建、运行和测试命令。
如果用户要求新增端点,请先检查当前项目结构,确保实现遵循既有约定,而不是混用不同风格。
工作流
第 1 步:确定 API 风格
编写代码前,先检查项目中现有的端点模式。
- 搜索继承
ControllerBase或标注[ApiController]的类。 - 在
Program.cs或端点文件中搜索app.MapGet、app.MapPost等。 - 如果项目已使用控制器,则继续使用控制器。
- 如果项目已使用minimal API,则继续使用 minimal API。
- 如果两者都不存在(新项目),则默认使用 minimal API,除非用户明确要求使用控制器。
不要在同一个项目中混用不同风格。
第 2 步:定义请求和响应类型
为 API 输入和输出创建专用类型。切勿在请求或响应正文中直接暴露 EF Core 实体。
所有 DTO 均使用 sealed record。 Record 可确保不可变性、提供基于值的相等性,并生成简洁的代码。将其设为密封类型,以防止意外继承并启用 JIT 去虚拟化(CA1852)。
命名约定:
| 角色 | 约定 | 示例 |
|---|---|---|
| 输入(创建) | Create{Entity}Request | CreateProductRequest |
| 输入(更新) | Update{Entity}Request | UpdateProductRequest |
| 输出(单个) | {Entity}Response | ProductResponse |
| 输出(列表) | {Entity}ListResponse | ProductListResponse |
为所有 DTO 添加 XML 文档注释: 为 API 中公开的每个请求和响应类型添加 <summary> XML 文档注释。这些注释会自动包含在生成的 OpenAPI 规范中,无需额外的元数据调用即可提供更丰富的文档。
参考:https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments
日期和时间值 — 使用 DateTimeOffset: DTO 包含日期或时间属性时,始终使用 DateTimeOffset,而不是 DateTime。DateTimeOffset 能保留 UTC 偏移量,避免产生模糊的时区转换,并在 JSON 中序列化为包含偏移信息的 ISO 8601 格式,这正是 API 使用者所期望的。
参考:https://learn.microsoft.com/en-us/dotnet/api/system.datetimeoffset JSON 序列化选项 — 默认保留现有行为: 对于现有 API,除非项目已经使用更严格的序列化/反序列化设置,或用户明确要求,否则不要引入此类设置。区分大小写的属性匹配和严格的数字处理等设置可能会破坏现有客户端。对于新项目,或明确要求严格 JSON 处理时,可配置如下选项,以尽量降低处理恶意请求的风险:
// Apply these settings only for new projects, when the existing project already
// uses them, or when the user explicitly requests stricter JSON behavior.
builder.Services.ConfigureHttpJsonOptions(options =>
{
// disallow reading numbers from JSON strings
options.SerializerOptions.NumberHandling = JsonNumberHandling.Strict;
// match properties with exact casing during deserialization
options.SerializerOptions.PropertyNameCaseInsensitive = false;
// reject duplicate JSON property names during deserialization
options.SerializerOptions.AllowDuplicateProperties = false;
// omit null properties from serialized output
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
枚举属性 — 默认序列化为字符串: 除非用户明确要求序列化为整数,否则所有枚举属性都应序列化为字符串。将枚举序列化为字符串可提高可读性,在值的顺序调整后也更稳定,并能生成更好的 OpenAPI 文档。有关 JsonStringEnumConverter 的配置,请参阅第 4 步。
响应 DTO — 使用位置参数形式的密封 record,实现简洁、不可变的输出:
/// <summary>Represents a product returned by the API.</summary>
public sealed record ProductResponse(
int Id,
string Name,
decimal Price,
Category Category,
bool IsAvailable,
DateTimeOffset CreatedAt);
请求 DTO — 使用带 init 属性的密封 record,让数据注解能够自然生效:
/// <summary>Payload for creating a new product.</summary>
public sealed record CreateProductRequest
{
[Required, MaxLength(200)]
public required string Name { get; init; }
[Range(0.01, 999999.99)]
public required decimal Price { get; init; }
public required Category Category { get; init; }
}
Update{Entity}Request record 也遵循相同模式,并添加更新所需的其他属性(例如 IsAvailable)。
Minimal API 验证 — 显式注册: 数据注解([Required]、[MaxLength]、[Range] 等)在 MVC 控制器中会自动生效,但 Minimal API 需要显式启用。对于使用 Minimal API 的 .NET 10+ 项目,请在 Program.cs 中添加验证服务:
builder.Services.AddValidation();
这会注册一个端点筛选器,在处理程序执行前验证使用数据注解修饰的参数;验证失败时返回包含验证问题详情的 400 Bad Request 响应。
参考:https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-10.0
不要为 DTO 使用可变类({ get; set; })。可变 DTO 可能会在构造后被意外修改,也会失去 record 所提供的、可通过自身结构明确表达的不可变性。
第 3 步:实现端点
无论使用控制器还是最简 API,都应始终遵循以下 HTTP 约定。
组织最简 API 端点: 对于使用最简 API 的项目,按资源组织端点,为每种资源创建一个静态类,并在类中定义静态 Map<Resource> 方法。这种模式能按资源类型归类端点定义,使代码更易维护,也更便于随着 API 扩展而查找。
模式结构:
- 每种资源创建一个静态类(例如,
ProductEndpoints、CategoryEndpoints)。 - 定义一个静态
Map<Resource>(this WebApplication app)扩展方法。 - 在方法中,为该资源的端点调用
MapGet、MapPost、MapPut、MapDelete等方法。 - 在
Program.cs中,依次调用每种资源的Map方法。
最简 API 返回类型——优先使用 TypedResults:
始终优先使用 TypedResults,而不是 Results 工厂。TypedResults 会在方法签名中嵌入响应类型信息,自动为 OpenAPI 生成器提供更丰富的元数据。
当处理程序返回多种结果类型(例如,Ok 或 NotFound)时,请为 lambda 添加显式的 Results<T1, T2> 返回类型注解。这样既可以使用 TypedResults,又能为编译器提供一个通用类型:
async Task<Results<Ok<ProductResponse>, NotFound>> (int id, ...) => ...
不要在没有显式返回类型注解的裸三元表达式中使用 TypedResults.Ok(x) 和 TypedResults.NotFound()。Ok<T> 和 NotFound 是不同类型,编译器无法推断出它们的共同基类,因此会回退到匹配 RequestDelegate(HttpContext),并导致 CS1593: Delegate 'RequestDelegate' does not take N arguments。
备用方案——Results 工厂: 如果处理程序有许多条件分支(7 种以上结果类型),可以使用返回 IResult 的 Results 工厂(Results.Ok()、Results.NotFound()),以更简单的签名换取编译时 OpenAPI 推断能力。
状态码:
| 操作 | 成功 | 常见错误 |
|---|---|---|
| GET(单条) | 200 OK | 404 Not Found |
| GET(列表) | 200 OK | — |
| POST(创建) | 201 Created,带有 Location 标头 | 400 Bad Request、409 Conflict |
| PUT(完整更新) | 200 OK | 400 Bad Request、404 Not Found |
| PATCH(部分更新/操作) | 200 OK | 400 Bad Request、404 Not Found |
| DELETE | 204 No Content | 404 Not Found、409 Conflict |
POST 201 响应: 始终返回一个指向新建资源的 Location 标头。
- 控制器:使用
CreatedAtAction(nameof(GetById), new { id = ... }, response) - 最简 API:使用
TypedResults.Created($"/api/products/{id}", response)
CancellationToken: 在每个端点签名中接受 CancellationToken,并将其传递给所有异步调用(服务方法、EF Core 查询、HttpClient 调用)。这样客户端断开连接时,服务器便可停止处理。
// Controller example
[HttpGet("{id}")]
public async Task<ActionResult<ProductResponse>> GetById(
int id, CancellationToken cancellationToken)
{
var product = await _productService.GetByIdAsync(id, cancellationToken);
return product is null ? NotFound() : Ok(product);
}
// Minimal API example — TypedResults with explicit return type (recommended)
app.MapGet("/api/products/{id}", async Task<Results<Ok<ProductResponse>, NotFound>> (
int id, IProductService service, CancellationToken cancellationToken) =>
{
var product = await service.GetByIdAsync(id, cancellationToken);
return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
});
步骤 4:配置 OpenAPI
每个 ASP.NET Core Web API 都应该有 OpenAPI 文档。在添加之前,先检查项目是否已经配置了 OpenAPI。
对于 .NET 9+ 项目,使用 ASP.NET Core 内置的 OpenAPI 支持(在开发环境中使用 builder.Services.AddOpenApi() + app.MapOpenApi())。
只需这些配置,无需添加其他程序包。
不要向 .NET 9+ 项目添加任何 Swashbuckle.* NuGet 程序包(Swashbuckle.AspNetCore、Swashbuckle.AspNetCore.SwaggerUI、Swashbuckle.AspNetCore.SwaggerGen 等)。Swashbuckle 与 .NET 9+ 和 .NET 10 的 OpenAPI 类型存在已知兼容性问题。对于面向 .NET 8 或更早版本的项目,可以使用 Swashbuckle。如果项目已经安装了 Swashbuckle,除非用户要求移除,否则请保留。
参考:https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview
**端点上的 OpenAPI 元数据:**添加描述性元数据,让生成的文档更实用,而不只是路由列表。对于最小 API,请链式调用元数据方法:
app.MapGet("/api/products/{id}", handler)
.WithName("GetProductById")
.WithSummary("Get a product by ID")
.WithDescription("Returns the full product details including category.")
.Produces<ProductResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
**枚举序列化(默认使用字符串):**配置 JSON 序列化,让枚举在 API 响应和 OpenAPI 架构中都显示为易读的字符串。除非用户明确要求将枚举序列化为整数,否则始终添加此配置。最小 API 和控制器使用不同的选项类型,因此两者都要配置:
// Minimal APIs
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// Controllers / MVC
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
步骤 5:设置错误处理
使用全局异常处理程序,这样各个端点就不需要 try-catch 块。所有错误响应都返回 RFC 7807 Problem Details。
对于 .NET 8+ 项目,优先使用内置的异常处理程序中间件:
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
app.UseStatusCodePages();
如果项目需要自定义异常到状态码的映射(例如,NotFoundException 应返回 404),请实现 IExceptionHandler:
internal sealed class ApiExceptionHandler(ILogger<ApiExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
var (statusCode, title) = exception switch
{
KeyNotFoundException => (StatusCodes.Status404NotFound, "Not Found"),
ArgumentException => (StatusCodes.Status400BadRequest, "Bad Request"),
InvalidOperationException => (StatusCodes.Status409Conflict, "Conflict"),
_ => (0, (string?)null)
};
if (statusCode == 0)
return false; // Let the default handler deal with it
// Important: returning true below suppresses the exception diagnostics middleware
// for this exception, so ensure it is logged/telemetrized before returning.
logger.LogWarning(exception, "Handled API exception: {Title}", title);
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = title,
// Do not use exception.Message here — it may leak sensitive internal details.
// Use a safe, user-facing message instead.
Detail = title,
Instance = httpContext.Request.Path
}, cancellationToken);
return true;
}
}
注册它:
builder.Services.AddExceptionHandler<ApiExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
**文件位置:**始终将异常处理程序类放在 Middleware/ 文件夹中,以保持项目组织结构一致。不要将它们放在项目根目录。
步骤 6:使用服务层
不要直接将数据存储注入控制器或端点处理程序。创建一个服务接口和一个封闭实现类,由它负责数据访问逻辑以及实体与请求/响应类型之间的映射。
始终为每个服务定义一个接口,这样可以使用模拟对象进行单元测试,并遵循依赖倒置原则:
// Services/IProductService.cs
public interface IProductService
{
Task<IReadOnlyList<ProductResponse>> GetAllAsync(CancellationToken ct);
Task<ProductResponse?> GetByIdAsync(int id, CancellationToken ct);
Task<ProductResponse> CreateAsync(CreateProductRequest request, CancellationToken ct);
}
// Services/ProductService.cs
public sealed class ProductService(...) : IProductService
{
// Data access logic, entity-to-DTO mapping
}
注册时使用接口,而不是具体类型:
// In Program.cs
builder.Services.AddScoped<IProductService, ProductService>();
有关 EF Core 数据访问模式(迁移、Fluent API 配置、AsNoTracking、种子数据),请参阅 optimizing-ef-core-queries 技能。
步骤 7:创建 .http 测试文件
实现端点后,在项目根目录创建一个 .http 文件,演示如何调用每个新增端点。该文件可作为动态文档和快速手动测试工具。
@baseUrl = http://localhost:5000
### Get all products
GET {{baseUrl}}/api/products
### Get product by ID
GET {{baseUrl}}/api/products/1
### Create a product
POST {{baseUrl}}/api/products
Content-Type: application/json
{
"name": "Wireless Mouse",
"price": 29.99,
"category": "Electronics"
}
### Delete a product
DELETE {{baseUrl}}/api/products/1
每个端点至少包含一个使用真实请求体的请求。展示错误场景(例如,不存在的 ID)。端口应与 launchSettings.json 中的端口一致。
步骤 8:构建并验证
- 运行
dotnet build,确认没有错误和警告。 - 启动应用并验证 OpenAPI 文档可以加载(默认路径:
/openapi/v1.json)。 - 运行
.http文件中的请求,并确认状态码正确。
验证
- 所有端点都按照步骤 3 中的表格返回正确的 HTTP 状态码
- POST 端点返回
201 Created,并带有Location标头 - DELETE 端点返回
204 No Content - 每个端点签名都包含
CancellationToken -
CancellationToken会传递给所有下游异步调用 - 已生成 OpenAPI 文档,且包含所有新增端点
- 端点包含 OpenAPI 摘要/描述元数据
- 枚举值在 JSON 响应和 OpenAPI 架构中以字符串形式呈现(除非用户明确要求以整数序列化)
- 错误响应使用 RFC 7807 Problem Details 格式
- API 请求/响应正文不会直接暴露领域实体
- 所有对 API 公开的 DTO 都有
<summary>XML 文档注释 - 日期和时间属性使用
DateTimeOffset,而不是DateTime - 存在
.http文件,且其中包含每个新增端点的请求 -
dotnet build通过,错误和警告均为零 - 所有 DTO 都是
sealed record类型(而非可变类) - 最小 API 处理程序使用
TypedResults,并显式指定Results<T1, T2>返回类型 - 每项服务都有对应的接口,并已注册到 DI 中
- 异常处理程序放在
Middleware/文件夹中
常见问题
| 问题 | 解决方案 |
|---|---|
| 将领域实体作为 API 响应公开 | 创建单独的 sealed record 请求/响应类型。实体会泄露导航属性和内部字段。 |
忘记 CancellationToken | 将其添加到每个端点,并在整个异步调用链中传递。 |
创建资源的 POST 请求返回 200 OK | 返回 201 Created,并带有 Location 标头。 |
| 缺少 OpenAPI 元数据 | 在每个端点上链式调用 .WithName()、.WithSummary()、.WithDescription() 和 .Produces<T>()。 |
| 直接向端点注入数据存储 | 使用服务层和接口,以实现职责分离并便于测试。 |
| 混用控制器和最小 API 风格 | 每个项目选择一种风格并保持一致。 |
在三元表达式中使用 TypedResults,但未显式指定返回类型 | Ok<T> 和 NotFound 没有共同基类——将返回类型标注为 Task<Results<Ok<T>, NotFound>>,或改用 Results 工厂。 |
| 为 DTO 使用可变类 | 使用 sealed record:响应采用位置参数语法,请求采用 init 属性。 |
| 注册服务时未使用接口 | 定义 IService,并使用 AddScoped<IService, Service>() 进行注册。 |
向新的 .NET 9+ 项目添加任何 Swashbuckle.* 包 | 使用内置的 AddOpenApi() + MapOpenApi()。不要添加 Swashbuckle.AspNetCore、Swashbuckle.AspNetCore.SwaggerUI 或任何其他 Swashbuckle 包。 |
| DTO 缺少 XML 文档注释 | 为每种请求和响应类型添加 <summary> XML 文档注释。这些注释会自动写入生成的 OpenAPI 规范。 |
日期/时间属性使用 DateTime | 改用 DateTimeOffset——它会保留 UTC 偏移量,避免时区歧义,并能在 JSON 中正确序列化。 |
| 将枚举序列化为整数 | 配置 JsonStringEnumConverter,使枚举默认序列化为字符串。只有在用户明确要求时才以整数序列化。 |
更多信息
- ASP.NET Core Web API 概述 — 构建 Web API 的基本概念
- ASP.NET Core 中的 OpenAPI — .NET 9+ 内置的 OpenAPI 支持
- 通过 XML 注释生成 OpenAPI — XML 文档注释如何写入 OpenAPI 规范
- Minimal APIs 概述 — 路由、参数绑定和响应类型
- 在 ASP.NET Core API 中处理错误 — Problem Details 和异常处理
- DateTimeOffset — API 中日期/时间值的首选类型