关于 dotnet-minimal-api-endpoints
为 .NET Minimal API 提供端点设计与实现指南,包含 CRUD、路由分组、验证、授权和测试模板。适合构建按功能组织的 HTTP API。
主要功能
- 01CRUD 端点与请求 DTO 模板
- 02MapGroup 路由组织
- 03TypedResults 类型化响应
- 04端点过滤器与授权示例
- 05测试及 OpenAPI 文档模式
适用场景
- 01为功能模块搭建 Minimal API 端点
- 02组织带授权和验证的路由组
- 03编写可测试且具备 OpenAPI 元数据的端点
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ ronnythedev/ dotnet-clean-architecture-skills --skill dotnet-minimal-api-endpoints安装后在 Claude Code 中输入/dotnet-minimal-api-endpoints确认已加载。
详细介绍
Minimal API 端点生成器
概述
Microsoft 推荐新项目采用此方法。 Minimal API 提供了一种简化且高性能的 HTTP API 构建方式,相比控制器需要编写的样板代码更少。
相比控制器的优势
- ✅ 语法更简单 - 代码更少,效率更高
- ✅ 性能更好 - 开销更低
- ✅ 更易测试 - 处理程序方法可测试
- ✅ 采用现代方式 - 使用最新的 .NET 功能
- ✅ 流程更简洁 - 无需控制器类
快速参考
| HTTP 方法 | 扩展方法 | 使用场景 |
|---|---|---|
MapGet | 读取单条/列表 | app.MapGet("/users/{id}", ...) |
MapPost | 创建 | app.MapPost("/users", ...) |
MapPut | 更新(完整) | app.MapPut("/users/{id}", ...) |
MapDelete | 删除 | app.MapDelete("/users/{id}", ...) |
端点结构
/API/Endpoints/
├── {Feature}/
│ ├── {Feature}Endpoints.cs
│ └── Request{Action}{Entity}.cs
└── ...
模板:完整的 CRUD 端点
// src/{name}.api/Endpoints/{Feature}/{Feature}Endpoints.cs
using {name}.application.{feature}.Create{Entity};
using {name}.application.{feature}.Delete{Entity};
using {name}.application.{feature}.Get{Entity}ById;
using {name}.application.{feature}.Get{Entities};
using {name}.application.{feature}.Update{Entity};
using {name}.infrastructure.authorization;
using MediatR;
using Microsoft.AspNetCore.Http.HttpResults;
namespace {name}.api.Endpoints.{Feature};
/// <summary>
/// Endpoints for {Entity} management
/// </summary>
public static class {Feature}Endpoints
{
/// <summary>
/// Maps all {Entity} endpoints
/// </summary>
public static RouteGroupBuilder Map{Feature}Endpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/{entities}")
.WithTags("{Feature}")
.RequireAuthorization();
group.MapGet("/{id:guid}", GetById)
.WithName("Get{Entity}ById")
.WithSummary("Get {entity} by ID")
.Produces<{Entity}Response>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/", GetAll)
.WithName("GetAll{Entities}")
.WithSummary("Get all {entities}")
.Produces<IReadOnlyList<{Entity}ListResponse>>(StatusCodes.Status200OK);
group.MapPost("/", Create)
.WithName("Create{Entity}")
.WithSummary("Create new {entity}")
.RequireAuthorization(Permissions.{Entities}Write)
.Produces<Guid>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
group.MapPut("/{id:guid}", Update)
.WithName("Update{Entity}")
.WithSummary("Update existing {entity}")
.RequireAuthorization(Permissions.{Entities}Write)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapDelete("/{id:guid}", Delete)
.WithName("Delete{Entity}")
.WithSummary("Delete {entity}")
.RequireAuthorization(Permissions.{Entities}Write)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound);
return group;
}
// ═══════════════════════════════════════════════════════════════
// HANDLER METHODS
// ═══════════════════════════════════════════════════════════════
/// <summary>
/// Gets an {entity} by ID
/// </summary>
private static async Task<Results<Ok<{Entity}Response>, NotFound>> GetById(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
var query = new Get{Entity}ByIdQuery(id);
var result = await sender.Send(query, cancellationToken);
return result.IsSuccess
? TypedResults.Ok(result.Value)
: TypedResults.NotFound();
}
/// <summary>
/// Gets all {entities}
/// </summary>
private static async Task<Ok<IReadOnlyList<{Entity}ListResponse>>> GetAll(
ISender sender,
CancellationToken cancellationToken)
{
var query = new GetAll{Entities}Query();
var result = await sender.Send(query, cancellationToken);
return TypedResults.Ok(result.Value);
}
/// <summary>
/// Creates a new {entity}
/// </summary>
private static async Task<Results<Created<Guid>, BadRequest<Error>>> Create(
RequestCreate{Entity} request,
ISender sender,
CancellationToken cancellationToken)
{
var command = new Create{Entity}Command(
request.Name,
request.Description);
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? TypedResults.Created($"/api/{entities}/{result.Value}", result.Value)
: TypedResults.BadRequest(result.Error);
}
/// <summary>
/// Updates an existing {entity}
/// </summary>
private static async Task<Results<NoContent, NotFound, BadRequest<Error>>> Update(
Guid id,
RequestUpdate{Entity} request,
ISender sender,
CancellationToken cancellationToken)
{
var command = new Update{Entity}Command(id, request.Name, request.Description);
var result = await sender.Send(command, cancellationToken);
if (result.IsFailure)
{
return result.Error.Code.Contains("NotFound")
? TypedResults.NotFound()
: TypedResults.BadRequest(result.Error);
}
return TypedResults.NoContent();
}
/// <summary>
/// Deletes an {entity}
/// </summary>
private static async Task<Results<NoContent, NotFound>> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
var command = new Delete{Entity}Command(id);
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? TypedResults.NoContent()
: TypedResults.NotFound();
}
}
模板:请求 DTO
// src/{name}.api/Endpoints/{Feature}/RequestCreate{Entity}.cs
namespace {name}.api.Endpoints.{Feature};
/// <summary>
/// Request to create a new {entity}
/// </summary>
public sealed record RequestCreate{Entity}(
string Name,
string? Description);
/// <summary>
/// Request to update an existing {entity}
/// </summary>
public sealed record RequestUpdate{Entity}(
string Name,
string? Description);
模板:Program.cs 注册
// src/{name}.api/Program.cs
using {name}.api.Endpoints.{Feature};
using {name}.application;
using {name}.infrastructure;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
// Map endpoints
app.Map{Feature}Endpoints();
// Add more endpoint groups here
app.Run();
模板:使用 MapGroup 构建复杂端点
// src/{name}.api/Endpoints/{Feature}/{Feature}Endpoints.cs
public static class {Feature}Endpoints
{
public static RouteGroupBuilder Map{Feature}Endpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/{entities}")
.WithTags("{Feature}")
.RequireAuthorization();
// Standard CRUD
group.MapGet("/{id:guid}", GetById);
group.MapGet("/", GetAll);
group.MapPost("/", Create);
group.MapPut("/{id:guid}", Update);
group.MapDelete("/{id:guid}", Delete);
// Custom operations
group.MapPost("/{id:guid}/activate", Activate)
.WithName("Activate{Entity}")
.RequireAuthorization(Permissions.{Entities}Write);
group.MapPost("/{id:guid}/deactivate", Deactivate)
.WithName("Deactivate{Entity}")
.RequireAuthorization(Permissions.{Entities}Write);
// Search
group.MapGet("/search", Search)
.WithName("Search{Entities}")
.AllowAnonymous();
// Related resources
var childrenGroup = group.MapGroup("/{parentId:guid}/children")
.WithTags("{Feature} - Children");
childrenGroup.MapGet("/", GetChildren);
childrenGroup.MapPost("/", AddChild);
childrenGroup.MapDelete("/{childId:guid}", RemoveChild);
return group;
}
private static async Task<Results<Ok, BadRequest<Error>>> Activate(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
var command = new Activate{Entity}Command(id);
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? TypedResults.Ok()
: TypedResults.BadRequest(result.Error);
}
private static async Task<Ok<IReadOnlyList<{Entity}Response>>> Search(
string? term,
int pageNumber,
int pageSize,
ISender sender,
CancellationToken cancellationToken)
{
var query = new Search{Entities}Query(term, pageNumber, pageSize);
var result = await sender.Send(query, cancellationToken);
return TypedResults.Ok(result.Value);
}
}
模板:端点筛选器(验证)
// src/{name}.api/Endpoints/Filters/ValidationFilter.cs
using {name}.domain.abstractions;
namespace {name}.api.Endpoints.Filters;
/// <summary>
/// Endpoint filter for validation
/// </summary>
public class ValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var request = context.Arguments.OfType<T>().FirstOrDefault();
if (request is null)
{
return await next(context);
}
var validationResult = await _validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
return TypedResults.BadRequest(new Error(
"Validation.Failed",
string.Join(", ", validationResult.Errors.Select(e => e.ErrorMessage))));
}
return await next(context);
}
}
// Usage in endpoint
group.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<RequestCreate{Entity}>>();
模板:授权
// Multiple authorization options
// 1. Require authentication for all endpoints in group
var group = routes.MapGroup("/api/{entities}")
.RequireAuthorization();
// 2. Specific permission on endpoint
group.MapPost("/", Create)
.RequireAuthorization(Permissions.{Entities}Write);
// 3. Multiple policies
group.MapDelete("/{id:guid}", Delete)
.RequireAuthorization(Permissions.{Entities}Write, Permissions.Admin);
// 4. Allow anonymous (override group auth)
group.MapGet("/public", GetPublicData)
.AllowAnonymous();
// 5. Roles
group.MapPost("/admin/action", AdminAction)
.RequireAuthorization(policy => policy.RequireRole("Admin", "SuperAdmin"));
模板:使用 MapGroup 进行 API 版本控制
// src/{name}.api/Endpoints/{Feature}/{Feature}Endpoints.cs
public static class {Feature}Endpoints
{
public static void Map{Feature}Endpoints(this IEndpointRouteBuilder routes)
{
// Version 1
var v1 = routes.MapGroup("/api/v1/{entities}")
.WithTags("{Feature} V1")
.HasApiVersion(1.0);
v1.MapGet("/{id:guid}", GetByIdV1);
v1.MapPost("/", CreateV1);
// Version 2 with breaking changes
var v2 = routes.MapGroup("/api/v2/{entities}")
.WithTags("{Feature} V2")
.HasApiVersion(2.0);
v2.MapGet("/{id:guid}", GetByIdV2);
v2.MapPost("/", CreateV2);
}
}
TypedResults 模式
始终使用 TypedResults,以获得类型安全且易于测试的响应:
// ✅ CORRECT: TypedResults with union return type
private static async Task<Results<Ok<UserResponse>, NotFound>> GetUser(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
var result = await sender.Send(new GetUserQuery(id), cancellationToken);
return result.IsSuccess
? TypedResults.Ok(result.Value)
: TypedResults.NotFound();
}
// ❌ WRONG: Non-typed Results
private static async Task<IResult> GetUser(Guid id, ISender sender)
{
var result = await sender.Send(new GetUserQuery(id));
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
}
TypedResults 的优势
- ✅ 编译时安全 - 在编译时捕获错误
- ✅ 更易测试 - 针对特定结果类型进行断言
- ✅ OpenAPI 元数据 - 自动生成 Swagger 文档
- ✅ IntelliSense 支持 - 带来更好的 IDE 体验
测试 Minimal API 端点
// Unit test for endpoint handler
public class {Feature}EndpointsTests
{
[Fact]
public async Task GetById_ExistingId_ReturnsOk()
{
// Arrange
var sender = Substitute.For<ISender>();
var response = new {Entity}Response(Guid.NewGuid(), "Test");
sender.Send(Arg.Any<Get{Entity}ByIdQuery>(), Arg.Any<CancellationToken>())
.Returns(Result.Success(response));
// Act
var result = await {Feature}Endpoints.GetById(
Guid.NewGuid(),
sender,
CancellationToken.None);
// Assert - Type-safe assertion
Assert.IsType<Ok<{Entity}Response>>(result.Result);
}
[Fact]
public async Task GetById_NonExistingId_ReturnsNotFound()
{
// Arrange
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<Get{Entity}ByIdQuery>(), Arg.Any<CancellationToken>())
.Returns(Result.Failure<{Entity}Response>({Entity}Errors.NotFound));
// Act
var result = await {Feature}Endpoints.GetById(
Guid.NewGuid(),
sender,
CancellationToken.None);
// Assert
Assert.IsType<NotFound>(result.Result);
}
}
OpenAPI / Swagger 文档
group.MapGet("/{id:guid}", GetById)
.WithName("Get{Entity}ById")
.WithSummary("Get {entity} by ID")
.WithDescription("Retrieves a single {entity} by its unique identifier")
.WithOpenApi()
.Produces<{Entity}Response>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest);
组织端点
选项 1:每个功能使用一个静态类(推荐)
/Endpoints/
├── Users/
│ ├── UsersEndpoints.cs
│ └── Requests.cs
├── Products/
│ ├── ProductsEndpoints.cs
│ └── Requests.cs
选项 2:在 IEndpointRouteBuilder 上使用扩展方法
public static class EndpointExtensions
{
public static IEndpointRouteBuilder MapAllEndpoints(this IEndpointRouteBuilder routes)
{
routes.MapUsersEndpoints();
routes.MapProductsEndpoints();
routes.MapOrdersEndpoints();
return routes;
}
}
// In Program.cs
app.MapAllEndpoints();
关键规则
- 使用 TypedResults - 始终优先选择 TypedResults,而不是 Results
- 处理程序使用静态方法 - 更易测试
- 使用 MapGroup 组织端点 - 将相关端点分组
- XML 文档 - 为所有公共方法编写文档
- 显式返回类型 - 使用 Results<T1, T2, ...> 联合类型
- 始终传递 CancellationToken - 将其传递给所有异步操作
- 路由约束 - 使用
{id:guid}、{id:int}等 - 默认启用授权 - 在组上调用 RequireAuthorization
- 使用 WithName 生成链接 - 为 CreatedAtRoute 使用命名路由
- 按功能组织 - 而不是按 HTTP 动词组织
应避免的反模式
// ❌ WRONG: Lambdas with business logic
app.MapPost("/users", async (CreateUserRequest request, IUserRepository repo) =>
{
if (await repo.ExistsByEmail(request.Email))
return Results.BadRequest("Email exists");
var user = new User { Email = request.Email };
repo.Add(user);
await repo.SaveAsync();
return Results.Created($"/users/{user.Id}", user);
});
// ✅ CORRECT: Handler method calls command via MediatR
app.MapPost("/users", Create);
private static async Task<Results<Created<Guid>, BadRequest<Error>>> Create(
CreateUserRequest request,
ISender sender,
CancellationToken cancellationToken)
{
var command = new CreateUserCommand(request.Email);
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? TypedResults.Created($"/users/{result.Value}", result.Value)
: TypedResults.BadRequest(result.Error);
}
// ❌ WRONG: Returning IResult (not type-safe)
private static async Task<IResult> GetUser(Guid id)
// ✅ CORRECT: Explicit return type with TypedResults
private static async Task<Results<Ok<UserResponse>, NotFound>> GetUser(Guid id)
// ❌ WRONG: Controllers in Minimal API project
public class UsersController : ControllerBase { }
// ✅ CORRECT: Static endpoint classes
public static class UsersEndpoints { }
相关技能
02-dotnet-cqrs-command-generator- 为端点生成命令03-dotnet-cqrs-query-generator- 为端点生成查询08-dotnet-result-pattern- 处理端点结果12-dotnet-jwt-authentication- 添加身份验证13-dotnet-permission-authorization- 添加授权01-dotnet-clean-architecture- 整体架构
从控制器迁移
如果要从控制器迁移:
// Before: Controller
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
[HttpGet("{id}")]
public async Task<ActionResult<UserResponse>> GetById(Guid id)
{
// ...
}
}
// After: Minimal API
public static class UsersEndpoints
{
public static RouteGroupBuilder MapUsersEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/users");
group.MapGet("/{id:guid}", GetById);
return group;
}
private static async Task<Results<Ok<UserResponse>, NotFound>> GetById(
Guid id,
ISender sender,
CancellationToken cancellationToken)
{
// ...
}
}
Minimal API 是 Microsoft 推荐的方案。与控制器相比,它具有更好的性能、代码更简洁,也更易于测试。
评论
支持 Markdown