Ir al contenido
wannacode

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/products son todos los productos, /api/products/2 es el producto 2. En la dirección no hay verbos como getProduct o delete.
  • Lo que haces con el recurso lo dice el método HTTP: GET lee, POST crea, PUT reemplaza, DELETE borra.
  • 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
  1. El recurso es /api/products: 4 productos en el servidor. El cliente solo conoce la dirección.
  2. GET lee el producto 3 (Sencha). Existe: 200 OK y el producto en JSON.
  3. POST a la colección crea un producto nuevo. El servidor le da el id 5: 201 Created.
  4. PUT reemplaza el producto 2: el precio pasa a 11.90 €. La respuesta no tiene cuerpo: 204 No Content.
  5. DELETE borra el producto 4: 204 No Content.
  6. GET del producto 4: ya no existe, así que 404 Not Found.
  7. 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).
  • GetAll devuelve una lista, y esta se convierte en JSON. Get devuelve un ActionResult<Product>: o un producto (200 con JSON) u otro resultado, como NotFound(). return product; está permitido porque C# convierte por sí solo el producto en un ActionResult<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);
Salida
[{"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 -> 404

GetFromJsonAsync<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));
}
Salida
201, Location: http://localhost:8263/api/Products/5
El producto nuevo tiene id 5
400
Name: 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ú mismo if (!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 solo errors.
  • NotFound() en un controlador de API también devuelve un poco de JSON (un ProblemDetails con 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);
Salida
PUT /api/products/2 -> 204
11.90 €
DELETE /api/products/4 -> 204
DELETE /api/products/4 -> 404
PUT /api/products/4 -> 404

PUT 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, PUT y DELETE son idempotentes: repetirlos es seguro, y el resultado en el servidor es el mismo. POST no 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);
Respuesta

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.

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:

Salida (12 peticiones, una tras otra)
201 201 201 201 201 201 201 201 201 201 409 409

Pero 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];
}
}
}
  • sync es un objeto común que sirve solo como llave. new() es la forma corta de new 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));
Salida
201 Created: 10
409 Conflict: 90
Quedan: 0

Con 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 lock no puede haber un await, y el compilador no lo permite. lock guarda la llave para el hilo que entró, y después de un await el resto del código puede seguir en otro hilo. Si necesitas esperar algo asíncrono mientras proteges los datos, se usa SemaphoreSlim con await semaphore.WaitAsync().
  • El bloque de un lock debe 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 como TryUpdate y AddOrUpdate hacen la comprobación y el cambio de una vez, sin un lock tuyo.
  • 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);
}
Salida
Añadido con id 5
Colombia Supremo – 12.50 €
Guatemala Antigua – 13.20 €
Etiopía Yirgacheffe – 14.90 €
Rechazado: 400
Má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 (AddOpenApi y MapOpenApi), y la página para probar llega de un paquete aparte, como Scalar.AspNetCore o Swashbuckle.AspNetCore.
  • CORS: por defecto, el navegador no deja que JavaScript de shop.example.bg lea respuestas de api.example.bg. El servidor permite de forma explícita qué sitios pueden, con builder.Services.AddCors(...) y app.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/xml y el servidor tiene AddXmlSerializerFormatters(). Elegir el formato según la cabecera se llama negociación de contenido (content negotiation).
  • Un mismo HttpClient se 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:

Salida
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 -> 400
PUT 1 -> 204
GET 1 -> 200 {"id":1,"title":"Pedir filtros para la cafetera","done":true}
DELETE 2 -> 204
GET 2 -> 404
GET -> 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

1. ¿Qué dirección y qué método siguen REST para «borrar el producto 7»?
2. ¿Qué estado devuelve un POST correcto que crea un producto?
3. Product tiene [Required] en Name. ¿Qué pasa con un POST sin nombre a un controlador con [ApiController]?
4. Cien peticiones reservan a la vez las últimas 10 unidades. ¿Por qué sin lock se pueden vender más?
5. ¿Qué devuelve DELETE cuando el borrado salió bien?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·