DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on

ASP.NET Core 6: Minimal APIs y Carter

Introducción

El mundo de .NET 6 está a la vuelta de la esquina (al día de hoy está en RC 2) y no solo yo estoy emocionado con estos cambios que vienen, si no también el equipo de Carter ya se ha encargado de no quedarse atrás y aprovechar lo nuevo que se viene.

Carter en versiones anteriores a .NET 6, nos ofrecían esta forma elegante de modularizar nuestras APIs y olvidarnos por siempre de los controladores y tener una forma simple de crear endpoints. Ahora esto ya lo hace las Minimal APIs pero eso significa que Carter esté muerto.

Para conocer más sobre Minimal APIs podemos ver este gist de David Flow que simplemente es lo que necesitas para entender todo sobre Minimal APIs.

Otra forma de aprender sobre Minimal APIs es este repositorio de Damian Edwards donde explora toda las funcionalidades con las que se cuenta. Si quieren aprender muy bien Minimal APIs les recomiendo esos dos enlaces.

Te recomiendo seguir este post viendo el código en GitHub para una mejor comprensión.

Carter

Lo que exploraremos en este post es la creación de Minimal APIs con carter con un ejemplo sencillo de catálogo de Productos (as usual).

¿Por qué usar Carter?

Que usemos Minimal APIs no significa que nuestra aplicación tiene que ser pequeña. Minimal APIs nace para tener una introducción sencilla a .NET y empezar sin tanto boilerplate y ceremony.

Carter nos da la posibilidad de modularizar nuestra API de una forma efectiva, utilizando una arquitectura vertical donde todo nuestro código está partido en Features, aunque esto es meramente opcional y no será tema de este post.

Nota 👀: Muchos de los features que exploraremos en este post no son propias de carter sino de Minimal APIs.

También Carter ya nos incluye FluentValidation, que es algo muy importante porque el ModelState en Minimal APIs no está disponible. Así que necesitamos alguna forma de validar nuestros modelos. Damian Edwards tiene una librería que hace validaciones con Data Annotations, que es un approach válido también, aunque prefiero no decorar los modelos con atributos (y más en este ejemplo que estamos usando directamente los Domain Objects y no DTOs).

Carter en ASP.NET Core 6

Comenzaremos desde 0, creando un proyecto web vacío con .NET 6 instalado previamente (versión RC 2 para este post).

dotnet new web -o MinimalApis
Enter fullscreen mode Exit fullscreen mode

Y posteriormente instalaremos los siguientes paquetes que incluyen carter y varios de EF Core (revisa mi GitHub para ver el ejemplo completo para que no te queden dudas)

<PackageReference Include="Carter" Version="6.0.0-pre2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0-rc.2.21480.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.0-rc.2.21480.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
Enter fullscreen mode Exit fullscreen mode

Nota 👀: Recuerda que estamos usando versiones preview y RC de estos paquetes NuGet. Si no te aparecen, hay que decirle a NuGet que incluya paquetes en pre-release y si estás leyendo esto después de noviembre, probablemente ya estarás usando las versiones finales.

In Memory Database

Para este ejemplo usaremos la siguiente base de datos con Entity Framework Core que mas adelante la configuraremos para que solo sea en memoria (para fines prácticos).

namespace MinimalApis.Api.Entities;
public class Product
{
    public Product(string description, double price)
    {
        Description = description;
        Price = price;
    }

    public int ProductId { get; set; }
    public string Description { get; set; }
    public double Price { get; set; }
}
Enter fullscreen mode Exit fullscreen mode
using Microsoft.EntityFrameworkCore;
using MinimalApis.Api.Entities;

namespace MinimalApis.Api.Persistence;
public class MyDbContext : DbContext
{
    public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) {}
    public DbSet<Product> Products => Set<Product>();
}
Enter fullscreen mode Exit fullscreen mode

Nota 👀: Los nullable reference types han llegado ya por default y es necesario asegurarnos que cualquier tipo de dato por referencia no sea nulo. Esto nos ayudará a que por fin (al menos tratar) eliminemos los NullReferenceExceptions que siempre aparecen cuando menos te lo esperas.

Products lo he guardado en una carpeta /Entities y el contexto en /Persistence. Realmente esto puede variar según el estilo de cada quien, así que no es relevante por ahora.

Products Module

Como mencioné antes, Carter nos pide crear módulos en lugar de Controladores. Por lo que guardaré esté módulo dentro de una carpeta Features/Products.

La intención de no poner todo junto es poder separar cada Feature según sus componentes relevantes (como Mappers, Validations, Servicios, etc).

namespace MinimalApis.Api.Features.Products;

public class ProductsModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
       // Los endpoints van aquí
    }
}
Enter fullscreen mode Exit fullscreen mode

Para registrar Endpoints en Minimal APIs tenemos disponible todos los verbos HTTP como lo solemos hacer en Web API (Ejem. [HttpPost], [HttpGet], etc).

Ejemplo.

app.MapGet("api/products", () => 
{
     // Consultar productos
});
Enter fullscreen mode Exit fullscreen mode

Aquí estamos registrando la ruta api/products con el verbo HttpGet.

Dependency Injection

Esta es una de las partes un poco extrañas, pero para resolver dependencias (en este caso nuestro DbContext) utilizamos los parámetros de la función del Endpoint en lugar del constructor como lo hacíamos antes.

app.MapGet("api/products", (MyDbContext context) =>
{
    var products = await context.Products
        .ToListAsync();

    return Results.Ok(products);
});
Enter fullscreen mode Exit fullscreen mode

Eso significa que en los parámetros de nuestra función se resuelven dependencias de distintos lados. Como por ejemplo (como ya vimos aquí) del IServiceCollection pero también de los datos que se reciben (ejemplo del Body, Headers, Query strings, etc).

Ocurre Model Binding de los datos recibidos por el cliente y también se puede resolver cualquier dependencia desde ahí. Si revisamos los links de Minimal APIs que he puesto más arriba podremos aprender más sobre el tema (ya que para todos es nuevo 😅).

Una versión mejorada y como hice este ejemplo, queda de la siguiente manera.

public void AddRoute(IEndpointRouteBuilder app)
{
        app.MapGet("api/products", GetProducts)
            .Produces<List<Product>>();
}

private static async Task<IResult> GetProducts(MyDbContext context)
{
    var products = await context.Products
        .ToListAsync();

    return Results.Ok(products);
}
Enter fullscreen mode Exit fullscreen mode

Aquí ya estamos agregando metadatos al endpoint (con .Produces<List<Product>>()) para que librerías como Swagger sepa interpretar y documentar nuestra API.

La clase Results cuenta con las posibles respuestas que podemos regresar, así como sucedía con los controladores (que regresábamos un IActionResult) aquí regresamos un IResult (algo muy similar).

Problem Details y Validaciones

Una de las cosas que también me gustó de Carter es que ya incluye FluentValidation. Y aunque no es algo automático como se podría configurar con MediatR o ASP.NET Web API, es muy sencillo.

Dentro de Features/Products puse el siguiente AbstractValidator.

using FluentValidation;
using MinimalApis.Api.Entities;

namespace MinimalApis.Api.Features.Products
{
    public class ProductValidator : AbstractValidator<Product>
    {
        public ProductValidator()
        {
            RuleFor(q => q.Description).NotEmpty();
            RuleFor(q => q.Price).NotNull();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Simplemente estamos estableciendo que los campos son obligatorios, pero aquí se pueden configurar muchas validaciones y es demasiado flexible (en comparación a los DataAnnotations).

Nuestro endpoint de creación queda así:

using Carter;
using Carter.ModelBinding;

// ... código omitido
public void AddRoutes(IEndpointRouteBuilder app)
{
        // ... código omitido
        app.MapPost("api/products", CreateProduct)
            .Produces<Product>(StatusCodes.Status201Created)
            .ProducesValidationProblem()
}

private static async Task<IResult> CreateProduct(HttpRequest req, Product product, MyDbContext context)
{
    var result = req.Validate(product);

    if (!result.IsValid)
    {
        return Results.ValidationProblem(result.ToValidationProblems());
    }

    context.Products.Add(product);

    await context.SaveChangesAsync();

    return Results.Created($"api/products/{product.ProductId}", product);
}
Enter fullscreen mode Exit fullscreen mode

Estamos inyectando 3 cosas en esta función:

  • HttpRequest. El request actual del HttpContext del endpoint
  • Product. Los datos recibidos en el [FromBody] del [HttpPost] que viene siendo el entity a crear
  • MyDbContext. DbContext de EF Core

Nota 👀: Si el método Validate no te aparece, probablemente es por namespaces faltantes.

Este endpoint tiene 2 posibles respuestas: un 201 o un 400. Aunque no se está obligado especificarlo, pero con los métodos Produces se especifica eso para que Swagger conozca mejor la API.

Un ejemplo de respuesta de error de validación.

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "errors": {
        "Description": [
            "'Description' no debería estar vacío."
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

Este formato es un estandar para APIs llamado Problem Details, este también se usa en los Api Controllers al usar el atributo [ApiController] (por si no sabías 😅).

Ejemplo de respuesta exitosa.

{
    "productId": 5,
    "description": "Product description here.",
    "price": 2499.99
}
Enter fullscreen mode Exit fullscreen mode

El método ToValidationProblems es una extensión de ValidationResult de FluentValidation para convertirlo en un diccionario agrupado según las propiedades que validaron.

using FluentValidation.Results;

namespace MinimalApis.Api.Extensions;

public static class GeneralExtensions
{
    public static Dictionary<string, string[]> ToValidationProblems(this ValidationResult result) =>
         result.Errors
            .GroupBy(e => e.PropertyName, e => e.ErrorMessage)
            .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray());
}
Enter fullscreen mode Exit fullscreen mode

Products Module completo

Creo que ya expliqué lo relevante en estos 2 endpoints. Así que ProductsModule queda de la siguiente manera.

using Carter;
using Carter.ModelBinding;
using Microsoft.EntityFrameworkCore;
using MinimalApis.Api.Entities;
using MinimalApis.Api.Extensions;
using MinimalApis.Api.Persistence;

namespace MinimalApis.Api.Features.Products;

public class ProductsModule : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapGet("api/products", GetProducts)
            .Produces<List<Product>>();

        app.MapGet("api/products/{productId}", GetProduct)
            .Produces<Product>()
            .Produces(StatusCodes.Status404NotFound);

        app.MapPost("api/products", CreateProduct)
            .Produces<Product>(StatusCodes.Status201Created)
            .ProducesValidationProblem();

        app.MapPut("api/products/{productId}", UpdateProduct)
            .Produces(StatusCodes.Status204NoContent)
            .ProducesProblem(StatusCodes.Status404NotFound)
            .ProducesValidationProblem();

        app.MapDelete("api/products/{productId}", DeleteProduct)
            .Produces(StatusCodes.Status204NoContent)
            .ProducesProblem(StatusCodes.Status404NotFound);
    }

    private static async Task<IResult> GetProducts(MyDbContext context)
    {
        var products = await context.Products
            .ToListAsync();

        return Results.Ok(products);
    }

    private static async Task<IResult> GetProduct(int productId, MyDbContext context)
    {
        var product = await context.Products.FindAsync(productId);

        if (product is null)
        {
            return Results.NotFound();
        }

        return Results.Ok(product);
    }

    private static async Task<IResult> CreateProduct(HttpRequest req, Product product, MyDbContext context)
    {
        var result = req.Validate(product);

        if (!result.IsValid)
        {
            return Results.ValidationProblem(result.ToValidationProblems());
        }

        context.Products.Add(product);

        await context.SaveChangesAsync();

        return Results.Created($"api/products/{product.ProductId}", product);
    }

    private static async Task<IResult> UpdateProduct(
        HttpRequest request, MyDbContext context, int productId, Product product)
    {
        var result = request.Validate(product);

        if (!result.IsValid)
        {
            return Results.ValidationProblem(result.ToValidationProblems());
        }

        var exists = await context.Products.AnyAsync(q => q.ProductId == productId);

        if (!exists)
        {
            return Results.Problem(
                detail: $"El producto con ID {productId} no existe",
                statusCode: StatusCodes.Status404NotFound);
        }

        context.Entry(product).State = EntityState.Modified;

        await context.SaveChangesAsync();

        return Results.NoContent();
    }

    private static async Task<IResult> DeleteProduct(int productId, MyDbContext context)
    {
        var product = await context.Products.FirstOrDefaultAsync(q => q.ProductId == productId);

        if (product is null)
        {
            return Results.Problem(
                detail: $"El producto con ID {productId} no existe",
                statusCode: StatusCodes.Status404NotFound);
        }

        context.Remove(product);

        await context.SaveChangesAsync();

        return Results.NoContent();
    }
}
Enter fullscreen mode Exit fullscreen mode

Extensiones (IServiceCollection y WebApplication)

Para dejar un Program.cs más limpio, siempre suelo usar extensiones para agregar la configuración de los middlewares y dependencias.

using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
using MinimalApis.Api.Persistence;

namespace MinimalApis.Api.Extensions;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddSwagger(this IServiceCollection services)
    {
        services.AddEndpointsApiExplorer();
        services.AddSwaggerGen(c =>
        {
            c.SwaggerDoc("v1", new OpenApiInfo()
            {
                Description = "Minimal API Demo",
                Title = "Minimal API Demo",
                Version = "v1",
                Contact = new OpenApiContact()
                {
                    Name = "Isaac Ojeda",
                    Url = new Uri("https://github.com/isaacOjeda")
                }
            });
        });

        return services;
    }

    public static IServiceCollection AddPersistence(this IServiceCollection services)
    {
        services.AddDbContext<MyDbContext>(options =>
            options.UseInMemoryDatabase(nameof(MyDbContext)));

        return services;
    }
}
Enter fullscreen mode Exit fullscreen mode

Aquí simplemente estamos creando 2 métodos de extensión para configurar Swagger y el DbContext (en este caso, en memoria).

namespace MinimalApis.Api.Extensions;

public static class WebApplicationExtensions
{
    public static WebApplication MapSwagger(this WebApplication app)
    {
        app.UseSwagger();
        app.UseSwaggerUI(c =>
        {
            c.SwaggerEndpoint("/swagger/v1/swagger.json", "API");
            c.RoutePrefix = "api";
        });

        return app;
    }
}
Enter fullscreen mode Exit fullscreen mode

Aquí también configuramos Swagger pero ahora su middleware para que tengamos un endpoint de exploración (el json) y la UI para hacer pruebas.

Integración de todo en Program

Ya tenemos todo listo para por fin correr esta API hecha con Carter

using Carter;
using MinimalApis.Api.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSwagger();
builder.Services.AddPersistence();
builder.Services.AddCarter();

var app = builder.Build();

app.MapSwagger();
app.MapCarter();

app.Run();
Enter fullscreen mode Exit fullscreen mode

Probando Swagger

Si ejecutamos dotnet run ya podemos explorar la API con Swagger ( o postman si lo prefieres)

Image description

Conclusión

Las Minimal APIs es algo que se necesitaba en .NET. Lenguajes como node con express manejaban este concepto desde siempre, pero poderlo hacer con .NET y toda su plataforma, es perfecto.

Carter existe desde versiones anteriores de .NET Core y buscaba simplificar esto, y su antecessor Nancy.Fx lo ha hecho desde siempre. Hay gente que odia los Controllers 😅.

Es cuestión de gustos, tal vez no todos empiecen a utilizar este approach, pero una buena razón es que por ahora es más rápido.

Image description

En un futuro tal vez se construyan más capas sobre Minimal APIs (como carter lo hace) y se vuelvan estandard en los templates que el mismo dotnet ofrecerá, no sé, puede pasar.

Espero que te haya gustado 🖖🏽

Code4Fun 🤓

Discussion (3)

Collapse
akiragothick profile image
Ernesto Vargas

Gran aporte!

Collapse
distorx profile image
Ricardo Rivera

Muy bien artículo, continua

Collapse
alfirian profile image
Jesús

Chulada de artículo!