WebAPI: datos para otros programas
En resumen: Una WebAPI es un servidor que devuelve datos en JSON en lugar de páginas; en REST la dirección nombra un recurso, y GET, POST, PUT y DELETE dicen qué se hace con él.
Problema
Una aplicación, no un navegador
La tienda lanza una aplicación para el teléfono, y una cafetería asociada quiere hacer pedidos del catálogo de forma automática. Las páginas de las lecciones anteriores son HTML para personas: menús, tablas, botones. El programa de la cafetería tiene que buscar el precio dentro de ese HTML. En cuanto el diseñador mueve una columna, el programa deja de funcionar.
Los programas quieren solo los datos, con una forma predecible. Por eso el servidor tiene una segunda puerta: la WebAPI, direcciones que devuelven JSON en lugar de páginas. Una sola API sirve a la aplicación del teléfono, al sitio web, al programa de escritorio de la caja y a los socios.
La idea
Recursos, métodos y estados
Hoy las API suelen seguir REST. Las reglas son pocas:
- La dirección nombra un recurso, una cosa:
/api/productsson todos los productos,/api/products/2es el producto 2. En la dirección no hay verbos comogetProductodelete. - Lo que haces con el recurso lo dice el método HTTP:
GETlee,POSTcrea,PUTreemplaza,DELETEborra. - Cómo salió lo dice el estado:
| Estado | Cuándo |
|---|---|
| 200 OK | GET encontró el recurso; el cuerpo es JSON |
| 201 Created | POST creó un recurso nuevo; Location es su dirección |
| 204 No Content | PUT o DELETE salieron bien; no hay cuerpo |
| 400 Bad Request | los datos no son válidos; el cuerpo dice por qué |
| 404 Not Found | ese recurso no existe |
| 409 Conflict | la petición es correcta, pero no se puede cumplir ahora (por ejemplo, no hay existencias) |
Una API que sigue estas reglas se llama API REST. Un cliente puede usarla sin leer el código del servidor: le bastan la dirección y el método.
Cinco peticiones al mismo recurso y los estados que devuelven. Cómo se escriben el controlador y el cliente está en las tarjetas siguientes:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:8292") };var matcha = new Product { Name = "Matcha", Price = 18.00m, Category = "té" };var cheaper = new Product { Name = "Colombia Supremo", Price = 11.90m, Category = "café" };
Show(await http.GetAsync("/api/products/3"));Show(await http.PostAsJsonAsync("/api/products", matcha));Show(await http.PutAsJsonAsync("/api/products/2", cheaper));Show(await http.DeleteAsync("/api/products/4"));Show(await http.GetAsync("/api/products/4"));
static void Show(HttpResponseMessage r) => Console.WriteLine(r.RequestMessage.Method + " " + r.RequestMessage.RequestUri.AbsolutePath + " -> " + (int)r.StatusCode);Paso a paso, en palabras
- El recurso es /api/products: 4 productos en el servidor. El cliente solo conoce la dirección.
- GET lee el producto 3 (Sencha). Existe: 200 OK y el producto en JSON.
- POST a la colección crea un producto nuevo. El servidor le da el id 5: 201 Created.
- PUT reemplaza el producto 2: el precio pasa a 11.90 €. La respuesta no tiene cuerpo: 204 No Content.
- DELETE borra el producto 4: 204 No Content.
- GET del producto 4: ya no existe, así que 404 Not Found.
- La dirección dice qué recurso, el método qué haces con él y el estado cómo salió.
Míralo
Un controlador para datos
Un controlador de API hereda de ControllerBase: es Controller sin las vistas. Los dos atributos de arriba lo convierten en una API:
[ApiController][Route("api/[controller]")]public class ProductsController : ControllerBase{ private readonly IProductService products;
public ProductsController(IProductService products) => this.products = products;
[HttpGet] public List<Product> GetAll() => products.All();
[HttpGet("{id:int}")] public ActionResult<Product> Get(int id) { Product product = products.Find(id); if (product == null) return NotFound(); return product; }}[Route("api/[controller]")]pone el nombre del controlador en la dirección:api/Products. Las mayúsculas y minúsculas en la dirección no importan.[ApiController]activa el comportamiento de API: los parámetros llegan del cuerpo JSON, y los datos no válidos devuelven 400 por sí solos (enseguida lo verás).GetAlldevuelve una lista, y esta se convierte en JSON.Getdevuelve unActionResult<Product>: o un producto (200 con JSON) u otro resultado, comoNotFound().return product;está permitido porque C# convierte por sí solo el producto en unActionResult<Product>.
En Program.cs, los controladores se añaden con AddControllers (sin vistas) y se conectan con MapControllers (las rutas están en los atributos). AddJsonOptions es el ajuste para un JSON legible de la lección sobre MVC:
var builder = WebApplication.CreateBuilder();builder.Logging.ClearProviders();builder.Services.AddControllers() .AddJsonOptions(o => o.JsonSerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));builder.Services.AddSingleton<IProductService, ProductService>();
var app = builder.Build();app.Urls.Add("http://localhost:8261");app.MapControllers();await app.StartAsync();El cliente fija el comienzo común de las direcciones con BaseAddress y después escribe solo la ruta:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:8261") };
Console.WriteLine(await http.GetStringAsync("/api/products"));
Product tea = await http.GetFromJsonAsync<Product>("/api/products/3");Console.WriteLine(tea.Name + ", " + tea.Price + " €");
HttpResponseMessage missing = await http.GetAsync("/api/products/99");Console.WriteLine("GET /api/products/99 -> " + (int)missing.StatusCode);[{"id":1,"name":"Etiopía Yirgacheffe","price":14.90,"category":"café"},{"id":2,"name":"Colombia Supremo","price":12.50,"category":"café"},{"id":3,"name":"Sencha","price":8.40,"category":"té"},{"id":4,"name":"Earl Grey","price":6.90,"category":"té"}]Sencha, 8.40 €GET /api/products/99 -> 404GetFromJsonAsync<Product> lee JSON y crea con él un objeto Product. Las propiedades en el JSON empiezan con minúscula (name), y en C# con mayúscula (Name). La lectura no distingue mayúsculas de minúsculas.
Míralo
POST: un producto nuevo
Product tiene las mismas propiedades, y los atributos dicen qué es válido:
public class Product{ public int Id { get; set; }
[Required(ErrorMessage = "El nombre es obligatorio.")] public string Name { get; set; }
[Range(0.01, 1000, ErrorMessage = "El precio va de 0.01 a 1000 €.")] public decimal Price { get; set; }
public string Category { get; set; }}La acción que lo crea:
[HttpPost] public ActionResult<Product> Create(Product product) { products.Add(product); return CreatedAtAction(nameof(Get), new { id = product.Id }, product); }CreatedAtAction devuelve 201, pone en Location la dirección del producto nuevo (armada a partir de la acción Get y el id) y envía el producto en el cuerpo. nameof(Get) es el nombre del método como texto, "Get", pero el compilador lo comprueba. El cliente envía un producto correcto y uno vacío:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:8263") };
var matcha = new Product { Name = "Matcha", Price = 18.00m, Category = "té" };HttpResponseMessage created = await http.PostAsJsonAsync("/api/products", matcha);Product saved = await created.Content.ReadFromJsonAsync<Product>();Console.WriteLine((int)created.StatusCode + ", Location: " + created.Headers.Location);Console.WriteLine("El producto nuevo tiene id " + saved.Id);
HttpResponseMessage rejected = await http.PostAsJsonAsync("/api/products", new Product { Price = 0 });Console.WriteLine((int)rejected.StatusCode);ValidationProblemDetails problem = await rejected.Content.ReadFromJsonAsync<ValidationProblemDetails>();foreach (KeyValuePair<string, string[]> error in problem.Errors.OrderBy(e => e.Key)){ Console.WriteLine(error.Key + ": " + string.Join(" ", error.Value));}201, Location: http://localhost:8263/api/Products/5El producto nuevo tiene id 5400Name: El nombre es obligatorio.Price: El precio va de 0.01 a 1000 €.El segundo producto no tiene nombre y su precio es 0. Create ni siquiera se llama: [ApiController] revisa los atributos y devuelve 400 por sí solo. El cuerpo es un ValidationProblemDetails: un JSON estándar con un campo errors que tiene una lista de errores para cada propiedad.
Más precisamente, para curiosos
- Sin
[ApiController], tienes que escribir tú mismoif (!ModelState.IsValid) return ValidationProblem();al principio de cada acción, y[FromBody]delante del parámetro, para que se lea del JSON. - El cuerpo del 400 también tiene un
traceId, el número de la petición. Con él encuentras el error en los registros del servidor. Es distinto en cada petición, por eso el programa imprime soloerrors. NotFound()en un controlador de API también devuelve un poco de JSON (unProblemDetailscon estado 404), no un cuerpo vacío.
Míralo
PUT y DELETE
[HttpPut("{id:int}")] public IActionResult Update(int id, Product product) { if (!products.Update(id, product)) return NotFound(); return NoContent(); }
[HttpDelete("{id:int}")] public IActionResult Delete(int id) { if (!products.Remove(id)) return NotFound(); return NoContent(); }El servicio es el de la lección sobre MVC, con tres métodos más: Add, Update y Remove. Update y Remove devuelven false si no hay un producto con ese id, y la acción lo convierte en un 404:
public interface IProductService{ Product Find(int id); List<Product> All(); void Add(Product product); bool Update(int id, Product product); bool Remove(int id);}var http = new HttpClient { BaseAddress = new Uri("http://localhost:8264") };
var cheaper = new Product { Name = "Colombia Supremo", Price = 11.90m, Category = "café" };HttpResponseMessage updated = await http.PutAsJsonAsync("/api/products/2", cheaper);Console.WriteLine("PUT /api/products/2 -> " + (int)updated.StatusCode);Console.WriteLine((await http.GetFromJsonAsync<Product>("/api/products/2")).Price + " €");
HttpResponseMessage deleted = await http.DeleteAsync("/api/products/4");Console.WriteLine("DELETE /api/products/4 -> " + (int)deleted.StatusCode);HttpResponseMessage again = await http.DeleteAsync("/api/products/4");Console.WriteLine("DELETE /api/products/4 -> " + (int)again.StatusCode);HttpResponseMessage nowhere = await http.PutAsJsonAsync("/api/products/4", cheaper);Console.WriteLine("PUT /api/products/4 -> " + (int)nowhere.StatusCode);PUT /api/products/2 -> 20411.90 €DELETE /api/products/4 -> 204DELETE /api/products/4 -> 404PUT /api/products/4 -> 404PUT envía el producto entero, y este reemplaza al anterior. El segundo borrado del producto 4 es 404: ya no existe. Lo mismo pasa con un PUT a él.
Más precisamente, para curiosos
- Para cambiar solo una parte de un recurso (por ejemplo, solo el precio) existe el método
PATCH. Es menos común, porque hay que ponerse de acuerdo en cómo es una «parte». GET,PUTyDELETEson idempotentes: repetirlos es seguro, y el resultado en el servidor es el mismo.POSTno lo es: dos POST crean dos productos. Por eso el navegador pregunta «¿Enviar el formulario de nuevo?» solo con un POST.
Pruébalo
Cuatro peticiones
El servidor es el de las tarjetas anteriores, con los cuatro productos. ¿Qué imprime el cliente?
var http = new HttpClient { BaseAddress = new Uri("http://localhost:8262") };
var sencha = new Product { Name = "Sencha", Price = 9.20m, Category = "té" };HttpResponseMessage a = await http.PutAsJsonAsync("/api/products/3", sencha);HttpResponseMessage b = await http.DeleteAsync("/api/products/3");HttpResponseMessage c = await http.GetAsync("/api/products/3");HttpResponseMessage d = await http.PostAsJsonAsync("/api/products", new Product { Name = "", Price = 5 });Console.WriteLine((int)a.StatusCode + " " + (int)b.StatusCode + " " + (int)c.StatusCode + " " + (int)d.StatusCode);El PUT al producto 3 sale bien: 204. El DELETE también: 204. Después, el GET no lo encuentra: 404. El último POST tiene el nombre vacío, y [Required] no acepta texto vacío, así que [ApiController] devuelve 400 antes de llegar a Create.
Pruébalo
El producto no aparece
GET /api/products/2 devuelve 405, y GET /api/products/id devuelve 404. El producto 2 aparece solo en la dirección rara /api/products/id?id=2. ¿En qué línea está el error?
Elige la línea con el error.
Sin llaves, "id" es texto literal en la dirección: la acción responde solo a /api/products/id. Entonces el parámetro int id no forma parte de la ruta, y [ApiController] lo busca en la consulta después de ?. Si no está, vale 0, y el producto no se encuentra. La dirección /api/products/2 coincide solo con PUT y DELETE, por eso un GET a ella es 405. La corrección es [HttpGet("{id:int}")].
Problema
Las últimas diez bolsas
El servidor no atiende las peticiones de una en una. Si cien personas pulsan «Comprar» en el mismo segundo, ASP.NET Core las procesa a la vez. Todas usan el mismo objeto: el servicio es un singleton.
El almacén tiene 10 bolsas del café en oferta. Una reserva comprueba y baja la cantidad:
public class Stock{ private readonly Dictionary<int, int> left = new Dictionary<int, int> { [1] = 10 };
public bool TryTake(int productId) { if (!left.TryGetValue(productId, out int count) || count == 0) return false; left[productId] = count - 1; return true; }
public int Left(int productId) => left[productId];}De una en una, las peticiones pasan bien, 201 diez veces y luego 409:
201 201 201 201 201 201 201 201 201 201 409 409Pero a la vez, dos peticiones pueden leer count = 1 en el mismo instante. Las dos deciden que queda una, las dos escriben 0 y las dos devuelven 201. Así vendes más de lo que tienes. Cuántas de más depende del azar: es distinto cada vez que lo ejecutas. Lo mismo pasa con la última habitación de un hotel y con el último asiento de una sala.
La idea
lock: de uno en uno en el punto peligroso
La comprobación y el cambio tienen que ser indivisibles: nadie puede meterse entre ellos. En C# esto lo hace lock, que viste en la lección «Hilos». Aquí los hilos son las peticiones:
public class Stock{ private readonly Dictionary<int, int> left = new Dictionary<int, int> { [1] = 10 }; private readonly object sync = new();
public bool TryTake(int productId) { lock (sync) { if (!left.TryGetValue(productId, out int count) || count == 0) return false; left[productId] = count - 1; return true; } }
public int Left(int productId) { lock (sync) { return left[productId]; } }}synces un objeto común que sirve solo como llave.new()es la forma corta denew object(): el tipo se toma del campo de la izquierda.lock (sync) { ... }deja entrar al bloque una sola petición a la vez. Las demás esperan en la puerta hasta que la primera sale. Así la comprobación y la bajada ocurren juntas.- La lectura también está en un
lock: si no, podrías leer el diccionario justo mientras otra petición lo cambia.
El controlador es normal: 201 si sale bien, 409 Conflict si no hay existencias:
public class Reservation{ public int ProductId { get; set; }}
[ApiController][Route("api/[controller]")]public class ReservationsController : ControllerBase{ private readonly Stock stock;
public ReservationsController(Stock stock) => this.stock = stock;
[HttpPost] public IActionResult Reserve(Reservation reservation) { if (!stock.TryTake(reservation.ProductId)) return Conflict("No hay existencias."); return StatusCode(201, reservation); }}La prueba: cien peticiones a la vez con Task.WhenAll contra 10 bolsas:
var http = new HttpClient { BaseAddress = new Uri("http://localhost:8266") };
var requests = new List<Task<HttpResponseMessage>>();for (int i = 0; i < 100; i++){ requests.Add(http.PostAsJsonAsync("/api/reservations", new Reservation { ProductId = 1 }));}HttpResponseMessage[] responses = await Task.WhenAll(requests);
Console.WriteLine("201 Created: " + responses.Count(r => (int)r.StatusCode == 201));Console.WriteLine("409 Conflict: " + responses.Count(r => (int)r.StatusCode == 409));Console.WriteLine("Quedan: " + app.Services.GetRequiredService<Stock>().Left(1));201 Created: 10409 Conflict: 90Quedan: 0Con lock, el resultado es el mismo cada vez que lo ejecutas. Lo mismo vale para cada servicio registrado con AddSingleton que guarda datos en memoria: el ProductService de las tarjetas anteriores también tiene que bloquear en Add, Update y Remove si muchos clientes trabajan con él.
Más precisamente, para curiosos
- Dentro de un
lockno puede haber unawait, y el compilador no lo permite.lockguarda la llave para el hilo que entró, y después de unawaitel resto del código puede seguir en otro hilo. Si necesitas esperar algo asíncrono mientras proteges los datos, se usaSemaphoreSlimconawait semaphore.WaitAsync(). - El bloque de un
lockdebe ser corto: todos los demás están esperando. No hagas dentro peticiones a otros servidores ni al disco. - Para un diccionario que se usa desde muchos sitios a la vez existe el ya hecho
ConcurrentDictionary<K, V>: métodos comoTryUpdateyAddOrUpdatehacen la comprobación y el cambio de una vez, sin unlocktuyo. - Para un contador basta
Interlocked:Interlocked.Increment(ref count)aumenta el número de forma indivisible.count++no es indivisible: es «leer, sumar, escribir». - En una aplicación real, las existencias están en una base de datos. Ahí el mismo problema lo resuelven las transacciones y una condición en la propia consulta: «baja solo si es mayor que 0».
Míralo
El programa del otro lado
La cafetería asociada no sabe nada de los controladores. Conoce las direcciones y la forma del JSON. Es práctico tener las peticiones en una clase:
public class CatalogClient{ private readonly HttpClient http;
public CatalogClient(string address) { http = new HttpClient { BaseAddress = new Uri(address) }; }
public async Task<List<Product>> AllAsync() => await http.GetFromJsonAsync<List<Product>>("/api/products");
public async Task<int> AddAsync(Product product) { HttpResponseMessage response = await http.PostAsJsonAsync("/api/products", product); response.EnsureSuccessStatusCode(); Product saved = await response.Content.ReadFromJsonAsync<Product>(); return saved.Id; }}EnsureSuccessStatusCode() lanza una HttpRequestException si el estado no es 2xx. Así un error del servidor no pasa desapercibido. La cafetería añade un producto, obtiene los cafés ordenados por precio e intenta enviar un producto no válido:
var catalog = new CatalogClient("http://localhost:8267");
int id = await catalog.AddAsync(new Product { Name = "Guatemala Antigua", Price = 13.20m, Category = "café" });Console.WriteLine("Añadido con id " + id);
List<Product> coffee = (await catalog.AllAsync()).Where(p => p.Category == "café").OrderBy(p => p.Price).ToList();foreach (Product p in coffee){ Console.WriteLine(p.Name + " – " + p.Price + " €");}
try{ await catalog.AddAsync(new Product { Name = "Sin precio" });}catch (HttpRequestException e){ Console.WriteLine("Rechazado: " + (int)e.StatusCode);}Añadido con id 5Colombia Supremo – 12.50 €Guatemala Antigua – 13.20 €Etiopía Yirgacheffe – 14.90 €Rechazado: 400Más precisamente, para curiosos
- Swagger / OpenAPI describe la API en un archivo JSON estándar: direcciones, métodos, forma de los cuerpos, estados. A partir de él se obtiene una página donde pruebas la API desde el navegador, y se pueden generar clientes en otros lenguajes. En .NET 10 la descripción viene incorporada (
AddOpenApiyMapOpenApi), y la página para probar llega de un paquete aparte, comoScalar.AspNetCoreoSwashbuckle.AspNetCore. - CORS: por defecto, el navegador no deja que JavaScript de
shop.example.bglea respuestas deapi.example.bg. El servidor permite de forma explícita qué sitios pueden, conbuilder.Services.AddCors(...)yapp.UseCors(...). Para programas fuera del navegador, como este, CORS no importa. - ASP.NET Core también puede responder con XML, si el cliente lo pide con la cabecera
Accept: application/xmly el servidor tieneAddXmlSerializerFormatters(). Elegir el formato según la cabecera se llama negociación de contenido (content negotiation). - Un mismo
HttpClientse usa para muchas peticiones. Uno nuevo para cada petición agota las conexiones de la computadora cuando hay mucha carga.
Ejercicio
Una API para tareas pendientes
Resuélvelo en tu editor, en un proyecto de dotnet new webapi --use-controllers o en un solo archivo como en la lección.
Una API para tareas pendientes (todo). TodoItem tiene Id, Title (obligatorio, con [Required]) y Done. TodosController en api/todos tiene las cinco acciones: todas, una por id (o 404), crear (201 con CreatedAtAction), reemplazar con PUT (204 o 404) y borrar (204 o 404). Las tareas están en un servicio singleton TodoList que bloquea con lock en cada lectura y escritura. Una tarea nueva recibe el número siguiente, aunque se hayan borrado otras más antiguas.
El cliente llama, en orden: dos tareas, una tarea sin título, marca la primera como hecha, la lee, borra la segunda, la busca y al final lee todas. Imprime el método, el estado y el cuerpo, si la petición salió bien y tiene cuerpo:
POST -> 201 {"id":1,"title":"Pedir filtros para la cafetera","done":false}POST -> 201 {"id":2,"title":"Llamar al proveedor de té","done":false}POST sin título -> 400PUT 1 -> 204GET 1 -> 200 {"id":1,"title":"Pedir filtros para la cafetera","done":true}DELETE 2 -> 204GET 2 -> 404GET -> 200 [{"id":1,"title":"Pedir filtros para la cafetera","done":true}]Después añade GET /api/todos?done=false, que devuelve solo las tareas sin hacer: un parámetro bool? done en GetAll y un Where.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Cómo funciona internet · Un servidor web con Minimal API · MVC: modelo, vista, controlador · Vistas con Razor
- Continúa en: Reservas de hotel
- Ejercicios sobre el tema: Web y escritorio