En esta página
Controladores y APIs REST en ASP.NET Core
Controladores en ASP.NET Core
Mientras que las Minimal APIs son ideales para APIs simples, los controladores ofrecen mejor organización para APIs grandes:
- Agrupación lógica de endpoints en una clase
- Herencia para compartir comportamiento
- Filtros a nivel de controlador o acción
- Mejor integración con herramientas de testing
// Estructura mínima de un controlador
[ApiController]
[Route("api/[controller]")]
public class PedidosController : ControllerBase
{
[HttpGet]
public IActionResult ObtenerTodos() => Ok(new[] { "pedido1", "pedido2" });
}[ApiController] y sus comportamientos
El atributo [ApiController] activa automáticamente:
- Validación del ModelState — devuelve 400 si los datos no son válidos
- Inferencia de fuentes —
[FromBody],[FromRoute],[FromQuery]se infieren - Respuestas de error estándar ProblemDetails (RFC 7807)
// Sin [ApiController] — necesitas verificar manualmente
[HttpPost]
public IActionResult Crear(ProductoDto dto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
// ...
}
// Con [ApiController] — validación automática, sin boilerplate
[HttpPost]
public IActionResult Crear(ProductoDto dto)
{
// Si dto no es válido, este código nunca se ejecuta
// ASP.NET ya devolvió un 400 con los errores
return Ok();
}Routing
ASP.NET Core soporta varios patrones de routing:
// Route en el controlador
[Route("api/v1/[controller]")] // [controller] → nombre del controlador sin "Controller"
// Route en el controlador con versión
[Route("api/v{version:apiVersion}/productos")]
// Routes en acciones
[HttpGet] // GET /api/v1/productos
[HttpGet("{id:int}")] // GET /api/v1/productos/5
[HttpGet("{id:int}/detalle")] // GET /api/v1/productos/5/detalle
[HttpGet("buscar/{termino}")] // GET /api/v1/productos/buscar/laptop
[HttpGet("~/admin/productos")] // ~ anula la ruta del controlador
// Constraints de ruta
// :int, :long, :bool, :datetime, :guid, :minlength(3), :maxlength(50), :range(1,100)
[HttpGet("{id:int:min(1)}")] // solo enteros >= 1
[HttpGet("{slug:minlength(3)}")] // solo strings con al menos 3 charsModel Binding
Cómo ASP.NET extrae datos del request:
[HttpGet("{id:int}")]
public IActionResult Get(
int id, // [FromRoute] inferido
[FromQuery] string? filtro, // ?filtro=valor
[FromHeader(Name="X-API-Key")] string? apiKey, // header
[FromServices] ILogger<ProductosController> log // DI directa
)
[HttpPost]
public IActionResult Post(
[FromBody] CrearProductoDto dto // JSON body
)
// Form data (para uploads)
[HttpPost("upload")]
public IActionResult Upload(
IFormFile archivo, // archivo multipart
[FromForm] string descripcion // campo de formulario
)DataAnnotations para validación
public class PedidoRequest
{
[Required]
[StringLength(100)]
public string NombreCliente { get; set; } = string.Empty;
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Phone]
public string? Telefono { get; set; }
[MinLength(1, ErrorMessage = "El pedido debe tener al menos un item")]
public List<ItemPedidoDto> Items { get; set; } = new();
[Range(typeof(DateTime), "2020-01-01", "2030-12-31")]
public DateTime FechaEntrega { get; set; }
[CreditCard]
public string? NumeroTarjeta { get; set; }
[Url]
public string? SitioWeb { get; set; }
[Compare(nameof(Email))]
public string? ConfirmarEmail { get; set; }
}Validación personalizada
// Atributo de validación personalizado
public class FechaFuturaAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
if (value is DateTime fecha && fecha <= DateTime.Today)
return new ValidationResult("La fecha debe ser futura");
return ValidationResult.Success;
}
}
public class CambioEstadoRequest
{
[Required]
[FechaFutura]
public DateTime FechaEfectiva { get; set; }
[Required]
public string NuevoEstado { get; set; } = string.Empty;
}ActionResult\
El tipo de retorno recomendado para acciones que devuelven datos:
// ActionResult<T> permite devolver el tipo T o un ActionResult
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductoDto>> ObtenerPorId(int id)
{
var producto = await _service.ObtenerAsync(id);
if (producto is null)
return NotFound(new ProblemDetails
{
Title = "Producto no encontrado",
Detail = $"No existe un producto con ID {id}",
Status = 404,
Instance = $"/api/v1/productos/{id}"
});
return Ok(producto); // ActionResult envuelve el DTO
}
// Helpers de ControllerBase disponibles
// Ok(obj) → 200 OK con body
// Created(uri, obj) → 201 Created con Location header
// CreatedAtAction(...)→ 201 Created con URL generada
// BadRequest(obj) → 400 Bad Request
// Unauthorized() → 401 Unauthorized
// Forbid() → 403 Forbidden
// NotFound() → 404 Not Found
// NoContent() → 204 No Content
// Conflict(obj) → 409 ConflictFiltros de acción
Los filtros ejecutan código antes/después de las acciones:
// Filtro personalizado
public class LogActionFilter : IActionFilter
{
private readonly ILogger<LogActionFilter> _logger;
private Stopwatch? _sw;
public LogActionFilter(ILogger<LogActionFilter> logger) => _logger = logger;
public void OnActionExecuting(ActionExecutingContext context)
{
_sw = Stopwatch.StartNew();
_logger.LogInformation(
"Ejecutando {Action}", context.ActionDescriptor.DisplayName);
}
public void OnActionExecuted(ActionExecutedContext context)
{
_sw?.Stop();
_logger.LogInformation(
"Completado en {Ms}ms", _sw?.ElapsedMilliseconds);
}
}
// Registrar globalmente
builder.Services.AddControllers(opts =>
{
opts.Filters.Add<LogActionFilter>();
});Práctica
- CRUD completo: Crea un
ClientesControllercon todos los endpoints REST paraCliente(int Id, string Nombre, string Email). Incluye validación con DataAnnotations. - Paginación: Implementa un endpoint
GET /api/clientes?pagina=1&tamaño=5que devuelvaPagedResult<ClienteDto>. - Validación personalizada: Crea un atributo
[SoloLetras]que valide que un string solo contenga letras y úsalo en el campoNombredeCrearClienteRequest.
En la siguiente lección aprenderemos Entity Framework Core para persistir los datos de nuestra API en una base de datos relacional.
CreatedAtAction para respuestas 201 correctas
Usa CreatedAtAction(nameof(ObtenerPorId), new { id = producto.Id }, producto) en lugar de Created() directamente. Esto genera automáticamente el header Location apuntando a la URL del nuevo recurso, cumpliendo con el estándar REST para respuestas 201 Created.
ActionResult<T> vs IActionResult
Usa ActionResult<T> cuando el endpoint siempre devuelve el mismo tipo de dato en caso exitoso. Esto permite a Swagger generar documentación más precisa. Usa IActionResult cuando puedas devolver tipos distintos según el caso (ej: Ok(dto) o File(stream)).
[ApiController] y validación automática
El atributo [ApiController] activa la validación del ModelState automáticamente: si el request body no pasa las DataAnnotations, ASP.NET devuelve un 400 Bad Request con los errores antes de ejecutar tu método. No necesitas if (!ModelState.IsValid) en cada action.
using Microsoft.AspNetCore.Mvc;
// [ApiController] activa: validación automática, inferencia de fuentes, respuestas 400
[ApiController]
[Route("api/v1/[controller]")] // → /api/v1/productos
public class ProductosController : ControllerBase
{
private readonly IProductoService _service;
private readonly ILogger<ProductosController> _logger;
// Inyección por constructor
public ProductosController(IProductoService service,
ILogger<ProductosController> logger)
{
_service = service;
_logger = logger;
}
// GET /api/v1/productos?pagina=1&tamaño=10&categoria=electrónica
[HttpGet]
[ProducesResponseType<PagedResult<ProductoDto>>(StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<ProductoDto>>> ObtenerTodos(
[FromQuery] int pagina = 1,
[FromQuery] int tamaño = 10,
[FromQuery] string? categoria = null)
{
_logger.LogInformation("Consultando productos. Página {Pagina}", pagina);
var resultado = await _service.ListarAsync(pagina, tamaño, categoria);
return Ok(resultado);
}
// GET /api/v1/productos/5
[HttpGet("{id:int}")]
[ProducesResponseType<ProductoDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductoDto>> ObtenerPorId(int id)
{
var producto = await _service.ObtenerPorIdAsync(id);
return producto is null ? NotFound() : Ok(producto);
}
// POST /api/v1/productos
[HttpPost]
[ProducesResponseType<ProductoDto>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ProductoDto>> Crear(
[FromBody] CrearProductoRequest request)
{
var producto = await _service.CrearAsync(request);
return CreatedAtAction(nameof(ObtenerPorId),
new { id = producto.Id }, producto);
}
// PUT /api/v1/productos/5
[HttpPut("{id:int}")]
public async Task<ActionResult<ProductoDto>> Actualizar(
int id, [FromBody] ActualizarProductoRequest request)
{
var actualizado = await _service.ActualizarAsync(id, request);
return actualizado is null ? NotFound() : Ok(actualizado);
}
// DELETE /api/v1/productos/5
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Eliminar(int id)
{
bool eliminado = await _service.EliminarAsync(id);
return eliminado ? NoContent() : NotFound();
}
}
Inicia sesión para guardar tu progreso