Claude
Skill
dotnet-minimal-apis
Design and implement Minimal APIs in ASP.NET Core using handler-first endpoints, route groups, filters, and lightweight composition suited to modern .NET services.
Virus-scanned
Reviewed automatically before listing.
Download
postpartum-genushyacinthus29-dotnet-skills-skills_dotnet-minimal-apis-bfa4ebd.zip · 11 KB
Install
skills CLI
npx skills add https://github.com/Postpartum-genushyacinthus29/dotnet-skills/tree/main/skills/dotnet-minimal-apis
Claude Code
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install postpartum-genushyacinthus29-dotnet-skills@llmmart
Git
git clone https://github.com/Postpartum-genushyacinthus29/dotnet-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole postpartum-genushyacinthus29/dotnet-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Minimal APIs
Trigger On
- building new HTTP APIs in ASP.NET Core
- creating lightweight microservices
- choosing between Minimal APIs and controllers
- organizing endpoints with route groups
- implementing validation and filters
Documentation
References
- patterns.md - detailed route groups, filters, TypedResults patterns, parameter binding, error handling, and testing
- anti-patterns.md - common Minimal API mistakes to avoid
When to Use Minimal APIs vs Controllers
| Use Minimal APIs | Use Controllers |
|---|---|
| New projects | Existing MVC/API projects |
| Microservices | Complex model binding |
| Simple CRUD APIs | OData, JsonPatch |
| Lightweight handlers | Heavy use of attributes |
| .NET 8+ projects | Need [ApiController] features |
Workflow
- Define endpoints directly in Program.cs (for small APIs)
- Use route groups for related endpoints
- Move handlers to separate classes as the API grows
- Apply filters for cross-cutting concerns
- Use TypedResults for type-safe responses
- Generate OpenAPI docs with
.WithOpenApi()
Basic Patterns
Simple Endpoints
var app = builder.Build();
app.MapGet("/", () => "Hello World");
app.MapGet("/products/{id}", (int id) => Results.Ok(new { Id = id }));
app.MapPost("/products", (Product product) => Results.Created($"/products/{product.Id}", product));
TypedResults (Strongly-Typed)
app.MapGet("/products/{id}", Results<Ok<Product>, NotFound> (int id, AppDb db) =>
{
var product = db.Products.Find(id);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
});
Dependency Injection
app.MapGet("/products", async (IProductService service) =>
{
return await service.GetAllAsync();
});
// Or with [FromServices] for clarity
app.MapGet("/products", async ([FromServices] IProductService service) =>
await service.GetAllAsync());
Route Groups
Basic Grouping
var products = app.MapGroup("/api/products");
products.MapGet("/", GetAll);
products.MapGet("/{id}", GetById);
products.MapPost("/", Create);
products.MapPut("/{id}", Update);
products.MapDelete("/{id}", Delete);
Groups with Shared Configuration
var api = app.MapGroup("/api")
.RequireAuthorization()
.AddEndpointFilter<ValidationFilter>();
var products = api.MapGroup("/products")
.WithTags("Products");
var orders = api.MapGroup("/orders")
.WithTags("Orders")
.RequireAuthorization("AdminOnly");
Endpoint Filters
Inline Filter
app.MapGet("/products/{id}", (int id) => Results.Ok(id))
.AddEndpointFilter(async (context, next) =>
{
var id = context.GetArgument<int>(0);
if (id <= 0)
return Results.BadRequest("Invalid ID");
return await next(context);
});
Class-Based Filter
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var argument = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (argument is null)
return Results.BadRequest("Invalid request body");
var validator = context.HttpContext.RequestServices
.GetService<IValidator<T>>();
if (validator is not null)
{
var result = await validator.ValidateAsync(argument);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
}
return await next(context);
}
}
// Usage
products.MapPost("/", Create)
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
Global Filters via Root Group
// All endpoints inherit filters from root group
var root = app.MapGroup("")
.AddEndpointFilter<LoggingFilter>()
.AddEndpointFilter<ErrorHandlingFilter>();
root.MapGet("/health", () => Results.Ok());
root.MapGroup("/api/products").MapGet("/", GetProducts);
Organizing Larger APIs
Extension Method Pattern
// ProductEndpoints.cs
public static class ProductEndpoints
{
public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/products")
.WithTags("Products");
group.MapGet("/", GetAll);
group.MapGet("/{id}", GetById);
group.MapPost("/", Create);
return group;
}
private static async Task<Ok<List<Product>>> GetAll(IProductService service)
=> TypedResults.Ok(await service.GetAllAsync());
private static async Task<Results<Ok<Product>, NotFound>> GetById(
int id, IProductService service)
{
var product = await service.GetByIdAsync(id);
return product is not null
? TypedResults.Ok(product)
: TypedResults.NotFound();
}
private static async Task<Created<Product>> Create(
CreateProductRequest request, IProductService service)
{
var product = await service.CreateAsync(request);
return TypedResults.Created($"/api/products/{product.Id}", product);
}
}
// Program.cs
app.MapProductEndpoints();
app.MapOrderEndpoints();
Request/Response DTOs
// Separate from domain models
public record CreateProductRequest(string Name, decimal Price);
public record UpdateProductRequest(string Name, decimal Price);
public record ProductResponse(int Id, string Name, decimal Price);
// Don't expose domain entities directly
app.MapPost("/products", (CreateProductRequest request, IMapper mapper) =>
{
var product = mapper.Map<Product>(request);
// ...
return TypedResults.Created($"/products/{product.Id}",
mapper.Map<ProductResponse>(product));
});
Anti-Patterns to Avoid
| Anti-Pattern | Why It's Bad | Better Approach |
|---|---|---|
| Everything in Program.cs | Unmaintainable | Use extension methods |
| No route groups | Repetitive config | Group related endpoints |
| Manual validation | Error-prone | Use filters + FluentValidation |
| Exposing entities | Tight coupling | Use DTOs |
| No TypedResults | No compile-time checks | Use TypedResults |
| Ignoring OpenAPI | No documentation | Add .WithOpenApi() |
OpenAPI Integration
builder.Services.AddOpenApi();
app.MapOpenApi(); // Serves OpenAPI spec
app.MapGet("/products", GetProducts)
.WithName("GetProducts")
.WithSummary("Get all products")
.WithDescription("Returns a list of all available products")
.Produces<List<Product>>(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status500InternalServerError);
Deliver
- clean, organized Minimal API endpoints
- proper use of route groups and filters
- type-safe responses with TypedResults
- OpenAPI documentation
- validation with endpoint filters
Validate
- endpoints return correct status codes
- validation filters catch invalid input
- OpenAPI spec is accurate
- route groups share common configuration
- handlers are testable (can mock dependencies)
Files (dotnet-skills)
-
references
-
anti-patterns.md 15.1 KB
# Minimal API Anti-Patterns ## Structural Anti-Patterns ### Monolithic Program.cs **Problem**: All endpoints defined directly in Program.cs becomes unmaintainable. ```csharp // BAD: Everything in Program.cs var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/products", async (AppDb db) => await db.Products.ToListAsync()); app.MapGet("/products/{id}", async (int id, AppDb db) => await db.Products.FindAsync(id)); app.MapPost("/products", async (Product p, AppDb db) => { db.Add(p); await db.SaveChangesAsync(); return p; }); // ... 50 more endpoints app.MapGet("/orders", async (AppDb db) => await db.Orders.ToListAsync()); // ... 50 more endpoints app.MapGet("/customers", async (AppDb db) => await db.Customers.ToListAsync()); // ... and so on app.Run(); ``` **Solution**: Use extension methods to organize endpoints by domain. ```csharp // GOOD: Organized via extension methods app.MapProductEndpoints(); app.MapOrderEndpoints(); app.MapCustomerEndpoints(); ``` ### Flat Route Structure Without Groups **Problem**: Repeating configuration across related endpoints. ```csharp // BAD: No route groups, repetitive configuration app.MapGet("/api/products", GetProducts) .RequireAuthorization() .WithTags("Products"); app.MapGet("/api/products/{id}", GetProductById) .RequireAuthorization() .WithTags("Products"); app.MapPost("/api/products", CreateProduct) .RequireAuthorization() .WithTags("Products") .AddEndpointFilter<ValidationFilter>(); ``` **Solution**: Use route groups to share configuration. ```csharp // GOOD: Shared configuration via groups var products = app.MapGroup("/api/products") .RequireAuthorization() .WithTags("Products"); products.MapGet("/", GetProducts); products.MapGet("/{id}", GetProductById); products.MapPost("/", CreateProduct).AddEndpointFilter<ValidationFilter>(); ``` ## Handler Anti-Patterns ### Anonymous Lambda Handlers **Problem**: Complex inline lambdas are hard to test and maintain. ```csharp // BAD: Complex inline logic app.MapPost("/products", async (CreateProductRequest request, AppDb db, IMapper mapper) => { if (string.IsNullOrEmpty(request.Name)) return Results.BadRequest("Name required"); if (request.Price <= 0) return Results.BadRequest("Price must be positive"); if (await db.Products.AnyAsync(p => p.Sku == request.Sku)) return Results.Conflict("SKU exists"); var product = mapper.Map<Product>(request); product.CreatedAt = DateTime.UtcNow; db.Products.Add(product); await db.SaveChangesAsync(); return Results.Created($"/products/{product.Id}", mapper.Map<ProductResponse>(product)); }); ``` **Solution**: Extract to named methods or service classes. ```csharp // GOOD: Extracted handler with validation filter products.MapPost("/", CreateProduct) .AddEndpointFilter<ValidationFilter<CreateProductRequest>>(); private static async Task<Results<Created<ProductResponse>, Conflict<string>>> CreateProduct( CreateProductRequest request, IProductService service) { var result = await service.CreateAsync(request); return result.Match<Results<Created<ProductResponse>, Conflict<string>>>( success => TypedResults.Created($"/products/{success.Id}", success), conflict => TypedResults.Conflict(conflict.Message)); } ``` ### Mixing Concerns in Handlers **Problem**: Handlers doing validation, mapping, business logic, and persistence. ```csharp // BAD: Handler does everything app.MapPut("/products/{id}", async (int id, UpdateProductRequest request, AppDb db) => { // Validation if (request.Price < 0) return Results.BadRequest("Invalid price"); // Fetch var product = await db.Products.FindAsync(id); if (product is null) return Results.NotFound(); // Authorization if (product.OwnerId != GetCurrentUserId()) return Results.Forbid(); // Mapping product.Name = request.Name; product.Price = request.Price; product.UpdatedAt = DateTime.UtcNow; // Persistence await db.SaveChangesAsync(); // Response mapping return Results.Ok(new ProductResponse(product.Id, product.Name, product.Price)); }); ``` **Solution**: Separate concerns using filters and services. ```csharp // GOOD: Concerns separated products.MapPut("/{id}", UpdateProduct) .AddEndpointFilter<ValidationFilter<UpdateProductRequest>>() .AddEndpointFilter<ProductOwnershipFilter>(); private static async Task<Results<Ok<ProductResponse>, NotFound>> UpdateProduct( int id, UpdateProductRequest request, IProductService service) { var result = await service.UpdateAsync(id, request); return result.Match<Results<Ok<ProductResponse>, NotFound>>( success => TypedResults.Ok(success), _ => TypedResults.NotFound()); } ``` ## Response Anti-Patterns ### Using Results Instead of TypedResults **Problem**: `Results` factory methods lose compile-time type checking. ```csharp // BAD: No compile-time checking of response types app.MapGet("/products/{id}", async (int id, AppDb db) => { var product = await db.Products.FindAsync(id); return product is not null ? Results.Ok(product) // IResult, no type info : Results.NotFound(); // IResult, no type info }); ``` **Solution**: Use `TypedResults` with union return types. ```csharp // GOOD: Compile-time checked response types app.MapGet("/products/{id}", async Task<Results<Ok<ProductDto>, NotFound>> (int id, AppDb db) => { var product = await db.Products.FindAsync(id); return product is not null ? TypedResults.Ok(product.ToDto()) : TypedResults.NotFound(); }); ``` ### Exposing Domain Entities **Problem**: Returning EF Core entities exposes internals and causes serialization issues. ```csharp // BAD: Exposing domain entity app.MapGet("/products/{id}", async (int id, AppDb db) => { return await db.Products .Include(p => p.Category) .Include(p => p.Supplier) .FirstOrDefaultAsync(p => p.Id == id); // Leaks navigation properties, internal IDs, circular references }); ``` **Solution**: Use DTOs/response records. ```csharp // GOOD: Return DTO app.MapGet("/products/{id}", async Task<Results<Ok<ProductResponse>, NotFound>> (int id, IProductService service) => { var dto = await service.GetByIdAsync(id); return dto is not null ? TypedResults.Ok(dto) : TypedResults.NotFound(); }); public record ProductResponse( int Id, string Name, decimal Price, string CategoryName, bool InStock); ``` ### Inconsistent Status Codes **Problem**: Endpoints return inconsistent status codes for similar operations. ```csharp // BAD: Inconsistent responses app.MapPost("/products", (Product p, AppDb db) => { db.Add(p); db.SaveChanges(); return Results.Ok(p); // Should be 201 Created }); app.MapPost("/orders", (Order o, AppDb db) => { db.Add(o); db.SaveChanges(); return Results.Json(o, statusCode: 200); // Also wrong }); ``` **Solution**: Follow REST conventions consistently. ```csharp // GOOD: Consistent REST responses app.MapPost("/products", async Task<Created<ProductResponse>> (CreateProductRequest request, IProductService service) => { var product = await service.CreateAsync(request); return TypedResults.Created($"/products/{product.Id}", product); }); ``` ## Validation Anti-Patterns ### Inline Validation in Handlers **Problem**: Validation logic scattered across handlers, hard to maintain and test. ```csharp // BAD: Manual validation in handler app.MapPost("/products", (CreateProductRequest request, AppDb db) => { var errors = new List<string>(); if (string.IsNullOrWhiteSpace(request.Name)) errors.Add("Name is required"); if (request.Name?.Length > 100) errors.Add("Name must be 100 characters or less"); if (request.Price <= 0) errors.Add("Price must be positive"); if (string.IsNullOrWhiteSpace(request.Sku)) errors.Add("SKU is required"); if (!Regex.IsMatch(request.Sku ?? "", @"^[A-Z]{3}-\d{4}$")) errors.Add("SKU must match format XXX-0000"); if (errors.Any()) return Results.BadRequest(new { Errors = errors }); // ... create product }); ``` **Solution**: Use validation filters with FluentValidation. ```csharp // GOOD: Declarative validation public class CreateProductValidator : AbstractValidator<CreateProductRequest> { public CreateProductValidator() { RuleFor(x => x.Name) .NotEmpty() .MaximumLength(100); RuleFor(x => x.Price) .GreaterThan(0); RuleFor(x => x.Sku) .NotEmpty() .Matches(@"^[A-Z]{3}-\d{4}$") .WithMessage("SKU must match format XXX-0000"); } } products.MapPost("/", Create) .AddEndpointFilter<ValidationFilter<CreateProductRequest>>(); ``` ### Not Returning Problem Details **Problem**: Custom error formats break client expectations. ```csharp // BAD: Non-standard error format return Results.BadRequest(new { success = false, error_code = "VALIDATION_ERROR", messages = errors }); ``` **Solution**: Use RFC 7807 Problem Details. ```csharp // GOOD: Standard Problem Details return TypedResults.ValidationProblem( errors.ToDictionary(e => e.PropertyName, e => new[] { e.ErrorMessage }), title: "Validation failed", detail: "One or more validation errors occurred"); ``` ## Dependency Injection Anti-Patterns ### Service Locator Pattern **Problem**: Using HttpContext.RequestServices directly hides dependencies. ```csharp // BAD: Service locator app.MapGet("/products", (HttpContext context) => { var db = context.RequestServices.GetRequiredService<AppDb>(); var logger = context.RequestServices.GetRequiredService<ILogger>(); var cache = context.RequestServices.GetRequiredService<IDistributedCache>(); // ... use services }); ``` **Solution**: Declare dependencies as parameters. ```csharp // GOOD: Explicit dependencies app.MapGet("/products", async ( AppDb db, ILogger<ProductsEndpoints> logger, IDistributedCache cache) => { // Dependencies are clear and testable }); ``` ### Over-Injection **Problem**: Too many parameters indicate the handler does too much. ```csharp // BAD: Too many dependencies app.MapPost("/orders", async ( CreateOrderRequest request, AppDb db, IMapper mapper, IInventoryService inventory, IPaymentService payment, IShippingService shipping, INotificationService notifications, ILogger<OrdersEndpoints> logger, IDistributedCache cache) => { // This handler orchestrates too much }); ``` **Solution**: Introduce a service to coordinate the operation. ```csharp // GOOD: Single coordinating service app.MapPost("/orders", async Task<Results<Created<OrderResponse>, BadRequest<ProblemDetails>>> (CreateOrderRequest request, IOrderService orderService) => { var result = await orderService.CreateAsync(request); return result.ToHttpResult(); }); ``` ## OpenAPI Anti-Patterns ### Missing Response Documentation **Problem**: Endpoints without OpenAPI metadata have incomplete specs. ```csharp // BAD: No OpenAPI metadata app.MapGet("/products/{id}", GetProductById); ``` **Solution**: Add comprehensive OpenAPI metadata. ```csharp // GOOD: Complete OpenAPI metadata app.MapGet("/products/{id}", GetProductById) .WithName("GetProductById") .WithSummary("Get a product by ID") .Produces<ProductResponse>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status404NotFound) .WithOpenApi(); ``` ### Inconsistent Naming **Problem**: Endpoint names don't follow conventions. ```csharp // BAD: Inconsistent naming app.MapGet("/products", GetAll).WithName("products_list"); app.MapGet("/products/{id}", GetById).WithName("GetProduct"); app.MapPost("/products", Create).WithName("create-product"); ``` **Solution**: Follow consistent naming convention. ```csharp // GOOD: Consistent PascalCase operation IDs app.MapGet("/products", GetAll).WithName("GetProducts"); app.MapGet("/products/{id}", GetById).WithName("GetProductById"); app.MapPost("/products", Create).WithName("CreateProduct"); ``` ## Security Anti-Patterns ### Missing Authorization **Problem**: Forgetting to protect sensitive endpoints. ```csharp // BAD: No authorization app.MapDelete("/products/{id}", DeleteProduct); app.MapGet("/admin/users", GetAllUsers); ``` **Solution**: Apply authorization at group level or per-endpoint. ```csharp // GOOD: Authorization applied var api = app.MapGroup("/api") .RequireAuthorization(); var admin = app.MapGroup("/admin") .RequireAuthorization("AdminOnly"); ``` ### Logging Sensitive Data **Problem**: Logging request bodies or headers that contain secrets. ```csharp // BAD: Logs sensitive data app.MapPost("/auth/login", async (LoginRequest request, ILogger logger) => { logger.LogInformation("Login attempt: {@Request}", request); // Logs password! }); ``` **Solution**: Exclude sensitive fields from logging. ```csharp // GOOD: Exclude sensitive data public record LoginRequest( string Username, [property: JsonIgnore] string Password); // Or use destructuring carefully logger.LogInformation("Login attempt for user {Username}", request.Username); ``` ## Performance Anti-Patterns ### N+1 Queries **Problem**: Lazy loading causes multiple database roundtrips. ```csharp // BAD: N+1 queries app.MapGet("/orders", async (AppDb db) => { var orders = await db.Orders.ToListAsync(); return orders.Select(o => new OrderResponse( o.Id, o.Customer.Name, // N additional queries! o.Items.Count // N additional queries! )); }); ``` **Solution**: Eager load required data. ```csharp // GOOD: Single query with includes app.MapGet("/orders", async (AppDb db) => { var orders = await db.Orders .Include(o => o.Customer) .Include(o => o.Items) .Select(o => new OrderResponse( o.Id, o.Customer.Name, o.Items.Count)) .ToListAsync(); return TypedResults.Ok(orders); }); ``` ### No Cancellation Token Support **Problem**: Long-running operations don't respect client disconnection. ```csharp // BAD: No cancellation app.MapGet("/reports/generate", async (IReportService service) => { var report = await service.GenerateLargeReportAsync(); // Continues even if client disconnects return Results.Ok(report); }); ``` **Solution**: Accept and pass cancellation tokens. ```csharp // GOOD: Cancellation token support app.MapGet("/reports/generate", async ( IReportService service, CancellationToken cancellationToken) => { var report = await service.GenerateLargeReportAsync(cancellationToken); return TypedResults.Ok(report); }); ``` ### Blocking Calls **Problem**: Synchronous operations block the thread pool. ```csharp // BAD: Blocking calls app.MapGet("/products", (AppDb db) => { var products = db.Products.ToList(); // Synchronous! Thread.Sleep(1000); // Blocking! return Results.Ok(products); }); ``` **Solution**: Use async operations throughout. ```csharp // GOOD: Fully async app.MapGet("/products", async (AppDb db) => { await Task.Delay(1000); // If delay needed var products = await db.Products.ToListAsync(); return TypedResults.Ok(products); }); ``` -
patterns.md 13.6 KB
# Minimal API Patterns ## Route Groups ### Hierarchical Route Groups Build nested groups for complex API structures: ```csharp var api = app.MapGroup("/api/v1") .RequireAuthorization(); var products = api.MapGroup("/products") .WithTags("Products"); var productReviews = products.MapGroup("/{productId:int}/reviews") .WithTags("Product Reviews"); productReviews.MapGet("/", GetReviewsForProduct); productReviews.MapPost("/", AddReviewToProduct); productReviews.MapGet("/{reviewId:int}", GetReviewById); ``` ### Group with Parameter Validation Apply route constraints at the group level: ```csharp var products = app.MapGroup("/api/products/{productId:int:min(1)}") .AddEndpointFilter(async (context, next) => { var productId = context.GetArgument<int>(0); var db = context.HttpContext.RequestServices.GetRequiredService<AppDb>(); if (!await db.Products.AnyAsync(p => p.Id == productId)) return TypedResults.NotFound(); return await next(context); }); products.MapGet("/", (int productId) => ...); products.MapGet("/variants", (int productId) => ...); ``` ### Versioned API Groups ```csharp var v1 = app.MapGroup("/api/v1").WithGroupName("v1"); var v2 = app.MapGroup("/api/v2").WithGroupName("v2"); v1.MapGet("/products", GetProductsV1); v2.MapGet("/products", GetProductsV2); ``` ## Endpoint Filters ### Validation Filter with FluentValidation ```csharp public class FluentValidationFilter<T> : IEndpointFilter where T : class { public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var argument = context.Arguments.OfType<T>().FirstOrDefault(); if (argument is null) return TypedResults.BadRequest(new ProblemDetails { Title = "Missing request body", Status = StatusCodes.Status400BadRequest }); var validator = context.HttpContext.RequestServices .GetService<IValidator<T>>(); if (validator is not null) { var result = await validator.ValidateAsync(argument); if (!result.IsValid) { return TypedResults.ValidationProblem( result.ToDictionary(), title: "Validation failed"); } } return await next(context); } } ``` ### Logging Filter ```csharp public class RequestLoggingFilter(ILogger<RequestLoggingFilter> logger) : IEndpointFilter { public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var sw = Stopwatch.StartNew(); var path = context.HttpContext.Request.Path; var method = context.HttpContext.Request.Method; logger.LogInformation("Request {Method} {Path} started", method, path); try { var result = await next(context); sw.Stop(); logger.LogInformation( "Request {Method} {Path} completed in {ElapsedMs}ms", method, path, sw.ElapsedMilliseconds); return result; } catch (Exception ex) { sw.Stop(); logger.LogError(ex, "Request {Method} {Path} failed after {ElapsedMs}ms", method, path, sw.ElapsedMilliseconds); throw; } } } ``` ### Rate Limiting Filter ```csharp public class RateLimitingFilter(IRateLimiter limiter) : IEndpointFilter { public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var clientId = context.HttpContext.User.FindFirst("sub")?.Value ?? context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; if (!await limiter.TryAcquireAsync(clientId)) { return TypedResults.StatusCode(StatusCodes.Status429TooManyRequests); } return await next(context); } } ``` ### Idempotency Filter ```csharp public class IdempotencyFilter(IDistributedCache cache) : IEndpointFilter { public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var idempotencyKey = context.HttpContext.Request.Headers["Idempotency-Key"].FirstOrDefault(); if (string.IsNullOrEmpty(idempotencyKey)) return await next(context); var cacheKey = $"idempotency:{idempotencyKey}"; var cachedResponse = await cache.GetStringAsync(cacheKey); if (cachedResponse is not null) return TypedResults.Content(cachedResponse, "application/json"); var result = await next(context); if (result is IValueHttpResult httpResult) { var json = JsonSerializer.Serialize(httpResult.Value); await cache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) }); } return result; } } ``` ### Filter Execution Order Filters execute in registration order (first registered runs first on request, last on response): ```csharp app.MapPost("/products", Create) .AddEndpointFilter<LoggingFilter>() // 1st on request, 3rd on response .AddEndpointFilter<AuthorizationFilter>() // 2nd on request, 2nd on response .AddEndpointFilter<ValidationFilter>(); // 3rd on request, 1st on response ``` ## TypedResults Patterns ### Union Return Types Use `Results<T1, T2, ...>` for compile-time checked multiple response types: ```csharp app.MapGet("/products/{id}", async Task<Results<Ok<ProductDto>, NotFound, BadRequest<ProblemDetails>>> (int id, IProductService service) => { if (id <= 0) return TypedResults.BadRequest(new ProblemDetails { Title = "Invalid ID", Detail = "Product ID must be positive" }); var product = await service.GetByIdAsync(id); return product is not null ? TypedResults.Ok(product) : TypedResults.NotFound(); }); ``` ### Complex Result Patterns ```csharp // Paginated results app.MapGet("/products", async Task<Ok<PagedResult<ProductDto>>> ([AsParameters] PaginationQuery query, IProductService service) => { var result = await service.GetPagedAsync(query.Page, query.PageSize); return TypedResults.Ok(result); }); public record PaginationQuery(int Page = 1, int PageSize = 20); public record PagedResult<T>( IReadOnlyList<T> Items, int TotalCount, int Page, int PageSize) { public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); public bool HasNextPage => Page < TotalPages; public bool HasPreviousPage => Page > 1; } ``` ### File Results ```csharp app.MapGet("/products/{id}/export", async Task<Results<FileStreamHttpResult, NotFound>> (int id, IProductService service) => { var product = await service.GetByIdAsync(id); if (product is null) return TypedResults.NotFound(); var stream = await service.ExportToCsvAsync(product); return TypedResults.File(stream, "text/csv", $"product-{id}.csv"); }); ``` ### Accepted with Location ```csharp app.MapPost("/products/import", async Task<Accepted<ImportJobResponse>> (ImportRequest request, IImportService service) => { var jobId = await service.StartImportAsync(request); return TypedResults.Accepted( $"/jobs/{jobId}", new ImportJobResponse(jobId, "Processing")); }); ``` ## Parameter Binding ### [AsParameters] for Complex Queries ```csharp public record ProductSearchQuery( string? Name, decimal? MinPrice, decimal? MaxPrice, string? Category, int Page = 1, int PageSize = 20, string SortBy = "name", bool Descending = false); app.MapGet("/products/search", async Task<Ok<PagedResult<ProductDto>>> ([AsParameters] ProductSearchQuery query, IProductService service) => { var result = await service.SearchAsync(query); return TypedResults.Ok(result); }); ``` ### Header and Query Binding ```csharp app.MapGet("/products", async Task<Ok<List<ProductDto>>> ([FromHeader(Name = "X-Tenant-Id")] string tenantId, [FromQuery] string? category, IProductService service) => { var products = await service.GetByTenantAsync(tenantId, category); return TypedResults.Ok(products); }); ``` ### Custom Model Binding ```csharp public record DateRange(DateOnly Start, DateOnly End) : IParsable<DateRange> { public static DateRange Parse(string s, IFormatProvider? provider) { var parts = s.Split(".."); return new DateRange(DateOnly.Parse(parts[0]), DateOnly.Parse(parts[1])); } public static bool TryParse(string? s, IFormatProvider? provider, out DateRange result) { result = default!; if (string.IsNullOrEmpty(s)) return false; var parts = s.Split(".."); if (parts.Length != 2) return false; if (!DateOnly.TryParse(parts[0], out var start)) return false; if (!DateOnly.TryParse(parts[1], out var end)) return false; result = new DateRange(start, end); return true; } } // Usage: /orders?dateRange=2024-01-01..2024-12-31 app.MapGet("/orders", (DateRange dateRange) => ...); ``` ## Error Handling ### Global Exception Handler ```csharp app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { context.Response.ContentType = "application/problem+json"; var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error; var problem = exception switch { ValidationException vex => new ProblemDetails { Status = StatusCodes.Status400BadRequest, Title = "Validation Error", Detail = string.Join("; ", vex.Errors.Select(e => e.ErrorMessage)) }, NotFoundException => new ProblemDetails { Status = StatusCodes.Status404NotFound, Title = "Not Found" }, _ => new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "An error occurred" } }; context.Response.StatusCode = problem.Status ?? 500; await context.Response.WriteAsJsonAsync(problem); }); }); ``` ### Result Pattern Integration ```csharp public static class ResultExtensions { public static IResult ToHttpResult<T>(this Result<T> result) => result.IsSuccess ? TypedResults.Ok(result.Value) : result.Error switch { NotFoundError => TypedResults.NotFound(), ValidationError ve => TypedResults.ValidationProblem(ve.Errors), ConflictError ce => TypedResults.Conflict(ce.Message), _ => TypedResults.Problem(result.Error.Message) }; } app.MapGet("/products/{id}", async (int id, IProductService service) => { var result = await service.GetByIdAsync(id); return result.ToHttpResult(); }); ``` ## OpenAPI Enhancements ### Rich Metadata ```csharp app.MapGet("/products/{id}", GetProductById) .WithName("GetProductById") .WithSummary("Get a product by ID") .WithDescription("Returns detailed information about a specific product including pricing and availability") .Produces<ProductDto>(StatusCodes.Status200OK, "application/json") .ProducesProblem(StatusCodes.Status404NotFound) .ProducesValidationProblem() .WithOpenApi(operation => { operation.Parameters[0].Description = "The unique product identifier"; operation.Parameters[0].Example = new OpenApiInteger(42); return operation; }); ``` ### Request/Response Examples ```csharp app.MapPost("/products", CreateProduct) .WithOpenApi(operation => { operation.RequestBody.Content["application/json"].Example = new OpenApiObject { ["name"] = new OpenApiString("Widget Pro"), ["price"] = new OpenApiDouble(29.99), ["category"] = new OpenApiString("Electronics") }; return operation; }); ``` ## Testing Patterns ### WebApplicationFactory Setup ```csharp public class MinimalApiTests : IClassFixture<WebApplicationFactory<Program>> { private readonly HttpClient _client; public MinimalApiTests(WebApplicationFactory<Program> factory) { _client = factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { services.AddScoped<IProductService, MockProductService>(); }); }).CreateClient(); } [Fact] public async Task GetProducts_ReturnsOk() { var response = await _client.GetAsync("/api/products"); response.StatusCode.Should().Be(HttpStatusCode.OK); var products = await response.Content.ReadFromJsonAsync<List<ProductDto>>(); products.Should().NotBeEmpty(); } } ``` ### Testing Filters in Isolation ```csharp [Fact] public async Task ValidationFilter_InvalidInput_ReturnsBadRequest() { var filter = new FluentValidationFilter<CreateProductRequest>(); var httpContext = new DefaultHttpContext(); httpContext.RequestServices = new ServiceCollection() .AddScoped<IValidator<CreateProductRequest>, CreateProductValidator>() .BuildServiceProvider(); var context = new EndpointFilterInvocationContext( httpContext, new object[] { new CreateProductRequest("", -1) }); var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>(TypedResults.Ok())); result.Should().BeOfType<ValidationProblem>(); } ```
-
-
SKILL.md 8.1 KB
--- name: dotnet-minimal-apis version: "1.0.0" category: "Web" description: "Design and implement Minimal APIs in ASP.NET Core using handler-first endpoints, route groups, filters, and lightweight composition suited to modern .NET services." compatibility: "Requires ASP.NET Core 6+, preferably .NET 8+ for full features." --- # Minimal APIs ## Trigger On - building new HTTP APIs in ASP.NET Core - creating lightweight microservices - choosing between Minimal APIs and controllers - organizing endpoints with route groups - implementing validation and filters ## Documentation - [Minimal APIs Overview](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-10.0) - [Minimal API Tutorial](https://learn.microsoft.com/en-us/aspnet/core/tutorials/min-web-api?view=aspnetcore-10.0) - [Filters in Minimal APIs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/min-api-filters?view=aspnetcore-10.0) - [OpenAPI Support](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview?view=aspnetcore-10.0) - [Route Groups](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/route-handlers?view=aspnetcore-10.0#route-groups) ### References - [patterns.md](references/patterns.md) - detailed route groups, filters, TypedResults patterns, parameter binding, error handling, and testing - [anti-patterns.md](references/anti-patterns.md) - common Minimal API mistakes to avoid ## When to Use Minimal APIs vs Controllers | Use Minimal APIs | Use Controllers | |------------------|-----------------| | New projects | Existing MVC/API projects | | Microservices | Complex model binding | | Simple CRUD APIs | OData, JsonPatch | | Lightweight handlers | Heavy use of attributes | | .NET 8+ projects | Need `[ApiController]` features | ## Workflow 1. **Define endpoints directly in Program.cs** (for small APIs) 2. **Use route groups** for related endpoints 3. **Move handlers to separate classes** as the API grows 4. **Apply filters** for cross-cutting concerns 5. **Use TypedResults** for type-safe responses 6. **Generate OpenAPI docs** with `.WithOpenApi()` ## Basic Patterns ### Simple Endpoints ```csharp var app = builder.Build(); app.MapGet("/", () => "Hello World"); app.MapGet("/products/{id}", (int id) => Results.Ok(new { Id = id })); app.MapPost("/products", (Product product) => Results.Created($"/products/{product.Id}", product)); ``` ### TypedResults (Strongly-Typed) ```csharp app.MapGet("/products/{id}", Results<Ok<Product>, NotFound> (int id, AppDb db) => { var product = db.Products.Find(id); return product is not null ? TypedResults.Ok(product) : TypedResults.NotFound(); }); ``` ### Dependency Injection ```csharp app.MapGet("/products", async (IProductService service) => { return await service.GetAllAsync(); }); // Or with [FromServices] for clarity app.MapGet("/products", async ([FromServices] IProductService service) => await service.GetAllAsync()); ``` ## Route Groups ### Basic Grouping ```csharp var products = app.MapGroup("/api/products"); products.MapGet("/", GetAll); products.MapGet("/{id}", GetById); products.MapPost("/", Create); products.MapPut("/{id}", Update); products.MapDelete("/{id}", Delete); ``` ### Groups with Shared Configuration ```csharp var api = app.MapGroup("/api") .RequireAuthorization() .AddEndpointFilter<ValidationFilter>(); var products = api.MapGroup("/products") .WithTags("Products"); var orders = api.MapGroup("/orders") .WithTags("Orders") .RequireAuthorization("AdminOnly"); ``` ## Endpoint Filters ### Inline Filter ```csharp app.MapGet("/products/{id}", (int id) => Results.Ok(id)) .AddEndpointFilter(async (context, next) => { var id = context.GetArgument<int>(0); if (id <= 0) return Results.BadRequest("Invalid ID"); return await next(context); }); ``` ### Class-Based Filter ```csharp public class ValidationFilter<T> : IEndpointFilter where T : class { public async ValueTask<object?> InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { var argument = context.Arguments .OfType<T>() .FirstOrDefault(); if (argument is null) return Results.BadRequest("Invalid request body"); var validator = context.HttpContext.RequestServices .GetService<IValidator<T>>(); if (validator is not null) { var result = await validator.ValidateAsync(argument); if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary()); } return await next(context); } } // Usage products.MapPost("/", Create) .AddEndpointFilter<ValidationFilter<CreateProductRequest>>(); ``` ### Global Filters via Root Group ```csharp // All endpoints inherit filters from root group var root = app.MapGroup("") .AddEndpointFilter<LoggingFilter>() .AddEndpointFilter<ErrorHandlingFilter>(); root.MapGet("/health", () => Results.Ok()); root.MapGroup("/api/products").MapGet("/", GetProducts); ``` ## Organizing Larger APIs ### Extension Method Pattern ```csharp // ProductEndpoints.cs public static class ProductEndpoints { public static RouteGroupBuilder MapProductEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/products") .WithTags("Products"); group.MapGet("/", GetAll); group.MapGet("/{id}", GetById); group.MapPost("/", Create); return group; } private static async Task<Ok<List<Product>>> GetAll(IProductService service) => TypedResults.Ok(await service.GetAllAsync()); private static async Task<Results<Ok<Product>, NotFound>> GetById( int id, IProductService service) { var product = await service.GetByIdAsync(id); return product is not null ? TypedResults.Ok(product) : TypedResults.NotFound(); } private static async Task<Created<Product>> Create( CreateProductRequest request, IProductService service) { var product = await service.CreateAsync(request); return TypedResults.Created($"/api/products/{product.Id}", product); } } // Program.cs app.MapProductEndpoints(); app.MapOrderEndpoints(); ``` ## Request/Response DTOs ```csharp // Separate from domain models public record CreateProductRequest(string Name, decimal Price); public record UpdateProductRequest(string Name, decimal Price); public record ProductResponse(int Id, string Name, decimal Price); // Don't expose domain entities directly app.MapPost("/products", (CreateProductRequest request, IMapper mapper) => { var product = mapper.Map<Product>(request); // ... return TypedResults.Created($"/products/{product.Id}", mapper.Map<ProductResponse>(product)); }); ``` ## Anti-Patterns to Avoid | Anti-Pattern | Why It's Bad | Better Approach | |--------------|--------------|-----------------| | Everything in Program.cs | Unmaintainable | Use extension methods | | No route groups | Repetitive config | Group related endpoints | | Manual validation | Error-prone | Use filters + FluentValidation | | Exposing entities | Tight coupling | Use DTOs | | No TypedResults | No compile-time checks | Use `TypedResults` | | Ignoring OpenAPI | No documentation | Add `.WithOpenApi()` | ## OpenAPI Integration ```csharp builder.Services.AddOpenApi(); app.MapOpenApi(); // Serves OpenAPI spec app.MapGet("/products", GetProducts) .WithName("GetProducts") .WithSummary("Get all products") .WithDescription("Returns a list of all available products") .Produces<List<Product>>(StatusCodes.Status200OK) .ProducesProblem(StatusCodes.Status500InternalServerError); ``` ## Deliver - clean, organized Minimal API endpoints - proper use of route groups and filters - type-safe responses with TypedResults - OpenAPI documentation - validation with endpoint filters ## Validate - endpoints return correct status codes - validation filters catch invalid input - OpenAPI spec is accurate - route groups share common configuration - handlers are testable (can mock dependencies)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.