Un servidor web en C# con Minimal API en un solo archivo

Con .NET 10, una API web completa cabe en un archivo de treinta líneas y se arranca con dotnet run. Aquí construimos una pequeña API de tareas con ASP.NET Core y la probamos con peticiones de verdad, sin navegador.

El servidor completo

ASP.NET Core es el marco web de .NET, y viene con él: no se instala nada. Su forma más corta se llama Minimal API: cada ruta es una línea con MapGet o MapPost y una función. Esta es una pequeña API de tareas, en un solo archivo:

#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var tareas = new List<Tarea> { new(1, "Comprar café", false), new(2, "Escribir el informe", true) };

app.MapGet("/", () => "Hola desde C#");

app.MapGet("/tareas", () => tareas);

app.MapGet("/tareas/{id:int}", (int id) =>
    tareas.FirstOrDefault(t => t.Id == id) is { } t ? Results.Ok(t) : Results.NotFound());

app.MapPost("/tareas", (NuevaTarea nueva) =>
{
    if (string.IsNullOrWhiteSpace(nueva.Titulo)) return Results.BadRequest(new { error = "Falta el título" });
    var t = new Tarea(tareas.Max(x => x.Id) + 1, nueva.Titulo, false);
    tareas.Add(t);
    return Results.Created($"/tareas/{t.Id}", t);
});

app.Run();

record Tarea(int Id, string Titulo, bool Hecha);
record NuevaTarea(string Titulo);
dotnet run servidor.cs

La primera línea, #:sdk Microsoft.NET.Sdk.Web, le dice a dotnet run que es una aplicación web (en un proyecto se crea con dotnet new web). La segunda desactiva AOT, que .NET 10 activa en los archivos sueltos y que exigiría declarar los tipos de JSON de antemano (lo explica JSON en C#). Al arrancar, el servidor escucha en http://localhost:5000; abre esa dirección en el navegador y verás «Hola desde C#».

Una petición GET /tareas/2 llega al servidor, el enrutamiento busca el MapGet cuyo patrón encaja, la función recibe el id ya convertido a int y lo que devuelve se envía como JSON con código 200, o 404 si no existeQué pasa con una petición en una Minimal APIGET /tareas/2llega la peticiónenrutamientoelige el MapGettu funciónrecibe id = 2 (int)200 + JSONo 404 si no existe/tareas/{id:int}solo encaja si id es un número: /tareas/dos da 404(NuevaTarea nueva)en un POST, el cuerpo JSON se convierte en el objetoreturn tareas;lo que devuelves se escribe como JSON en la respuesta

El camino de una petición: el enrutamiento elige la función por el método y el patrón de la ruta, convierte los parámetros a su tipo y escribe como JSON lo que la función devuelve. Esquema: decodigo.com.

Probarlo desde el propio programa

Para comprobar la API sin navegador, este programa arranca el mismo servidor con StartAsync (que no se queda esperando, como Run), le hace peticiones con HttpClient y lo detiene. Es también la idea de las pruebas de integración de ASP.NET Core:

#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false
using System.Net.Http.Json;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();               // sin los mensajes de arranque
builder.WebHost.UseUrls("http://127.0.0.1:5199");
var app = builder.Build();

// ... las mismas rutas de arriba ...

await app.StartAsync();                          // arranca y sigue, en vez de quedarse esperando

using var cliente = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5199") };
async Task Ver(HttpResponseMessage r) =>
    Console.WriteLine($"{(int)r.StatusCode} {await r.Content.ReadAsStringAsync()}");

await Ver(await cliente.GetAsync("/"));
await Ver(await cliente.GetAsync("/tareas"));
await Ver(await cliente.GetAsync("/tareas/2"));
await Ver(await cliente.GetAsync("/tareas/9"));
await Ver(await cliente.GetAsync("/tareas/dos"));
await Ver(await cliente.PostAsJsonAsync("/tareas", new { titulo = "Llamar a Luis" }));
await Ver(await cliente.PostAsJsonAsync("/tareas", new { titulo = "" }));
await Ver(await cliente.GetAsync("/tareas"));

await app.StopAsync();

// ... los dos record de arriba ...
salida200 Hola desde C#
200 [{"id":1,"titulo":"Comprar café","hecha":false},{"id":2,"titulo":"Escribir el informe","hecha":true}]
200 {"id":2,"titulo":"Escribir el informe","hecha":true}
404 
404 
201 {"id":3,"titulo":"Llamar a Luis","hecha":false}
400 {"error":"Falta el título"}
200 [{"id":1,"titulo":"Comprar café","hecha":false},{"id":2,"titulo":"Escribir el informe","hecha":true},{"id":3,"titulo":"Llamar a Luis","hecha":false}]

Fíjate en lo que el marco hace solo. Una lista o un record se devuelven como JSON, con los nombres en camelCase. /tareas/9 da 404 porque la función devuelve Results.NotFound(), y /tareas/dos también, pero por otra razón: la restricción {id:int} hace que esa ruta ni siquiera encaje. En el POST, el cuerpo JSON se convierte en un NuevaTarea, y Results.Created responde 201 con la cabecera Location apuntando a la tarea nueva.

La lista vive en memoria. Los datos de este ejemplo desaparecen al parar el servidor, y una List no es segura si llegan dos peticiones a la vez (ASP.NET Core las atiende en paralelo). En una aplicación real, los datos van a una base de datos (mira el ejemplo de SQLite) y los servicios se registran con builder.Services y se reciben como parámetros de las funciones.

Resumen

ParaEscribe
Una ruta GETapp.MapGet("/ruta", () => ...)
Un parámetro en la ruta"/tareas/{id:int}" y (int id) => ...
Leer el cuerpo JSONun parámetro de tu tipo: (NuevaTarea nueva)
Elegir el códigoResults.Ok, NotFound, BadRequest, Created
Arrancarapp.Run(); en pruebas, StartAsync

Todo el código de esta página se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.