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
  • curl desde la terminal
curl https://localhost:7001/weatherforecast

💡 Consejo: Si el navegador muestra un aviso por el certificado HTTPS local, ejecuta dotnet dev-certs https --trust una 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.

Instalar paquetes ASP.NET Core desde NuGet en Visual Studio Community 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

Instalar paquetes ASP.NET Core desde la terminal de Visual Studio Code 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.json
  • appsettings.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 POST porque 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 de UseAuthorization().


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/libros
  • GET /api/libros/{id}
  • POST /api/libros
  • PUT /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:

  • POST
  • PUT
  • PATCH
  • DELETE

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