Crear una API REST con Minimal APIs en .NET 8 y C#: Guía paso a paso para CRUD, EF Core, JWT, Swagger y Docker
Meta title sugerido: Tutorial Minimal APIs .NET 8: API REST C# con EF Core, JWT y Swagger
Meta description sugerida: Aprende a crear una API REST en C# con Minimal APIs en .NET 8: CRUD, Entity Framework Core, SQLite/SQL Server, Swagger/OpenAPI, autenticación JWT, pruebas con xUnit y despliegue con Docker y Azure.
URL slug sugerido: tutorial-minimal-apis-dotnet-8-api-rest-csharp
1. Requisitos e instalación
En este tutorial Minimal APIs .NET 8 aprenderás a construir una API REST C# completa: CRUD, Entity Framework Core, Swagger .NET 8, JWT, pruebas y Docker. Comencemos instalando el SDK y herramientas.
1.1. Instalar .NET 8 SDK
- Descarga: https://dotnet.microsoft.com/download/dotnet/8.0
- Verifica instalación:
1 2 3 4 |
dotnet --version # Debe mostrar 8.x dotnet --info |
1.2. IDE/Editor recomendado
- Visual Studio 2022 17.8+ (Workload: ASP.NET and web development)
- Visual Studio Code + extensiones: C#, C# Dev Kit, NuGet Gallery
1.3. Herramientas adicionales
- Git
- Docker Desktop (para contenedorización y pruebas locales)
- EF Core CLI:
1 2 3 |
dotnet tool install --global dotnet-ef dotnet ef --version |
Checklist
- [ ] .NET 8 instalado y verificado
- [ ] Editor listo (VS o VS Code)
- [ ] Docker Desktop corriendo
- [ ] dotnet-ef instalado
2. Crear la solución y proyecto
Crearemos una API llamada MinimalApiDemo.
1 2 3 4 5 6 7 |
mkdir MinimalApiDemo && cd MinimalApiDemo # Proyecto web minimal dotnet new web -n MinimalApiDemo.Api # Solución dotnet new sln -n MinimalApiDemo dotnet sln add MinimalApiDemo.Api/MinimalApiDemo.Api.csproj |
Agrega paquetes necesarios:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
cd MinimalApiDemo.Api # EF Core + SQLite y SQL Server (elige al menos uno) dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Design # Swagger / OpenAPI dotnet add package Swashbuckle.AspNetCore # FluentValidation dotnet add package FluentValidation dotnet add package FluentValidation.DependencyInjectionExtensions # JWT Auth dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer # Testing (se agregará después en proyecto de pruebas) |
Estructura de carpetas recomendada:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
MinimalApiDemo.Api/ ├─ Program.cs ├─ appsettings.json ├─ appsettings.Development.json ├─ Properties/ ├─ Domain/ │ └─ Product.cs ├─ Data/ │ ├─ AppDbContext.cs │ └─ Seed.cs ├─ DTOs/ │ ├─ ProductDtos.cs │ └─ Mappings.cs ├─ Validation/ │ └─ ProductValidators.cs ├─ Endpoints/ │ └─ ProductsEndpoints.cs ├─ Middleware/ │ └─ ErrorHandlingMiddleware.cs ├─ Auth/ │ ├─ JwtSettings.cs │ └─ AuthEndpoints.cs ├─ Dockerfile └─ docker-compose.yml (en raíz de solución) |
Checklist
- [ ] Proyecto web creado
- [ ] Paquetes NuGet agregados
- [ ] Carpetas base creadas
3. Configuración base (appsettings y variables de entorno)
Archivo appsettings.json:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
{ "ConnectionStrings": { "Sqlite": "Data Source=app.db", "SqlServer": "Server=localhost,1433;Database=MinimalApiDemo;User Id=sa;Password=Your_password123;TrustServerCertificate=true" }, "DbProvider": "Sqlite", "Cors": { "AllowedOrigins": ["https://localhost:5173", "http://localhost:5173"] }, "Jwt": { "Issuer": "MinimalApiDemo", "Audience": "MinimalApiDemoAudience", "Secret": "SuperSecretKey_ChangeMe_32chars_minimo", "ExpirationMinutes": 60 }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*" } |
Notas
- Puedes cambiar DbProvider a SqlServer para usar SQL Server.
- Para producción, sobreescribe valores con variables de entorno (por ejemplo, JWT__Secret).
Checklist
- [ ] appsettings.json con conexión y JWT configurado
- [ ] Decidido proveedor de base de datos (SQLite o SQL Server)
4. Dominio, DTOs y mapeo
4.1. Entidad de dominio con Data Annotations
Archivo Domain/Product.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
using System.ComponentModel.DataAnnotations; namespace MinimalApiDemo.Api.Domain; public class Product { public int Id { get; set; } [Required] [StringLength(100)] public string Name { get; set; } = default!; [StringLength(500)] public string? Description { get; set; } [Range(0, double.MaxValue)] public decimal Price { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } |
4.2. DTOs y mapeos
Archivo DTOs/ProductDtos.cs:
1 2 3 4 5 6 |
namespace MinimalApiDemo.Api.DTOs; public record ProductDto(int Id, string Name, string? Description, decimal Price, DateTime CreatedAt); public record CreateProductDto(string Name, string? Description, decimal Price); public record UpdateProductDto(string Name, string? Description, decimal Price); |
Archivo DTOs/Mappings.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
using MinimalApiDemo.Api.Domain; namespace MinimalApiDemo.Api.DTOs; public static class Mappings { public static ProductDto ToDto(this Product p) => new(p.Id, p.Name, p.Description, p.Price, p.CreatedAt); public static Product FromCreateDto(this CreateProductDto dto) => new() { Name = dto.Name, Description = dto.Description, Price = dto.Price }; public static void UpdateFromDto(this Product p, UpdateProductDto dto) { p.Name = dto.Name; p.Description = dto.Description; p.Price = dto.Price; } } |
Checklist
- [ ] Entidad creada con Data Annotations
- [ ] DTOs definidos
- [ ] Mapeos implementados
5. Entity Framework Core: DbContext, migraciones y seeding
5.1. DbContext
Archivo Data/AppDbContext.cs:
1 2 3 4 5 6 7 8 9 10 |
using Microsoft.EntityFrameworkCore; using MinimalApiDemo.Api.Domain; namespace MinimalApiDemo.Api.Data; public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) { public DbSet<Product> Products => Set<Product>(); } |
5.2. Seeding
Archivo Data/Seed.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
using MinimalApiDemo.Api.Domain; namespace MinimalApiDemo.Api.Data; public static class Seed { public static async Task InitializeAsync(AppDbContext db) { if (await db.Products.AnyAsync()) return; db.Products.AddRange( new Product { Name = "Mouse", Description = "Mouse óptico", Price = 15.99m }, new Product { Name = "Teclado", Description = "Mecánico", Price = 59.99m } ); await db.SaveChangesAsync(); } } |
5.3. Registrar DbContext y aplicar migraciones en Program.cs
Archivo Program.cs (versión completa más abajo) tendrá algo como:
1 2 3 4 5 6 7 8 9 10 11 12 |
var provider = builder.Configuration.GetValue<string>("DbProvider") ?? "Sqlite"; if (provider.Equals("SqlServer", StringComparison.OrdinalIgnoreCase)) { builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("SqlServer"))); } else { builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlite(builder.Configuration.GetConnectionString("Sqlite"))); } |
Crear y aplicar migraciones:
1 2 3 4 |
# Desde MinimalApiDemo.Api dotnet ef migrations add InitialCreate dotnet ef database update |
Checklist
- [ ] DbContext registrado
- [ ] Migración creada y aplicada
- [ ] Datos iniciales seed en base
6. Validación: Data Annotations y FluentValidation
Archivo Validation/ProductValidators.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
using FluentValidation; using MinimalApiDemo.Api.DTOs; namespace MinimalApiDemo.Api.Validation; public class CreateProductValidator : AbstractValidator<CreateProductDto> { public CreateProductValidator() { RuleFor(x => x.Name).NotEmpty().MaximumLength(100); RuleFor(x => x.Description).MaximumLength(500); RuleFor(x => x.Price).GreaterThanOrEqualTo(0); } } public class UpdateProductValidator : AbstractValidator<UpdateProductDto> { public UpdateProductValidator() { Include(new CreateProductValidator()); } } |
Uso: inyecta IValidator
Checklist
- [ ] Validadores de FluentValidation creados
- [ ] Reglas coherentes con Data Annotations
7. Middleware global de errores y logging
Archivo Middleware/ErrorHandlingMiddleware.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
using System.Net; using System.Text.Json; namespace MinimalApiDemo.Api.Middleware; public class ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger) { public async Task Invoke(HttpContext context) { try { await next(context); } catch (Exception ex) { logger.LogError(ex, "Unhandled exception"); context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; context.Response.ContentType = "application/json"; var problem = new { title = "Unexpected error", status = 500, detail = ex.Message, traceId = context.TraceIdentifier }; await context.Response.WriteAsync(JsonSerializer.Serialize(problem)); } } } |
Regístralo en Program.cs con app.UseMiddleware
Checklist
- [ ] Middleware de errores agregado
- [ ] ILogger configurado por defecto
8. Endpoints CRUD con Minimal APIs (paginación y filtros)
Archivo Endpoints/ProductsEndpoints.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
using FluentValidation; using Microsoft.EntityFrameworkCore; using MinimalApiDemo.Api.Data; using MinimalApiDemo.Api.Domain; using MinimalApiDemo.Api.DTOs; namespace MinimalApiDemo.Api.Endpoints; public static class ProductsEndpoints { public static RouteGroupBuilder MapProducts(this IEndpointRouteBuilder routes) { var group = routes.MapGroup("/api/v1/products").WithTags("Products"); // GET paginado y filtrado group.MapGet("", async (int page = 1, int pageSize = 10, string? search = null, AppDbContext db) => { var query = db.Products.AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) query = query.Where(p => p.Name.Contains(search)); var total = await query.CountAsync(); var items = await query .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p => p.ToDto()) .ToListAsync(); return Results.Ok(new { total, page, pageSize, items }); }) .WithName("GetProducts") .Produces(StatusCodes.Status200OK); // GET by id group.MapGet("/{id:int}", async (int id, AppDbContext db) => { var p = await db.Products.FindAsync(id); return p is null ? Results.NotFound() : Results.Ok(p.ToDto()); }) .WithName("GetProductById") .Produces<ProductDto>(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound); // POST (requiere rol Admin) group.MapPost("", async ( CreateProductDto dto, IValidator<CreateProductDto> validator, AppDbContext db, ILoggerFactory loggerFactory) => { var result = await validator.ValidateAsync(dto); if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary()); var logger = loggerFactory.CreateLogger("Products"); var product = dto.FromCreateDto(); db.Products.Add(product); await db.SaveChangesAsync(); logger.LogInformation("Product created with id {Id}", product.Id); return Results.Created($"/api/v1/products/{product.Id}", product.ToDto()); }) .RequireAuthorization("AdminOnly") .Produces<ProductDto>(StatusCodes.Status201Created) .ProducesValidationProblem(); // PUT group.MapPut("/{id:int}", async ( int id, UpdateProductDto dto, IValidator<UpdateProductDto> validator, AppDbContext db) => { var result = await validator.ValidateAsync(dto); if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary()); var product = await db.Products.FindAsync(id); if (product is null) return Results.NotFound(); product.UpdateFromDto(dto); await db.SaveChangesAsync(); return Results.NoContent(); }) .RequireAuthorization("AdminOnly") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesValidationProblem(); // DELETE group.MapDelete("/{id:int}", async (int id, AppDbContext db) => { var product = await db.Products.FindAsync(id); if (product is null) return Results.NotFound(); db.Products.Remove(product); await db.SaveChangesAsync(); return Results.NoContent(); }) .RequireAuthorization("AdminOnly") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound); return group; } } |
Ejemplos de requests/responses (cURL):
1 2 3 4 5 6 7 8 9 |
# Listado paginado curl -s "https://localhost:5001/api/v1/products?page=1&pageSize=5&search=mouse" # Crear (requiere Bearer token con rol Admin) curl -X POST https://localhost:5001/api/v1/products \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_JWT" \ -d '{"name":"Monitor","description":"27"" 144Hz","price":199.99}' |
Checklist
- [ ] CRUD mapeado en rutas /api/v1/products
- [ ] Paginación y búsqueda por query string
- [ ] Validación y logging en operaciones de escritura
9. Swagger/OpenAPI con Swashbuckle
Configura servicios en Program.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(o => { o.SwaggerDoc("v1", new() { Title = "MinimalApiDemo", Version = "v1" }); // JWT support o.AddSecurityDefinition("Bearer", new() { Name = "Authorization", Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", In = Microsoft.OpenApi.Models.ParameterLocation.Header, Description = "Ingrese 'Bearer {token}'" }); o.AddSecurityRequirement(new() { { new() { Reference = new() { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "Bearer" } }, new string[] { } } }); }); |
En desarrollo activa Swagger UI:
1 2 3 4 5 6 |
if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } |
Checklist
- [ ] Swagger habilitado con esquema de seguridad Bearer
- [ ] Probar endpoints desde Swagger UI
10. Autenticación y autorización con JWT
Archivo Auth/JwtSettings.cs:
1 2 3 4 5 6 7 8 9 10 |
namespace MinimalApiDemo.Api.Auth; public class JwtSettings { public string Issuer { get; set; } = default!; public string Audience { get; set; } = default!; public string Secret { get; set; } = default!; public int ExpirationMinutes { get; set; } } |
Archivo Auth/AuthEndpoints.cs (emite un token de ejemplo; en producción valida contra tu base de usuarios):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; namespace MinimalApiDemo.Api.Auth; public static class AuthEndpoints { public static IEndpointRouteBuilder MapAuth(this IEndpointRouteBuilder routes) { routes.MapPost("/api/v1/auth/login", (LoginRequest req, JwtSettings jwt) => { // Demo: usuario/clave fijo. Reemplaza por verificación real. if (req.Username == "admin" && req.Password == "Pass@123") { var claims = new List<Claim> { new(ClaimTypes.Name, req.Username), new(ClaimTypes.Role, "Admin") }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: jwt.Issuer, audience: jwt.Audience, claims: claims, expires: DateTime.UtcNow.AddMinutes(jwt.ExpirationMinutes), signingCredentials: creds); var jwtToken = new JwtSecurityTokenHandler().WriteToken(token); return Results.Ok(new { access_token = jwtToken }); } return Results.Unauthorized(); }).WithTags("Auth"); return routes; } public record LoginRequest(string Username, string Password); } |
Config en Program.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; var jwtSection = builder.Configuration.GetSection("Jwt"); builder.Services.Configure<JwtSettings>(jwtSection); var jwtSettings = jwtSection.Get<JwtSettings>()!; builder.Services.AddSingleton(jwtSettings); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new() { ValidateIssuer = true, ValidateAudience = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings.Issuer, ValidAudience = jwtSettings.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)) }; }); builder.Services.AddAuthorization(o => { o.AddPolicy("AdminOnly", p => p.RequireRole("Admin")); }); |
Checklist
- [ ] Endpoint /api/v1/auth/login devuelve JWT
- [ ] Políticas de autorización definidas (AdminOnly)
- [ ] CRUD protegido con RequireAuthorization
11. CORS y versionado de API
Activa CORS para frontends específicos:
1 2 3 4 5 6 7 8 9 10 11 |
var allowed = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? []; builder.Services.AddCors(options => { options.AddPolicy("AllowSpecific", policy => policy.WithOrigins(allowed) .AllowAnyHeader() .AllowAnyMethod()); }); app.UseCors("AllowSpecific"); |
Versionado: En este tutorial usamos la versión en la ruta (/api/v1/…). Para versionado avanzado considera Asp.Versioning.Http.
Checklist
- [ ] CORS habilitado con orígenes permitidos
- [ ] Rutas con prefijo /api/v1
12. Program.cs completo
Archivo Program.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
using FluentValidation; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using MinimalApiDemo.Api.Auth; using MinimalApiDemo.Api.Data; using MinimalApiDemo.Api.Endpoints; using MinimalApiDemo.Api.Middleware; using MinimalApiDemo.Api.Validation; using System.Text; var builder = WebApplication.CreateBuilder(args); // Logging default ya configurado por hosting // DbContext var provider = builder.Configuration.GetValue<string>("DbProvider") ?? "Sqlite"; if (provider.Equals("SqlServer", StringComparison.OrdinalIgnoreCase)) builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(builder.Configuration.GetConnectionString("SqlServer"))); else builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlite(builder.Configuration.GetConnectionString("Sqlite"))); // FluentValidation builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>(); // JWT var jwtSection = builder.Configuration.GetSection("Jwt"); builder.Services.Configure<JwtSettings>(jwtSection); var jwtSettings = jwtSection.Get<JwtSettings>()!; builder.Services.AddSingleton(jwtSettings); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.TokenValidationParameters = new() { ValidateIssuer = true, ValidateAudience = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtSettings.Issuer, ValidAudience = jwtSettings.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)) }; }); builder.Services.AddAuthorization(o => { o.AddPolicy("AdminOnly", p => p.RequireRole("Admin")); }); // CORS var allowed = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? []; builder.Services.AddCors(o => o.AddPolicy("AllowSpecific", p => p.WithOrigins(allowed).AllowAnyHeader().AllowAnyMethod())); // Swagger builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(o => { o.SwaggerDoc("v1", new() { Title = "MinimalApiDemo", Version = "v1" }); o.AddSecurityDefinition("Bearer", new() { Name = "Authorization", Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", In = Microsoft.OpenApi.Models.ParameterLocation.Header, Description = "Ingrese 'Bearer {token}'" }); o.AddSecurityRequirement(new() { { new() { Reference = new() { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "Bearer" } }, new string[] { } } }); }); var app = builder.Build(); // Middleware de errores app.UseMiddleware<ErrorHandlingMiddleware>(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseCors("AllowSpecific"); app.UseAuthentication(); app.UseAuthorization(); // Map endpoints app.MapAuth(); app.MapProducts(); // Migrate & Seed using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); await db.Database.MigrateAsync(); await Seed.InitializeAsync(db); } app.Run(); public partial class Program { } |
Checklist
- [ ] Program.cs compila y ejecuta
- [ ] Swagger disponible en /swagger
13. Pruebas unitarias e integración con xUnit y WebApplicationFactory
Crear proyecto de pruebas:
1 2 3 4 5 6 7 8 9 10 |
cd .. dotnet new xunit -n MinimalApiDemo.Tests dotnet sln add MinimalApiDemo.Tests/MinimalApiDemo.Tests.csproj cd MinimalApiDemo.Tests dotnet add reference ../MinimalApiDemo.Api/MinimalApiDemo.Api.csproj dotnet add package Microsoft.AspNetCore.Mvc.Testing dotnet add package FluentAssertions dotnet add package Microsoft.Data.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Sqlite |
Prueba unitaria simple (DTO mapping):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
using FluentAssertions; using MinimalApiDemo.Api.Domain; using MinimalApiDemo.Api.DTOs; public class MappingTests { [Fact] public void Product_ToDto_MapsCorrectly() { var p = new Product { Id = 1, Name = "X", Price = 10m }; var dto = p.ToDto(); dto.Id.Should().Be(1); dto.Name.Should().Be("X"); } } |
Prueba de integración con WebApplicationFactory (in-memory SQLite):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
using Microsoft.AspNetCore.Mvc.Testing; using System.Net.Http.Json; using MinimalApiDemo.Api.DTOs; public class ProductsIntegrationTests : IClassFixture<WebApplicationFactory<Program>> { private readonly WebApplicationFactory<Program> _factory; public ProductsIntegrationTests(WebApplicationFactory<Program> factory) { _factory = factory.WithWebHostBuilder(builder => { }); } [Fact] public async Task GetProducts_ReturnsOk() { var client = _factory.CreateClient(); var resp = await client.GetAsync("/api/v1/products"); resp.EnsureSuccessStatusCode(); var payload = await resp.Content.ReadFromJsonAsync<dynamic>(); Assert.NotNull(payload); } } |
Checklist
- [ ] Proyecto de pruebas agregado a la solución
- [ ] Pruebas unitarias y de integración ejecutan: dotnet test
14. Docker: Dockerfile y docker-compose
Archivo Dockerfile (en MinimalApiDemo.Api):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# Build FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY . . RUN dotnet restore RUN dotnet publish -c Release -o /app /p:UseAppHost=false # Runtime FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app . ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 ENTRYPOINT ["dotnet", "MinimalApiDemo.Api.dll"] |
Archivo docker-compose.yml (en raíz de la solución) con SQL Server opcional:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
version: "3.9" services: api: build: ./MinimalApiDemo.Api ports: - "8080:8080" environment: - ASPNETCORE_ENVIRONMENT=Production - DbProvider=SqlServer - ConnectionStrings__SqlServer=Server=db,1433;Database=MinimalApiDemo;User Id=sa;Password=Your_password123;TrustServerCertificate=true - Jwt__Issuer=MinimalApiDemo - Jwt__Audience=MinimalApiDemoAudience - Jwt__Secret=ChangeMeInSecrets_AtLeast32Chars - Jwt__ExpirationMinutes=60 depends_on: - db db: image: mcr.microsoft.com/mssql/server:2022-latest environment: - ACCEPT_EULA=Y - SA_PASSWORD=Your_password123 ports: - "1433:1433" |
Construir y ejecutar:
1 2 3 4 5 6 7 |
# Local con SQLite (sin compose) docker build -t minimalapi-demo ./MinimalApiDemo.Api docker run -p 8080:8080 -e DbProvider=Sqlite minimalapi-demo # Con compose (SQL Server) docker compose up -d --build |
Checklist
- [ ] Imagen Docker construida
- [ ] API accesible en http://localhost:8080/swagger
15. Configuración por entornos
- appsettings.Development.json para overrides locales.
- Variables de entorno para producción (prefijo secciones con doble guion bajo: Jwt__Secret).
- ASPNETCORE_ENVIRONMENT controla qué appsettings se cargan.
Ejemplo ejecución local con variables:
1 2 3 |
setx ASPNETCORE_ENVIRONMENT Development setx Jwt__Secret ChangeThisInDev_1234567890_123456 |
Checklist
- [ ] Entornos configurados (Development/Production)
- [ ] Secretos sensibles via variables/env
16. Despliegue rápido (Azure App Service o contenedor)
Opción A: Contenedor
1) Publica imagen en un registry (Docker Hub/Azure Container Registry):
1 2 3 |
docker tag minimalapi-demo yourrepo/minimalapi-demo:latest docker push yourrepo/minimalapi-demo:latest |
2) Crea App Service for Containers y apunta a tu imagen. Configura variables de entorno: DbProvider, ConnectionStrings…, Jwt…
Opción B: Publish directo (código)
- En Visual Studio: Publish > Azure > Azure App Service > Configura ConnectionStrings y variables de app.
Checklist
- [ ] Imagen publicada en registry
- [ ] App configurada con variables y conexión
17. FAQ (orientada a featured snippets)
-
¿Qué es una Minimal API en .NET 8?
Una Minimal API es una forma ligera de construir endpoints HTTP en ASP.NET Core usando mínima configuración y un modelo de hosting simplificado, ideal para microservicios y APIs REST. -
¿Cómo documento una Minimal API con Swagger .NET 8?
Agrega AddEndpointsApiExplorer y AddSwaggerGen, habilita UseSwagger/UseSwaggerUI y define el esquema Bearer para JWT. -
¿Cómo implementar autenticación JWT?
Configura AddAuthentication con JwtBearer, valida issuer/audience/signing key y expón un endpoint de login que emita tokens. -
¿Puedo usar Entity Framework Core con SQLite y SQL Server?
Sí. Registra AppDbContext con UseSqlite o UseSqlServer según un flag (DbProvider) y gestiona migraciones con dotnet ef. -
¿Cómo hago paginación y filtros?
Acepta parámetros page, pageSize, search en el endpoint y aplica Skip/Take y Where en la consulta LINQ.
18. Resolución de problemas comunes
-
Error: The Entity Framework tools version is older/newer than runtime.
Ejecuta: dotnet tool update –global dotnet-ef y asegura paquetes EF Core 8.x. -
Error de CORS (blocked by CORS policy).
Verifica orígenes en appsettings (Cors:AllowedOrigins) y que UseCors está antes de los endpoints. -
401 Unauthorized al llamar endpoints protegidos.
Asegura enviar Authorization: Bearer {token} y que el token incluye el rol Admin si la política lo requiere. -
No se aplica migración en contenedor.
Confirma que app realiza db.Database.Migrate() al iniciar y que la cadena de conexión es correcta (host db en compose). -
Clave JWT demasiado corta.
Usa una clave >= 32 caracteres para HMAC-SHA256. -
SQLite locked database en Docker.
Monta volumen persistente o cambia a SQL Server en compose para concurrencia.
19. Buenas prácticas y conclusiones
Buenas prácticas
- Valida todas las entradas con FluentValidation y Data Annotations.
- Usa DTOs para no exponer entidades directamente.
- Centraliza manejo de errores con middleware y registra logs con ILogger.
- Configura CORS de forma restrictiva en producción.
- Separa por capas/carpetas claras y añade tests con xUnit y WebApplicationFactory.
- Documenta la API con Swagger/OpenAPI y ejemplos de uso.
- Usa variables de entorno para secretos y connection strings.
- Conteneriza con Docker y automatiza despliegues (CI/CD) hacia Azure App Service u otro destino.
Conclusión
Has construido una .NET 8 Minimal API CRUD con Entity Framework Core, Swagger .NET 8, autenticación JWT, CORS, logging, validación, pruebas y Docker. Este tutorial Minimal APIs .NET 8 te deja con una base sólida para producir una API REST C# lista para desarrollo y despliegue. ¿Próximo paso? Amplía el dominio, agrega versionado formal con Asp.Versioning.Http, integra métricas (OpenTelemetry) y habilita CI/CD. Si te ha sido útil, compártelo y considera suscribirte para más guías avanzadas de .NET 8 Minimal API CRUD.