mcpField
D
dotnet-minimal-api-endpoints

dotnet-minimal-api-endpoints

github.com/ronnythedev开发7761 次安装15 周前更新
代码仓库

关于 dotnet-minimal-api-endpoints

为 .NET Minimal API 提供端点设计与实现指南,包含 CRUD、路由分组、验证、授权和测试模板。适合构建按功能组织的 HTTP API。

主要功能

  1. 01CRUD 端点与请求 DTO 模板
  2. 02MapGroup 路由组织
  3. 03TypedResults 类型化响应
  4. 04端点过滤器与授权示例
  5. 05测试及 OpenAPI 文档模式

适用场景

  1. 01为功能模块搭建 Minimal API 端点
  2. 02组织带授权和验证的路由组
  3. 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();

关键规则

  1. 使用 TypedResults - 始终优先选择 TypedResults,而不是 Results
  2. 处理程序使用静态方法 - 更易测试
  3. 使用 MapGroup 组织端点 - 将相关端点分组
  4. XML 文档 - 为所有公共方法编写文档
  5. 显式返回类型 - 使用 Results<T1, T2, ...> 联合类型
  6. 始终传递 CancellationToken - 将其传递给所有异步操作
  7. 路由约束 - 使用 {id:guid}、{id:int} 等
  8. 默认启用授权 - 在组上调用 RequireAuthorization
  9. 使用 WithName 生成链接 - 为 CreatedAtRoute 使用命名路由
  10. 按功能组织 - 而不是按 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