Ir al contenido
wannacode

Un servidor web con Minimal API

En resumen: Un servidor web es un programa que responde a peticiones HTTP; en C# se escribe con ASP.NET Core, y Minimal API describe las rutas con MapGet y MapPost en unas pocas líneas.

Problema

El catálogo tiene que verse desde cualquier lugar

El catálogo de la tienda ya es una lista de productos en tu programa, y puedes filtrarlo con LINQ. Pero está en la memoria de tu computadora. Un cliente abre la tienda en el navegador, otro la abre en la aplicación de su teléfono. Los dos programas hablan HTTP: envían una petición como GET /products/3 y esperan una respuesta con un estado y un cuerpo.

Necesitas un programa que escuche en un puerto, reciba peticiones HTTP y las responda. Eso es un servidor web.

Míralo

Servidor y cliente en un solo programa

En C#, el servidor web es ASP.NET Core. La forma más sencilla de usarlo es Minimal API: el servidor y sus direcciones se describen en unas pocas líneas. Este programa arranca un servidor y luego le pregunta algo como cliente:

Program.cs
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
var app = builder.Build();
app.Urls.Add("http://localhost:8201");
app.MapGet("/", () => "Café y té: abierto");
await app.StartAsync();
using var http = new HttpClient();
string text = await http.GetStringAsync("http://localhost:8201/");
Console.WriteLine(text);
await app.StopAsync();
Salida
Café y té: abierto
  • WebApplication.CreateBuilder() y Build() crean la aplicación. app.Urls.Add dice en qué dirección y puerto escuchar.
  • MapGet("/", lambda) dice: a una petición GET a la ruta de acceso /, responde con lo que devuelva la lambda. El texto va en el cuerpo de la respuesta.
  • await app.StartAsync() arranca el servidor y no espera: sigue con la línea siguiente. await app.StopAsync() lo detiene al final.
  • HttpClient es el cliente. GetStringAsync(dirección) envía una petición GET y te da el cuerpo de la respuesta como cadena.

Aquí hay var porque los tipos son largos (WebApplicationBuilder, WebApplication). using var http significa que el cliente se cierra solo cuando termina el programa.

Más precisamente, para curiosos
  • builder.Logging.ClearProviders() quita los mensajes que ASP.NET escribe en la consola, como «Now listening on: http://localhost:8201». En un servidor de verdad son útiles. Aquí los quitamos para que en la salida solo esté nuestro texto.
  • Un servidor de verdad termina con app.Run(): escucha hasta que lo detienes con Ctrl+C. Entonces el cliente es otro programa: el navegador (abre http://localhost:8201/) o la aplicación del teléfono. Aquí el servidor y el cliente están en un solo programa, para que el código se pueda comprobar sin una segunda computadora.
  • Un proyecto se crea con dotnet new web. El programa está en Program.cs, y los using para la web vienen incluidos por defecto.
  • Un solo HttpClient sirve para muchas peticiones. No crees uno nuevo para cada una: mantiene abiertas las conexiones con el servidor y las reutiliza.

Míralo

Rutas: qué dirección devuelve qué

El servidor compara la ruta de acceso de cada petición con sus rutas y llama a la lambda de la que coincide. Una parte de la ruta de acceso puede ser un parámetro:

app.MapGet("/products/{id:int}", (int id) =>
{
Product product = catalog.FirstOrDefault(p => p.Id == id);
return product == null ? "No existe ese producto" : product.Name + ": " + product.Price + " €";
});
app.MapGet("/search", (string q) =>
string.Join(", ", catalog.Where(p => p.Name.Contains(q)).Select(p => p.Name)));
using var http = new HttpClient();
Console.WriteLine(await http.GetStringAsync("http://localhost:8202/products/3"));
Console.WriteLine(await http.GetStringAsync("http://localhost:8202/products/9"));
Console.WriteLine(await http.GetStringAsync("http://localhost:8202/search?q=grano"));
Salida
Colombia en grano: 31.50 €
No existe ese producto
Espresso en grano, Colombia en grano
  • {id:int} en la ruta es un parámetro en la ruta de acceso: /products/3 coincide, e id es 3. :int es una restricción: solo coinciden números.
  • La lambda tiene un parámetro con el mismo nombre, int id. ASP.NET toma el valor de la ruta de acceso y lo convierte en int por sí solo.
  • (string q) no tiene lugar en la ruta de acceso, así que se toma de la consulta después de ?: /search?q=grano da q = "grano".
  • catalog es la lista de productos de la lección sobre LINQ. Las lambdas la ven porque están en el mismo código, después de ella.
Más precisamente, para curiosos
  • El archivo completo. La clase Product va después del código, como en la lección sobre LINQ:
Program.cs
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
var app = builder.Build();
app.Urls.Add("http://localhost:8202");
List<Product> catalog = new List<Product>
{
new Product { Id = 1, Name = "Espresso en grano", Price = 24.90m, Category = "café" },
new Product { Id = 2, Name = "Té verde sencha", Price = 9.80m, Category = "té" },
new Product { Id = 3, Name = "Colombia en grano", Price = 31.50m, Category = "café" },
new Product { Id = 4, Name = "Menta", Price = 4.20m, Category = "té" },
};
app.MapGet("/products/{id:int}", (int id) =>
{
Product product = catalog.FirstOrDefault(p => p.Id == id);
return product == null ? "No existe ese producto" : product.Name + ": " + product.Price + " €";
});
app.MapGet("/search", (string q) =>
string.Join(", ", catalog.Where(p => p.Name.Contains(q)).Select(p => p.Name)));
await app.StartAsync();
using var http = new HttpClient();
Console.WriteLine(await http.GetStringAsync("http://localhost:8202/products/3"));
Console.WriteLine(await http.GetStringAsync("http://localhost:8202/products/9"));
Console.WriteLine(await http.GetStringAsync("http://localhost:8202/search?q=grano"));
await app.StopAsync();
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
}
  • Si la dirección no tiene ?q=, aquí q es null, Contains(null) lanza una excepción y el servidor responde con 500. En un proyecto creado con dotnet new web está activada la comprobación de null (<Nullable>enable</Nullable>), y entonces ASP.NET responde con 400 por sí solo si falta un parámetro obligatorio.
  • Otras restricciones: {price:decimal}, {code:alpha} (solo letras), {id:int:min(1)}. Un parámetro con ? es opcional: /products/{page:int?}.

Míralo

Petición y respuesta

app.MapGet("/products/{id:int}", (int id) =>
{
Product product = catalog.FirstOrDefault(p => p.Id == id);
return product == null ? Results.NotFound() : Results.Ok(product);
});
await app.StartAsync();
using var http = new HttpClient();
HttpResponseMessage response = await http.GetAsync("http://localhost:8219/products/2");
Console.WriteLine((int)response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());
Paso a paso, en palabras
  1. El servidor registra una ruta: la dirección /products/<número> lleva a este método.
  2. StartAsync: el servidor empieza a escuchar en el puerto 8219.
  3. El cliente envía una petición: método GET, ruta de acceso /products/2.
  4. La ruta de acceso coincide con la ruta. 2 es un número, así que id = 2.
  5. El producto está en el catálogo, así que el método devuelve Results.Ok con él. El servidor lo convierte en JSON.
  6. Vuelve la respuesta: estado 200 y un cuerpo con JSON.
  7. El cliente imprime el estado y el cuerpo.

Una lambda puede devolver no solo un valor, sino una respuesta entera. Results.Ok(objeto) es el estado 200 con el objeto en el cuerpo, y Results.NotFound() es 404 sin cuerpo. El servidor convierte el producto en JSON, un texto que describe el objeto, por sí solo.

Del lado del cliente, GetAsync te da la respuesta entera como HttpResponseMessage. StatusCode es el estado ((int) lo muestra como número), y Content.ReadAsStringAsync() lee el cuerpo. GetStringAsync de las tarjetas anteriores lanza una excepción con cualquier estado distinto de 2xx. GetAsync no lanza nada, así que con él también ves el 404.

Lo mismo para el catálogo, con un producto que existe y otro que no:

app.MapGet("/products/{id:int}", (int id) =>
{
Product product = catalog.FirstOrDefault(p => p.Id == id);
return product == null ? Results.NotFound() : Results.Ok(product);
});
using var http = new HttpClient();
foreach (string path in new string[] { "/products/2", "/products/9" })
{
HttpResponseMessage response = await http.GetAsync("http://localhost:8203" + path);
Console.WriteLine(path + " -> " + (int)response.StatusCode + " " + response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());
}
Salida
/products/2 -> 200 OK
{"id":2,"name":"Té verde sencha","price":9.80,"category":"té"}
/products/9 -> 404 NotFound

Pruébalo

Texto en lugar de un número

La ruta es la misma, /products/{id:int}. El cliente pide un producto por su nombre:

¿Qué imprimirá?

HttpResponseMessage response = await http.GetAsync("http://localhost:8204/products/espresso");
Console.WriteLine((int)response.StatusCode);
Respuesta

Míralo

JSON: objetos por la red

Por la red viaja texto, no objetos. JSON (JavaScript Object Notation) es la forma más común de escribir un objeto como texto: {"id":2,"name":"Té verde sencha","price":9.80,"category":"té"}. Las propiedades son pares "nombre":valor entre llaves, y una lista va entre corchetes: [{…},{…}]. Lo entienden todos los lenguajes y los navegadores.

Si la lambda devuelve una lista, el servidor la convierte en un array de JSON. En el cliente, GetFromJsonAsync<List<Product>> lee el JSON y lo vuelve a convertir en una lista de productos:

app.MapGet("/products", () => catalog);
using var http = new HttpClient();
string json = await http.GetStringAsync("http://localhost:8205/products");
Console.WriteLine(json.Substring(0, 70) + "...");
List<Product> products = await http.GetFromJsonAsync<List<Product>>("http://localhost:8205/products");
foreach (Product p in products.Where(p => p.Category == "té"))
{
Console.WriteLine(p.Name + ": " + p.Price + " €");
}
Salida
[{"id":1,"name":"Espresso en grano","price":24.90,"category":"café"},{...
Té verde sencha: 9.80 €
Menta: 4.20 €
  • En el JSON los nombres empiezan con minúscula (name), y en C# con mayúscula (Name). El servidor y HttpClient se entienden en esto por sí solos.
  • decimal es un número en JSON, con las cifras después del punto: 24.90.

Los programas de esta lección que imprimen JSON con tildes tienen dos using al principio:

using System.Text.Encodings.Web;
using System.Text.Unicode;

y una línea más después de ClearProviders(). El porqué está en «Más precisamente»:

builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Encoder = JavaScriptEncoder.Create(UnicodeRanges.All));
Más precisamente, para curiosos
  • Las tildes en JSON. La biblioteca de JSON de .NET (System.Text.Json) escribe por defecto todo lo que está fuera del latín básico como \uXXXX, así que é se convierte en é. Sigue siendo JSON válido y cualquier programa lo lee bien, pero una persona no puede leerlo. Aquí está JsonSerializer.Serialize sin el servidor web. Los nombres de las propiedades se quedan aquí con mayúscula, porque la minúscula es un ajuste de la web:
using System.Text.Json;
Product sencha = new Product { Id = 2, Name = "Té verde sencha", Price = 9.80m, Category = "té" };
Console.WriteLine(JsonSerializer.Serialize(sencha));
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
}
Salida
{"Id":2,"Name":"T\u00E9 verde sencha","Price":9.80,"Category":"t\u00E9"}
  • El servidor web de .NET 10 codifica menos por defecto: las letras con tilde salen tal cual, pero tampoco se codifican los caracteres <, >, & y '. Es cómodo, pero es peligroso si el JSON termina dentro de una página HTML. Por eso el nombre de ese ajuste es UnsafeRelaxedJsonEscaping. La línea con ConfigureHttpJsonOptions dice de forma explícita qué pasa: todas las letras de Unicode (UnicodeRanges.All) se quedan tal cual, y los caracteres peligrosos para HTML se codifican (< se convierte en <). Así la salida es legible y no depende del ajuste por defecto.
  • La conversión inversa, de JSON a objeto, no tiene en cuenta estos ajustes: é y é se leen igual.

Míralo

POST: añadir un producto

GET lee. Para añadir un producto, el cliente envía un POST con el producto como JSON en el cuerpo. En el servidor, eso es MapPost:

app.MapPost("/products", (Product product) =>
{
product.Id = catalog.Max(p => p.Id) + 1;
catalog.Add(product);
return Results.Created($"/products/{product.Id}", product);
});
using var http = new HttpClient();
Product tea = new Product { Name = "Té negro assam", Price = 7.40m, Category = "té" };
HttpResponseMessage response = await http.PostAsJsonAsync("http://localhost:8207/products", tea);
Console.WriteLine((int)response.StatusCode + " " + response.Headers.Location);
Product created = await response.Content.ReadFromJsonAsync<Product>();
Console.WriteLine("Nuevo producto " + created.Id + ": " + created.Name);
List<Product> all = await http.GetFromJsonAsync<List<Product>>("http://localhost:8207/products");
Console.WriteLine("En el catálogo: " + all.Count);
Salida
201 /products/5
Nuevo producto 5: Té negro assam
En el catálogo: 5
  • La lambda recibe Product product. No está en la ruta de acceso, así que ASP.NET lo lee del JSON en el cuerpo de la petición.
  • Max(p => p.Id) de LINQ da el número más grande, así que el nuevo es uno más.
  • Results.Created(dirección, objeto) es el estado 201 Created: se creó algo. La cabecera Location da la dirección del nuevo recurso, y el cuerpo es el propio producto con su nuevo número.
  • En el cliente, PostAsJsonAsync convierte el objeto en JSON y envía un POST. ReadFromJsonAsync<Product> lee el cuerpo de la respuesta como un producto.
Más precisamente, para curiosos
  • El catálogo está en la memoria del servidor: al reiniciarlo, los productos nuevos desaparecen. Una tienda de verdad los guarda en una base de datos.
  • Un servidor de verdad atiende muchas peticiones a la vez, y List no está hecha para cambios de varias peticiones al mismo tiempo. Es una razón más para que los datos estén en una base de datos, que se encarga de eso.
  • También existen MapPut (reemplazar) y MapDelete (borrar). Están en la lección sobre WebAPI.

Pruébalo

500 en lugar de 404

El cliente pide /products/9, y ese producto no existe. Espera 404, pero recibe 500. ¿En qué línea está el error?

Elige la línea con el error.

Ejercicio

El servidor de la tienda

Resuelve estas tareas en tu editor. Cada tarea es un programa: un servidor, un cliente que lo llama, y la detención. Si ejecutas varios a la vez, dale a cada uno su propio puerto.

1. Qué hora es. GET /time devuelve la hora como texto, por ejemplo 14:05 (DateTime.Now.ToString("HH:mm")). La hora cambia, así que el cliente solo comprueba su forma: longitud 5 y : en la posición 2:

Salida
La hora tiene la forma HH:mm: True

2. Un catálogo con búsqueda. GET /products?category=té devuelve los productos de la categoría, ordenados por precio, y GET /search?q=… busca en los nombres sin importar mayúsculas y minúsculas (ToLower). El cliente lee los dos primeros con GetFromJsonAsync e imprime el tercero como JSON:

Salida
Té: Menta 4.20 €, Té verde sencha 9.80 €
Encontrados: 2
[{"id":4,"name":"Menta","price":4.20,"category":"té"}]

3. Un producto que no existe. GET /products/{id:int} devuelve el producto, o 404 con un mensaje en el cuerpo: Results.NotFound("No hay ningún producto con el número 42"). El cliente pide el 1 y el 42 e imprime el estado y el cuerpo:

Salida
1: 200 {"id":1,"name":"Espresso en grano","price":24.90,"category":"café"}
42: 404 "No hay ningún producto con el número 42"

4. Añadir un producto. POST /products añade el producto y devuelve 201 con su dirección. Si el nombre está vacío (string.IsNullOrWhiteSpace) o el precio no es positivo, devuelve Results.BadRequest("El nombre y el precio son obligatorios") y no añade nada. El cliente añade rooibos por 6,90 €, lo lee por la dirección de Location e intenta añadir un producto sin nombre:

Salida
POST: 201 /products/5
GET: 5 Rooibos 6.90 €
POST sin nombre: 400 "El nombre y el precio son obligatorios"

Test

Ponte a prueba

1. ¿Qué hace app.MapGet("/products/{id:int}", (int id) => ...)?
2. La lambda devuelve Results.NotFound(). ¿Qué recibe el cliente?
3. La lambda devuelve un objeto Product. ¿Cómo llega al cliente?
4. ¿Por qué los programas de esta lección tienen await app.StartAsync() y await app.StopAsync()?
5. La lambda busca el producto con catalog.First(p => p.Id == id), y ese producto no existe. ¿Qué recibe el cliente?

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