Módulo 20: ASP.NET Core
Objetivos del módulo
- Comprender qué es ASP.NET Core y qué problema resuelve
- Crear APIs HTTP con Minimal APIs y Controllers
- Diseñar endpoints REST correctamente
- Validar datos, devolver códigos de estado adecuados y manejar errores
- Usar inyección de dependencias, servicios y configuración
- Conectar una API con Entity Framework Core
- Conocer alternativas no REST: RPC, webhooks, SignalR/WebSockets y gRPC
1. ¿Qué es ASP.NET Core?
📘 Concepto: ASP.NET Core es el framework web moderno de .NET. Permite crear aplicaciones web, APIs HTTP, servicios en tiempo real, aplicaciones con Razor Pages, MVC, SignalR, gRPC y mucho más.
En este módulo nos centraremos en crear APIs, es decir, aplicaciones que reciben peticiones y devuelven datos.
| Concepto | Significado |
|---|---|
| API | Interfaz para que otros programas se comuniquen con nuestra aplicación |
| HTTP | Protocolo usado por navegadores, apps móviles y servicios web |
| Endpoint | URL concreta que ejecuta una acción |
| Request | Petición que llega al servidor |
| Response | Respuesta que devuelve el servidor |
| JSON | Formato habitual para enviar y recibir datos |
| Middleware | Pieza del pipeline que procesa peticiones |
| DI | Inyección de dependencias, sistema para pedir servicios ya configurados |
Ejemplo de comunicación:
GET /api/productos/3
Accept: application/json
Respuesta:
{
"id": 3,
"nombre": "Teclado mecánico",
"precio": 89.99,
"stock": 12
}
2. Crear un proyecto Web API
dotnet new webapi -n TiendaApi
cd TiendaApi
dotnet run
Al arrancar la aplicación verás una salida similar:
Now listening on: https://localhost:7001
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
Puedes probar la API desde:
- El navegador
- Visual Studio Community
- Visual Studio Code con extensiones REST Client
- Postman, Insomnia o Bruno
curldesde la terminal
curl https://localhost:7001/weatherforecast
💡 Consejo: Si el navegador muestra un aviso por el certificado HTTPS local, ejecuta
dotnet dev-certs https --trustuna vez en tu equipo de desarrollo.
Instalación de paquetes en Visual Studio Community
Para crear una API básica con ASP.NET Core no necesitas instalar un paquete NuGet adicional: el framework web viene incluido con el .NET SDK.
Sí tendrás que instalar paquetes cuando añadas funcionalidades concretas:
| Funcionalidad | Paquete habitual |
|---|---|
| Entity Framework Core con SQLite | Microsoft.EntityFrameworkCore.Sqlite |
| Migraciones EF Core | Microsoft.EntityFrameworkCore.Design |
| Consola PMC de EF Core en Visual Studio | Microsoft.EntityFrameworkCore.Tools |
| Autenticación JWT | Microsoft.AspNetCore.Authentication.JwtBearer |
| Swagger UI clásica | Swashbuckle.AspNetCore |
En Visual Studio Community puedes instalarlos desde Herramientas → Administrador de paquetes NuGet → Administrar paquetes NuGet para la solución…. Después elige el proyecto de la API, busca el paquete e instálalo.
Administrador de paquetes NuGet: instala solo los paquetes que necesite la funcionalidad que vas a usar
⚠️ Importante: Si tu solución tiene varios proyectos, asegúrate de instalar el paquete en el proyecto correcto, normalmente el proyecto de la API.
Instalación de paquetes en Visual Studio Code
En Visual Studio Code se usa normalmente la terminal integrada. Abre la carpeta del proyecto donde está el archivo .csproj y ejecuta los comandos necesarios:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet tool install --global dotnet-ef
Terminal integrada de VS Code: ejecuta los comandos dentro de la carpeta del proyecto
También puedes revisar el archivo .csproj para comprobar que se han añadido las referencias:
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
</ItemGroup>
💡 Consejo: Las versiones pueden cambiar con el tiempo. Lo importante es que los paquetes sean compatibles con la versión de .NET que estás usando en el proyecto.
3. Anatomía de una API mínima
Un proyecto ASP.NET Core moderno puede empezar con muy poco código:
var builder = WebApplication.CreateBuilder(args);
// Registrar servicios
builder.Services.AddOpenApi();
var app = builder.Build();
// Configurar el pipeline HTTP
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Definir endpoints
app.MapGet("/", () => "API funcionando");
app.Run();
| Línea | Para qué sirve |
|---|---|
CreateBuilder | Crea la configuración inicial de la aplicación |
builder.Services | Registra servicios para inyección de dependencias |
builder.Build() | Construye la aplicación |
app.Use... | Añade middleware al pipeline |
app.MapGet | Define un endpoint HTTP GET |
app.Run() | Arranca el servidor |
4. Minimal APIs
📘 Concepto: Las Minimal APIs permiten crear endpoints directamente en
Program.cs, con poco código y mucha claridad. Son ideales para APIs pequeñas, microservicios o ejemplos didácticos.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
List<Producto> productos = new()
{
new(1, "Teclado", 89.99m, 12),
new(2, "Ratón", 29.99m, 40),
new(3, "Monitor", 199.99m, 8)
};
app.MapGet("/api/productos", () => productos);
app.MapGet("/api/productos/{id:int}", (int id) =>
{
Producto? producto = productos.FirstOrDefault(p => p.Id == id);
return producto is null
? Results.NotFound()
: Results.Ok(producto);
});
app.MapPost("/api/productos", (CrearProductoRequest request) =>
{
int nuevoId = productos.Max(p => p.Id) + 1;
Producto producto = new(nuevoId, request.Nombre, request.Precio, request.Stock);
productos.Add(producto);
return Results.Created($"/api/productos/{producto.Id}", producto);
});
app.Run();
record Producto(int Id, string Nombre, decimal Precio, int Stock);
record CrearProductoRequest(string Nombre, decimal Precio, int Stock);
Pruebas con curl:
curl https://localhost:7001/api/productos
curl https://localhost:7001/api/productos/2
curl -X POST https://localhost:7001/api/productos ^
-H "Content-Type: application/json" ^
-d "{\"nombre\":\"Webcam\",\"precio\":49.99,\"stock\":20}"
5. HTTP y REST
📘 Concepto: REST es un estilo de diseño para APIs HTTP. La idea principal es representar recursos con URLs y operar sobre ellos usando métodos HTTP.
En una API REST no usamos rutas como:
/crearProducto
/borrarProducto
/obtenerProducto
Usamos recursos:
GET /api/productos
GET /api/productos/5
POST /api/productos
PUT /api/productos/5
PATCH /api/productos/5
DELETE /api/productos/5
| Método | Uso típico | Ejemplo |
|---|---|---|
GET | Leer datos | GET /api/productos |
POST | Crear un recurso | POST /api/productos |
PUT | Reemplazar un recurso completo | PUT /api/productos/5 |
PATCH | Actualizar parte de un recurso | PATCH /api/productos/5 |
DELETE | Eliminar un recurso | DELETE /api/productos/5 |
Códigos de estado habituales
| Código | Significado | Cuándo usarlo |
|---|---|---|
200 OK | Correcto | Lectura o actualización correcta |
201 Created | Creado | Después de un POST exitoso |
204 No Content | Correcto sin cuerpo | Después de borrar o actualizar sin devolver datos |
400 Bad Request | Petición incorrecta | Datos inválidos |
401 Unauthorized | No autenticado | Falta iniciar sesión o token |
403 Forbidden | Prohibido | Autenticado, pero sin permiso |
404 Not Found | No encontrado | El recurso no existe |
409 Conflict | Conflicto | Duplicados, reglas de negocio |
500 Internal Server Error | Error interno | Excepción no controlada |
6. CRUD REST completo con Minimal APIs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
List<Producto> productos = new()
{
new(1, "Teclado", "Periféricos", 89.99m, 12),
new(2, "Ratón", "Periféricos", 29.99m, 40),
new(3, "Monitor", "Pantallas", 199.99m, 8)
};
app.MapGet("/api/productos", (string? categoria, decimal? precioMaximo) =>
{
IEnumerable<Producto> consulta = productos;
if (!string.IsNullOrWhiteSpace(categoria))
consulta = consulta.Where(p => p.Categoria.Equals(categoria, StringComparison.OrdinalIgnoreCase));
if (precioMaximo is not null)
consulta = consulta.Where(p => p.Precio <= precioMaximo);
return Results.Ok(consulta.OrderBy(p => p.Nombre));
});
app.MapGet("/api/productos/{id:int}", (int id) =>
{
Producto? producto = productos.FirstOrDefault(p => p.Id == id);
return producto is null ? Results.NotFound() : Results.Ok(producto);
});
app.MapPost("/api/productos", (ProductoCrearDto dto) =>
{
if (string.IsNullOrWhiteSpace(dto.Nombre))
return Results.BadRequest("El nombre es obligatorio");
if (dto.Precio <= 0)
return Results.BadRequest("El precio debe ser mayor que 0");
int nuevoId = productos.Count == 0 ? 1 : productos.Max(p => p.Id) + 1;
Producto producto = new(
nuevoId,
dto.Nombre.Trim(),
dto.Categoria.Trim(),
dto.Precio,
dto.Stock
);
productos.Add(producto);
return Results.Created($"/api/productos/{producto.Id}", producto);
});
app.MapPut("/api/productos/{id:int}", (int id, ProductoActualizarDto dto) =>
{
int index = productos.FindIndex(p => p.Id == id);
if (index == -1)
return Results.NotFound();
Producto actualizado = new(id, dto.Nombre, dto.Categoria, dto.Precio, dto.Stock);
productos[index] = actualizado;
return Results.Ok(actualizado);
});
app.MapPatch("/api/productos/{id:int}/stock", (int id, CambiarStockDto dto) =>
{
Producto? producto = productos.FirstOrDefault(p => p.Id == id);
if (producto is null)
return Results.NotFound();
Producto actualizado = producto with { Stock = dto.Stock };
productos[productos.IndexOf(producto)] = actualizado;
return Results.Ok(actualizado);
});
app.MapDelete("/api/productos/{id:int}", (int id) =>
{
Producto? producto = productos.FirstOrDefault(p => p.Id == id);
if (producto is null)
return Results.NotFound();
productos.Remove(producto);
return Results.NoContent();
});
app.Run();
record Producto(int Id, string Nombre, string Categoria, decimal Precio, int Stock);
record ProductoCrearDto(string Nombre, string Categoria, decimal Precio, int Stock);
record ProductoActualizarDto(string Nombre, string Categoria, decimal Precio, int Stock);
record CambiarStockDto(int Stock);
Ejemplos de uso:
curl https://localhost:7001/api/productos
curl "https://localhost:7001/api/productos?categoria=Periféricos&precioMaximo=100"
curl -X PUT https://localhost:7001/api/productos/1 ^
-H "Content-Type: application/json" ^
-d "{\"nombre\":\"Teclado Pro\",\"categoria\":\"Periféricos\",\"precio\":109.99,\"stock\":5}"
curl -X PATCH https://localhost:7001/api/productos/1/stock ^
-H "Content-Type: application/json" ^
-d "{\"stock\":25}"
curl -X DELETE https://localhost:7001/api/productos/3
7. Controllers
📘 Concepto: Los Controllers organizan los endpoints en clases. Son muy usados en APIs grandes porque separan mejor la lógica por recurso.
Para usar controllers:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
Controller de ejemplo:
// Controllers/ProductosController.cs
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class ProductosController : ControllerBase
{
private static readonly List<Producto> Productos = new()
{
new(1, "Teclado", "Periféricos", 89.99m, 12),
new(2, "Ratón", "Periféricos", 29.99m, 40)
};
[HttpGet]
public ActionResult<List<Producto>> ObtenerTodos()
{
return Ok(Productos);
}
[HttpGet("{id:int}")]
public ActionResult<Producto> ObtenerPorId(int id)
{
Producto? producto = Productos.FirstOrDefault(p => p.Id == id);
if (producto is null)
return NotFound();
return Ok(producto);
}
[HttpPost]
public ActionResult<Producto> Crear(ProductoCrearDto dto)
{
int nuevoId = Productos.Max(p => p.Id) + 1;
Producto producto = new(nuevoId, dto.Nombre, dto.Categoria, dto.Precio, dto.Stock);
Productos.Add(producto);
return CreatedAtAction(nameof(ObtenerPorId), new { id = producto.Id }, producto);
}
[HttpDelete("{id:int}")]
public IActionResult Eliminar(int id)
{
Producto? producto = Productos.FirstOrDefault(p => p.Id == id);
if (producto is null)
return NotFound();
Productos.Remove(producto);
return NoContent();
}
}
public record Producto(int Id, string Nombre, string Categoria, decimal Precio, int Stock);
public record ProductoCrearDto(string Nombre, string Categoria, decimal Precio, int Stock);
| Minimal APIs | Controllers |
|---|---|
| Menos código inicial | Mejor organización en APIs grandes |
| Perfectas para microservicios | Muy cómodos para equipos grandes |
Endpoints en Program.cs o extensiones | Endpoints agrupados en clases |
| Muy directas | Integran filtros, convenciones y atributos |
8. DTOs y validación
📘 Concepto: Un DTO (Data Transfer Object) es una clase o record que representa los datos que entran o salen de la API. No tiene por qué ser igual al modelo de base de datos.
Ejemplo con validación:
using System.ComponentModel.DataAnnotations;
public class CrearProductoDto
{
[Required(ErrorMessage = "El nombre es obligatorio")]
[StringLength(80, MinimumLength = 2)]
public string Nombre { get; set; } = "";
[Required]
[StringLength(50)]
public string Categoria { get; set; } = "";
[Range(0.01, 99999)]
public decimal Precio { get; set; }
[Range(0, 100000)]
public int Stock { get; set; }
}
Con [ApiController], ASP.NET Core valida automáticamente los atributos y devuelve 400 Bad Request si el modelo no es válido:
[HttpPost]
public ActionResult<Producto> Crear(CrearProductoDto dto)
{
Producto producto = new()
{
Nombre = dto.Nombre,
Categoria = dto.Categoria,
Precio = dto.Precio,
Stock = dto.Stock
};
// Guardar producto...
return CreatedAtAction(nameof(ObtenerPorId), new { id = producto.Id }, producto);
}
Separar entidad y DTO evita problemas:
// Entidad interna
public class Usuario
{
public int Id { get; set; }
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = "";
public bool EsAdmin { get; set; }
}
// Respuesta pública
public record UsuarioResponse(int Id, string Email);
⚠️ Importante: Nunca devuelvas campos sensibles como contraseñas, hashes, tokens internos o datos privados que el cliente no necesita.
9. Inyección de dependencias y servicios
En una API real no queremos meter toda la lógica dentro del endpoint. Creamos servicios:
public interface IProductoService
{
Task<List<Producto>> ObtenerTodosAsync();
Task<Producto?> ObtenerPorIdAsync(int id);
Task<Producto> CrearAsync(CrearProductoDto dto);
}
Implementación:
public class ProductoService : IProductoService
{
private readonly List<Producto> _productos = new()
{
new(1, "Teclado", "Periféricos", 89.99m, 12),
new(2, "Ratón", "Periféricos", 29.99m, 40)
};
public Task<List<Producto>> ObtenerTodosAsync()
{
return Task.FromResult(_productos.OrderBy(p => p.Nombre).ToList());
}
public Task<Producto?> ObtenerPorIdAsync(int id)
{
Producto? producto = _productos.FirstOrDefault(p => p.Id == id);
return Task.FromResult(producto);
}
public Task<Producto> CrearAsync(CrearProductoDto dto)
{
int nuevoId = _productos.Max(p => p.Id) + 1;
Producto producto = new(nuevoId, dto.Nombre, dto.Categoria, dto.Precio, dto.Stock);
_productos.Add(producto);
return Task.FromResult(producto);
}
}
Registro en Program.cs:
builder.Services.AddSingleton<IProductoService, ProductoService>();
Uso desde Minimal API:
app.MapGet("/api/productos", async (IProductoService service) =>
{
List<Producto> productos = await service.ObtenerTodosAsync();
return Results.Ok(productos);
});
app.MapPost("/api/productos", async (CrearProductoDto dto, IProductoService service) =>
{
Producto producto = await service.CrearAsync(dto);
return Results.Created($"/api/productos/{producto.Id}", producto);
});
Uso desde Controller:
[ApiController]
[Route("api/[controller]")]
public class ProductosController : ControllerBase
{
private readonly IProductoService _service;
public ProductosController(IProductoService service)
{
_service = service;
}
[HttpGet]
public async Task<ActionResult<List<Producto>>> ObtenerTodos()
{
return Ok(await _service.ObtenerTodosAsync());
}
}
| Registro | Duración | Uso típico |
|---|---|---|
AddTransient | Nueva instancia cada vez | Servicios ligeros sin estado |
AddScoped | Una instancia por request | Servicios que usan DbContext |
AddSingleton | Una instancia para toda la app | Configuración, cachés simples |
10. API con Entity Framework Core
Instalación:
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-ef
Entidad:
// Models/Producto.cs
public class Producto
{
public int Id { get; set; }
public string Nombre { get; set; } = "";
public string Categoria { get; set; } = "";
public decimal Precio { get; set; }
public int Stock { get; set; }
public DateTime FechaCreacion { get; set; } = DateTime.UtcNow;
}
DbContext:
// Data/TiendaContext.cs
using Microsoft.EntityFrameworkCore;
public class TiendaContext : DbContext
{
public TiendaContext(DbContextOptions<TiendaContext> options) : base(options)
{
}
public DbSet<Producto> Productos => Set<Producto>();
}
Registro:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TiendaContext>(options =>
options.UseSqlite("Data Source=tienda.db"));
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
Crear migración:
dotnet ef migrations add Inicial
dotnet ef database update
Controller usando EF Core:
[ApiController]
[Route("api/[controller]")]
public class ProductosController : ControllerBase
{
private readonly TiendaContext _db;
public ProductosController(TiendaContext db)
{
_db = db;
}
[HttpGet]
public async Task<ActionResult<List<Producto>>> ObtenerTodos(
string? categoria,
decimal? precioMaximo,
int pagina = 1,
int tamano = 10)
{
if (pagina < 1) pagina = 1;
if (tamano is < 1 or > 100) tamano = 10;
IQueryable<Producto> consulta = _db.Productos.AsNoTracking();
if (!string.IsNullOrWhiteSpace(categoria))
consulta = consulta.Where(p => p.Categoria == categoria);
if (precioMaximo is not null)
consulta = consulta.Where(p => p.Precio <= precioMaximo);
List<Producto> productos = await consulta
.OrderBy(p => p.Nombre)
.Skip((pagina - 1) * tamano)
.Take(tamano)
.ToListAsync();
return Ok(productos);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<Producto>> ObtenerPorId(int id)
{
Producto? producto = await _db.Productos.FindAsync(id);
if (producto is null)
return NotFound();
return Ok(producto);
}
[HttpPost]
public async Task<ActionResult<Producto>> Crear(CrearProductoDto dto)
{
Producto producto = new()
{
Nombre = dto.Nombre,
Categoria = dto.Categoria,
Precio = dto.Precio,
Stock = dto.Stock
};
_db.Productos.Add(producto);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(ObtenerPorId), new { id = producto.Id }, producto);
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Actualizar(int id, ActualizarProductoDto dto)
{
Producto? producto = await _db.Productos.FindAsync(id);
if (producto is null)
return NotFound();
producto.Nombre = dto.Nombre;
producto.Categoria = dto.Categoria;
producto.Precio = dto.Precio;
producto.Stock = dto.Stock;
await _db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Eliminar(int id)
{
Producto? producto = await _db.Productos.FindAsync(id);
if (producto is null)
return NotFound();
_db.Productos.Remove(producto);
await _db.SaveChangesAsync();
return NoContent();
}
}
public record CrearProductoDto(string Nombre, string Categoria, decimal Precio, int Stock);
public record ActualizarProductoDto(string Nombre, string Categoria, decimal Precio, int Stock);
11. Configuración
ASP.NET Core lee configuración desde varios sitios:
appsettings.jsonappsettings.Development.json- Variables de entorno
- Secret Manager en desarrollo
- Parámetros de línea de comandos
Ejemplo de appsettings.json:
{
"ConnectionStrings": {
"Tienda": "Data Source=tienda.db"
},
"OpcionesTienda": {
"StockMinimo": 5,
"PermitirVentasSinStock": false
}
}
Leer cadena de conexión:
builder.Services.AddDbContext<TiendaContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Tienda")));
Clase de opciones:
public class OpcionesTienda
{
public int StockMinimo { get; set; }
public bool PermitirVentasSinStock { get; set; }
}
Registro:
builder.Services.Configure<OpcionesTienda>(
builder.Configuration.GetSection("OpcionesTienda"));
Uso:
using Microsoft.Extensions.Options;
public class StockService
{
private readonly OpcionesTienda _opciones;
public StockService(IOptions<OpcionesTienda> opciones)
{
_opciones = opciones.Value;
}
public bool EstaBajoMinimo(int stock)
{
return stock <= _opciones.StockMinimo;
}
}
12. Manejo de errores
Una API debe devolver errores claros, consistentes y seguros.
if (producto is null)
return Results.NotFound(new { mensaje = "Producto no encontrado" });
if (dto.Precio <= 0)
return Results.BadRequest(new { mensaje = "El precio debe ser mayor que 0" });
También podemos usar ProblemDetails, un formato estándar para errores HTTP:
app.MapPost("/api/productos", (CrearProductoDto dto) =>
{
if (dto.Precio <= 0)
{
return Results.Problem(
title: "Precio inválido",
detail: "El precio debe ser mayor que 0.",
statusCode: StatusCodes.Status400BadRequest
);
}
return Results.Ok();
});
Manejo global de excepciones:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapGet("/error-demo", () =>
{
throw new InvalidOperationException("Error de prueba");
});
app.Run();
⚠️ Importante: En producción no devuelvas detalles internos de excepciones, trazas completas ni datos de conexión. Esos datos ayudan a depurar, pero también pueden ayudar a atacar la aplicación.
13. OpenAPI y documentación
OpenAPI describe los endpoints de la API para que humanos y herramientas puedan entenderla.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapGet("/api/productos", () => new[]
{
new ProductoResponse(1, "Teclado", 89.99m),
new ProductoResponse(2, "Ratón", 29.99m)
})
.WithName("ObtenerProductos")
.WithSummary("Obtiene todos los productos")
.WithDescription("Devuelve la lista completa de productos disponibles en la tienda.")
.Produces<List<ProductoResponse>>(StatusCodes.Status200OK);
app.Run();
record ProductoResponse(int Id, string Nombre, decimal Precio);
En un controller puedes documentar con atributos:
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(Producto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Producto>> ObtenerPorId(int id)
{
Producto? producto = await _db.Productos.FindAsync(id);
return producto is null ? NotFound() : Ok(producto);
}
14. CORS
📘 Concepto: CORS controla qué aplicaciones web pueden llamar a tu API desde un navegador.
Si tu frontend está en http://localhost:5173 y tu API en https://localhost:7001, necesitas permitir ese origen:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCors(options =>
{
options.AddPolicy("FrontendLocal", policy =>
{
policy
.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.AllowAnyMethod();
});
});
var app = builder.Build();
app.UseCors("FrontendLocal");
app.MapGet("/api/productos", () => new[] { "Teclado", "Ratón" });
app.Run();
⚠️ Importante:
AllowAnyOrigin()es cómodo para pruebas, pero en producción conviene permitir solo los dominios reales de tu aplicación.
15. Autenticación sencilla con JWT
📘 Concepto: En muchas APIs el cliente envía un token en cada petición. El servidor valida ese token y decide si permite acceder al endpoint.
Ejemplo de petición autenticada:
GET /api/pedidos
Authorization: Bearer eyJhbGciOi...
Configurar autenticación:
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
string clave = builder.Configuration["Jwt:Clave"]!;
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(clave))
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/api/publico", () => "Cualquiera puede entrar");
app.MapGet("/api/privado", () => "Solo con token")
.RequireAuthorization();
app.Run();
💡 Consejo: En una aplicación real, guarda secretos con Secret Manager, variables de entorno o un sistema seguro de secretos. No subas claves JWT a Git.
16. APIs no REST
No todas las APIs tienen que ser REST. REST es muy común, pero no es la única opción.
| Estilo | Cuándo encaja |
|---|---|
| RPC sobre HTTP | Acciones concretas que no encajan bien como recurso |
| Webhooks | Tu API avisa a otro sistema cuando ocurre algo |
| WebSockets/SignalR | Comunicación en tiempo real |
| gRPC | Comunicación eficiente entre servicios |
17. RPC sobre HTTP
📘 Concepto: En una API tipo RPC la ruta representa una acción, no un recurso. Suele usar
POSTporque el cliente está pidiendo “ejecuta esta operación”.
Ejemplo: calcular el precio final de un carrito.
app.MapPost("/api/calculos/precio-final", (CalcularPrecioRequest request) =>
{
decimal subtotal = request.Lineas.Sum(l => l.PrecioUnitario * l.Cantidad);
decimal descuento = subtotal * request.DescuentoPorcentaje / 100;
decimal baseImponible = subtotal - descuento;
decimal iva = baseImponible * 0.21m;
decimal total = baseImponible + iva;
return Results.Ok(new CalcularPrecioResponse(
Subtotal: subtotal,
Descuento: descuento,
Iva: iva,
Total: total
));
});
record CalcularPrecioRequest(List<LineaCarrito> Lineas, decimal DescuentoPorcentaje);
record LineaCarrito(string Producto, int Cantidad, decimal PrecioUnitario);
record CalcularPrecioResponse(decimal Subtotal, decimal Descuento, decimal Iva, decimal Total);
Petición:
curl -X POST https://localhost:7001/api/calculos/precio-final ^
-H "Content-Type: application/json" ^
-d "{\"descuentoPorcentaje\":10,\"lineas\":[{\"producto\":\"Teclado\",\"cantidad\":1,\"precioUnitario\":89.99}]}"
Otro ejemplo: importar un catálogo.
app.MapPost("/api/catalogo/importar", async (ImportarCatalogoRequest request) =>
{
await Task.Delay(1000); // Simula procesamiento
return Results.Accepted(
value: new
{
mensaje = "Importación iniciada",
archivo = request.NombreArchivo,
productos = request.Productos.Count
}
);
});
record ImportarCatalogoRequest(string NombreArchivo, List<ProductoImportado> Productos);
record ProductoImportado(string Sku, string Nombre, decimal Precio);
💡 Consejo: RPC no es “malo”. Simplemente tiene otro objetivo. Si estás trabajando con recursos claros, REST suele ser más natural. Si estás ejecutando acciones de negocio, RPC puede ser más expresivo.
18. Webhooks
📘 Concepto: Un webhook es una llamada HTTP que un sistema envía a otro cuando ocurre un evento. En vez de preguntar constantemente “¿hay cambios?”, el sistema recibe una notificación.
Ejemplo: un proveedor de pagos avisa a nuestra API de que un pago se ha completado.
app.MapPost("/api/webhooks/pagos", async (
PagoWebhook evento,
ILogger<Program> logger) =>
{
logger.LogInformation(
"Pago recibido: {PagoId}, estado: {Estado}",
evento.PagoId,
evento.Estado
);
if (evento.Estado == "completed")
{
// Marcar pedido como pagado
await Task.Delay(100);
}
return Results.Ok(new { recibido = true });
});
record PagoWebhook(
string EventoId,
string PagoId,
string PedidoId,
string Estado,
decimal Importe,
DateTime Fecha);
Validación básica con una firma:
app.MapPost("/api/webhooks/pagos-seguro", async (
HttpRequest request,
IConfiguration configuration) =>
{
string firmaRecibida = request.Headers["X-Firma"].ToString();
string firmaEsperada = configuration["Webhooks:Clave"] ?? "";
if (firmaRecibida != firmaEsperada)
return Results.Unauthorized();
PagoWebhook? evento = await request.ReadFromJsonAsync<PagoWebhook>();
if (evento is null)
return Results.BadRequest();
return Results.Ok(new { recibido = true, evento.EventoId });
});
⚠️ Importante: Un webhook real debe validar la firma criptográfica enviada por el proveedor, ser idempotente y responder rápido. Si llega el mismo evento dos veces, no debería duplicar pagos ni pedidos.
19. Tiempo real con SignalR
📘 Concepto: SignalR permite comunicación en tiempo real entre servidor y clientes. Es ideal para chats, paneles en vivo, notificaciones, juegos sencillos o seguimiento de pedidos.
Crear un hub:
// Hubs/NotificacionesHub.cs
using Microsoft.AspNetCore.SignalR;
public class NotificacionesHub : Hub
{
public async Task UnirseAGrupo(string grupo)
{
await Groups.AddToGroupAsync(Context.ConnectionId, grupo);
await Clients.Caller.SendAsync("Sistema", $"Unido al grupo {grupo}");
}
public async Task EnviarMensaje(string grupo, string usuario, string mensaje)
{
await Clients.Group(grupo).SendAsync("MensajeRecibido", usuario, mensaje);
}
}
Registrar SignalR:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
var app = builder.Build();
app.MapHub<NotificacionesHub>("/hubs/notificaciones");
app.MapPost("/api/pedidos/{id:int}/enviado", async (
int id,
IHubContext<NotificacionesHub> hub) =>
{
await hub.Clients.Group($"pedido-{id}")
.SendAsync("PedidoActualizado", id, "Enviado");
return Results.Ok();
});
app.Run();
Cliente JavaScript de ejemplo:
<script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@latest/dist/browser/signalr.min.js"></script>
<script type="module">
const conexion = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notificaciones")
.build();
conexion.on("MensajeRecibido", (usuario, mensaje) => {
console.log(`${usuario}: ${mensaje}`);
});
conexion.on("PedidoActualizado", (id, estado) => {
console.log(`Pedido ${id}: ${estado}`);
});
await conexion.start();
await conexion.invoke("UnirseAGrupo", "pedido-42");
</script>
20. gRPC
📘 Concepto: gRPC es una tecnología para comunicación entre servicios. Usa contratos
.proto, genera código fuertemente tipado y suele ser muy eficiente.
Crear proyecto:
dotnet new grpc -n InventarioGrpc
cd InventarioGrpc
dotnet run
Contrato Protos/inventario.proto:
syntax = "proto3";
option csharp_namespace = "InventarioGrpc";
package inventario;
service Inventario {
rpc ObtenerStock (StockRequest) returns (StockResponse);
rpc ReservarStock (ReservarStockRequest) returns (ReservarStockResponse);
}
message StockRequest {
int32 producto_id = 1;
}
message StockResponse {
int32 producto_id = 1;
int32 stock = 2;
}
message ReservarStockRequest {
int32 producto_id = 1;
int32 cantidad = 2;
}
message ReservarStockResponse {
bool reservado = 1;
string mensaje = 2;
}
Servicio:
using Grpc.Core;
public class InventarioService : Inventario.InventarioBase
{
private static readonly Dictionary<int, int> Stock = new()
{
[1] = 10,
[2] = 25
};
public override Task<StockResponse> ObtenerStock(
StockRequest request,
ServerCallContext context)
{
int stock = Stock.GetValueOrDefault(request.ProductoId, 0);
return Task.FromResult(new StockResponse
{
ProductoId = request.ProductoId,
Stock = stock
});
}
public override Task<ReservarStockResponse> ReservarStock(
ReservarStockRequest request,
ServerCallContext context)
{
int stockActual = Stock.GetValueOrDefault(request.ProductoId, 0);
if (stockActual < request.Cantidad)
{
return Task.FromResult(new ReservarStockResponse
{
Reservado = false,
Mensaje = "Stock insuficiente"
});
}
Stock[request.ProductoId] = stockActual - request.Cantidad;
return Task.FromResult(new ReservarStockResponse
{
Reservado = true,
Mensaje = "Stock reservado"
});
}
}
Registro:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddGrpc();
var app = builder.Build();
app.MapGrpcService<InventarioService>();
app.MapGet("/", () => "Servicio gRPC de inventario");
app.Run();
| REST | gRPC |
|---|---|
| Muy fácil de probar con navegador/curl | Requiere cliente gRPC |
| Usa JSON habitualmente | Usa Protobuf |
| Excelente para APIs públicas | Excelente entre microservicios internos |
| Más legible para humanos | Más eficiente y tipado |
21. Middleware
📘 Concepto: El middleware es una cadena de componentes que procesan cada petición HTTP antes y después de llegar al endpoint.
Ejemplo de middleware simple para medir tiempo:
app.Use(async (context, next) =>
{
var reloj = System.Diagnostics.Stopwatch.StartNew();
await next(context);
reloj.Stop();
Console.WriteLine(
$"{context.Request.Method} {context.Request.Path} -> " +
$"{context.Response.StatusCode} en {reloj.ElapsedMilliseconds}ms"
);
});
Orden habitual:
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseCors("FrontendLocal");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
⚠️ Importante: El orden importa. Por ejemplo,
UseAuthentication()debe ir antes deUseAuthorization().
22. Logging
ASP.NET Core incluye logging integrado.
app.MapGet("/api/productos/{id:int}", async (
int id,
TiendaContext db,
ILogger<Program> logger) =>
{
logger.LogInformation("Buscando producto {ProductoId}", id);
Producto? producto = await db.Productos.FindAsync(id);
if (producto is null)
{
logger.LogWarning("Producto {ProductoId} no encontrado", id);
return Results.NotFound();
}
return Results.Ok(producto);
});
Niveles comunes:
| Nivel | Uso |
|---|---|
Trace | Información muy detallada |
Debug | Depuración durante desarrollo |
Information | Eventos normales importantes |
Warning | Algo inesperado pero recuperable |
Error | Error que impide completar una operación |
Critical | Fallo grave del sistema |
23. Buenas prácticas para APIs
| Práctica | Motivo |
|---|---|
| Usa DTOs | Evita exponer tus entidades internas |
| Devuelve códigos HTTP correctos | Hace la API predecible |
| Valida la entrada | Protege reglas de negocio y datos |
| Usa async/await | Mejora escalabilidad en I/O |
| No filtres secretos en respuestas | Seguridad |
| Pagina listados grandes | Evita respuestas enormes |
Usa AsNoTracking() en lecturas EF | Reduce trabajo cuando no vas a modificar |
| Documenta con OpenAPI | Facilita pruebas e integración |
| Mantén endpoints pequeños | Mejora legibilidad y testabilidad |
| Centraliza errores comunes | Respuestas consistentes |
Ejemplo de respuesta paginada:
public record Pagina<T>(
int PaginaActual,
int Tamano,
int Total,
List<T> Items);
app.MapGet("/api/productos-paginados", async (
TiendaContext db,
int pagina = 1,
int tamano = 10) =>
{
IQueryable<Producto> consulta = db.Productos.AsNoTracking();
int total = await consulta.CountAsync();
List<Producto> items = await consulta
.OrderBy(p => p.Id)
.Skip((pagina - 1) * tamano)
.Take(tamano)
.ToListAsync();
return Results.Ok(new Pagina<Producto>(pagina, tamano, total, items));
});
24. Ejercicios
Ejercicio 1: API REST de tareas
Crea una API con endpoints:
| Método | Ruta | Acción |
|---|---|---|
GET | /api/tareas | Listar tareas |
GET | /api/tareas/{id} | Obtener una tarea |
POST | /api/tareas | Crear tarea |
PUT | /api/tareas/{id} | Actualizar tarea |
PATCH | /api/tareas/{id}/completar | Marcar como completada |
DELETE | /api/tareas/{id} | Eliminar tarea |
Añade filtros por estado, prioridad y fecha límite.
Ejercicio 2: API de biblioteca con EF Core
Convierte parte del proyecto final de biblioteca en una API:
GET /api/librosGET /api/libros/{id}POST /api/librosPUT /api/libros/{id}DELETE /api/libros/{id}GET /api/libros?autor=Cervantes&genero=Novela
Usa SQLite, migraciones, DTOs y validaciones.
Ejercicio 3: Endpoint RPC de informes
Crea un endpoint:
POST /api/informes/ventas-mensuales
Debe recibir fecha inicial, fecha final y categoría. Devuelve total de ventas, producto más vendido, ticket medio y ventas agrupadas por mes.
Ejercicio 4: Webhook de pagos
Implementa:
POST /api/webhooks/pagos
Debe recibir eventos payment.completed, payment.failed y payment.refunded. Guarda los eventos recibidos y evita procesar dos veces el mismo EventoId.
Ejercicio 5: Notificaciones en tiempo real
Crea un hub SignalR para que los clientes reciban notificaciones cuando:
- Se crea un pedido
- Cambia el estado del pedido
- Un producto baja de stock mínimo
Ejercicio 6: API segura
Añade autenticación JWT a una API y protege los endpoints de escritura:
POSTPUTPATCHDELETE
Deja públicos los endpoints de lectura.
Resumen
| Tema | Idea clave |
|---|---|
| ASP.NET Core | Framework web de .NET |
| Minimal APIs | Endpoints pequeños y directos |
| Controllers | Organización por clases y atributos |
| REST | Recursos + métodos HTTP |
| DTOs | Modelos de entrada/salida de la API |
| DI | Servicios inyectados en endpoints o controllers |
| EF Core | Persistencia con base de datos |
| OpenAPI | Documentación de la API |
| CORS | Permisos entre frontend y API |
| JWT | Autenticación basada en tokens |
| RPC | Endpoints orientados a acciones |
| Webhooks | Notificaciones entre sistemas |
| SignalR | Tiempo real |
| gRPC | Comunicación eficiente entre servicios |