Persistencia de Datos y Validación
Este módulo se centra en cómo las Minimal APIs interactúan con la capa de datos usando Entity Framework Core (EF Core) y aseguran la integridad de los datos mediante mecanismos de validación.
4.1. Integración con Entity Framework Core (EF Core)
EF Core es el ORM (Mapeo Objeto-Relacional) recomendado en .NET para interactuar con bases de datos.
Instalación de paquetes de EF Core
Para un proyecto real, necesitarías paquetes específicos del proveedor (ej. Microsoft.EntityFrameworkCore.SqlServer o Microsoft.EntityFrameworkCore.Sqlite). Para este ejemplo, usaremos un modelo de base de datos en memoria para simplificar la configuración inicial y evitar migraciones complejas.
Configuración del DbContext
El DbContext es la puerta de enlace a la base de datos. Debe registrarse en el contenedor de servicios de la aplicación:
builder.Services.AddDbContext<ApiDbContext>(options =>
options.UseInMemoryDatabase("MinimalDb"));
Una vez inyectado, el DbContext permite realizar consultas LINQ y operaciones CRUD.
4.2. Implementación de CRUD Completo con EF Core
Con el DbContext inyectado en la firma del endpoint (ApiDbContext db), podemos implementar las operaciones CRUD completas y asíncronas.
Endpoints Asíncronos
Es crucial usar operaciones asíncronas (como await db.Todos.ToListAsync()) en las Minimal APIs para evitar bloquear el hilo del servidor y maximizar la escalabilidad.
| Operación | Método EF Core (Async) |
|---|---|
| GET (Todo) | await db.Items.ToListAsync() |
| GET (ID) | await db.Items.FindAsync(id) o await db.Items.FirstOrDefaultAsync(id) |
| POST | db.Items.Add(item); await db.SaveChangesAsync() |
| PUT/DELETE | db.Items.Update/Remove(item); await db.SaveChangesAsync() |
4.3. Validación de Datos
Una API robusta siempre valida los datos de entrada antes de procesarlos. En .NET, la forma más sencilla es usar Data Annotations en el modelo de datos.
Uso de Data Annotations
Añade atributos como [Required], [MaxLength], o [Range] a las propiedades de tu modelo.
public class TodoItem
{
// ...
[Required(ErrorMessage = "El título es obligatorio.")]
[StringLength(100, ErrorMessage = "Máximo 100 caracteres.")]
public string Title { get; set; }
// ...
}
Implementación de validación manual
Cuando el framework recibe un modelo, intenta validarlo. Podemos forzar la validación y devolver un error 400 Bad Request si los datos no son válidos.
En un app.MapPost (o MapPut), si el objeto es un tipo complejo (como nuestro TodoItem), ASP.NET Core ya realiza una validación básica del modelo. Sin embargo, para obtener los mensajes de error de las Data Annotations de forma explícita, se requiere un paso de validación manual o usar un paquete más avanzado como FluentValidation (que no cubriremos en este módulo por brevedad).
Una práctica común es verificar si el modelo ligado es null o si cumple con restricciones de tipo, y para obtener resultados de validación detallados en Minimal APIs, se puede inyectar el objeto HttpContext y usar el método context.Request.ReadFromJsonAsync<T>() o recurrir a librerías de terceros. Para mantener el código simple, nos centraremos en verificar la existencia de campos requeridos y devolveremos mensajes genéricos de error 400.
// Usings necesarios.
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore; // Requerido para EF Core y UseInMemoryDatabase
using System.ComponentModel.DataAnnotations; // Requerido para Data Annotations
using System.Collections.Generic;
using System.Linq;
// ====================================================================
// MODELOS Y CONTEXTO DE DATOS (MÓDULO 4)
// ====================================================================
// Modelo para la base de datos con Data Annotations para validación
public class TodoItem
{
public int Id { get; set; }
[Required(ErrorMessage = "El título es obligatorio.")]
[StringLength(100, ErrorMessage = "El título no puede exceder los 100 caracteres.")]
public string Title { get; set; } = string.Empty;
public bool IsComplete { get; set; }
}
// Contexto de Base de Datos (ApiDbContext)
public class ApiDbContext : DbContext
{
public ApiDbContext(DbContextOptions<ApiDbContext> options) : base(options) { }
public DbSet<TodoItem> Todos => Set<TodoItem>();
// Inicialización de datos para la base de datos en memoria
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TodoItem>().HasData(
new TodoItem { Id = 1, Title = "Completar Módulo 4", IsComplete = false },
new TodoItem { Id = 2, Title = "Aprender EF Core", IsComplete = true }
);
}
}
// ====================================================================
// MODELOS DE CONFIGURACIÓN Y SERVICIOS (MÓDULO 3 - MANTENIDOS)
// ====================================================================
public class SettingsOptions
{
public const string Settings = "Settings";
public string NombreAplicacion { get; set; } = "API por Defecto";
public int MaxItems { get; set; }
}
public interface IItemService
{
List<string> ObtenerDatos();
}
public class MockItemService : IItemService
{
private readonly List<string> _items = new List<string> { "Item A", "Item B", "Item C" };
public List<string> ObtenerDatos() => _items;
}
// ====================================================================
// APLICACIÓN PRINCIPAL (Program.cs)
// ====================================================================
var builder = WebApplication.CreateBuilder(args);
// --------------------------------------------------------------------
// MÓDULO 4: REGISTRO DE PERSISTENCIA Y SERVICIOS
// --------------------------------------------------------------------
// 4.1. Registro del DbContext con Base de Datos en Memoria (In-Memory)
builder.Services.AddDbContext<ApiDbContext>(options =>
options.UseInMemoryDatabase("MinimalDb"));
// --------------------------------------------------------------------
// MÓDULO 3: REGISTRO DE SERVICIOS Y CONFIGURACIÓN (MANTENIDOS)
// --------------------------------------------------------------------
builder.Services.AddSingleton<IItemService, MockItemService>();
builder.Services.Configure<SettingsOptions>(
builder.Configuration.GetSection(SettingsOptions.Settings));
builder.Services.AddTransient<OtroServicio>();
var app = builder.Build();
// Inicialización de la base de datos en memoria (opcional pero útil)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApiDbContext>();
// Asegurarse de que la base de datos se crea y se inicializa con los datos de OnModelCreating
db.Database.EnsureCreated();
}
// Configuración de Middleware
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
// --------------------------------------------------------------------
// MÓDULO 3 ENDPOINTS (MANTENIDOS)
// --------------------------------------------------------------------
app.MapGet("/api/v1/items-con-servicio", (IItemService itemService) =>
{
var datos = itemService.ObtenerDatos();
return Results.Ok(new { Mensaje = "Datos obtenidos del servicio inyectado", Datos = datos });
})
.WithName("ItemsConServicio");
app.MapGet("/api/v1/configuracion", (IOptions<SettingsOptions> options) =>
{
var settings = options.Value;
return Results.Ok(new
{
settings.NombreAplicacion,
settings.MaxItems,
Mensaje = "Opciones de configuración inyectadas correctamente."
});
})
.WithName("Configuracion");
app.MapDemoGroups(); // Llama al grupo de rutas V2
// --------------------------------------------------------------------
// MÓDULO 4: ENDPOINTS CRUD CON PERSISTENCIA Y VALIDACIÓN
// --------------------------------------------------------------------
// 3.3. Agrupación para los endpoints de ToDo list
var todoApi = app.MapGroup("/api/v1/todos")
.WithTags("ToDos (EF Core)"); // Etiqueta para Swagger/OpenAPI
// GET: Obtener todos los ítems (4.2. Lectura asíncrona)
todoApi.MapGet("/", async (ApiDbContext db) =>
await db.Todos.ToListAsync())
.WithName("GetTodos");
// GET: Obtener por ID
todoApi.MapGet("/{id:int}", async (int id, ApiDbContext db) =>
await db.Todos.FindAsync(id)
is TodoItem todo
? Results.Ok(todo)
: Results.NotFound());
// POST: Crear un nuevo ítem (4.3. Validación de Datos)
todoApi.MapPost("/", async (TodoItem todo, ApiDbContext db) =>
{
// 4.3. Validación manual simple (verificación de Data Annotations)
// Nota: Aunque el framework hace un "model binding" automático,
// la validación de Data Annotations en Minimal APIs a menudo requiere
// librerías externas (ej. FluentValidation) o validación manual explícita.
// Aquí verificamos el campo [Required]
if (string.IsNullOrWhiteSpace(todo.Title))
{
return Results.BadRequest(new { Error = "El campo 'Title' es obligatorio y no puede estar vacío." });
}
if (todo.Title.Length > 100)
{
return Results.BadRequest(new { Error = "El título excede los 100 caracteres." });
}
// 4.2. Persistencia asíncrona
db.Todos.Add(todo);
await db.SaveChangesAsync();
return Results.Created($"/api/v1/todos/{todo.Id}", todo);
});
// PUT: Actualizar un ítem
todoApi.MapPut("/{id:int}", async (int id, TodoItem inputTodo, ApiDbContext db) =>
{
// Validar el campo Title
if (string.IsNullOrWhiteSpace(inputTodo.Title))
{
return Results.BadRequest(new { Error = "El campo 'Title' es obligatorio y no puede estar vacío." });
}
// Buscar el ítem a actualizar
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
// Actualizar propiedades
todo.Title = inputTodo.Title;
todo.IsComplete = inputTodo.IsComplete;
await db.SaveChangesAsync();
return Results.NoContent();
});
// DELETE: Eliminar un ítem
todoApi.MapDelete("/{id:int}", async (int id, ApiDbContext db) =>
{
var todo = await db.Todos.FindAsync(id);
if (todo is null) return Results.NotFound();
db.Todos.Remove(todo);
await db.SaveChangesAsync();
return Results.NoContent();
});
// --------------------------------------------------------------------
// EXTENSIÓN PARA AGRUPACIÓN Y MANEJO DE ENDPOINTS FUERA DE Program.cs (MÓDULO 3 - MANTENIDOS)
// --------------------------------------------------------------------
public class OtroServicio
{
public string ObtenerInfo() => "Información de un servicio del grupo de rutas.";
}
public static class RouteGroupExtensions
{
public static void MapDemoGroups(this WebApplication app)
{
var grupoV2 = app.MapGroup("/api/v2/grupo")
.WithTags("Grupo V2");
grupoV2.MapGet("/saludo", () =>
{
return Results.Ok("¡Hola desde el Grupo V2!");
})
.WithName("GrupoV2Saludo");
grupoV2.MapGet("/info", (OtroServicio otroServicio) =>
{
return Results.Ok(new { Mensaje = otroServicio.ObtenerInfo() });
})
.WithName("GrupoV2Info");
}
}