Disclaimer: Esta publicación se inspiró en el curso de Nick Chapsas De cero a héroe: APIs REST en .NET. El curso es muy completo y lo recomiendo ampliamente. En esta publicación, intentaré resumir los puntos clave del curso, así como mi propia experiencia durante estos 13 años desarrollando APIs.
La idea de separar los proyectos es para tener una separación de responsabilidades y poder reutilizar el código en otros proyectos:
Por ejemplo, tener los contracts en una librería nueva nos permite generar el SDK de la API con Refit sin necesidad de incluir nada de lógica de la aplicación.
Al separar application, podemos reutilizar la lógica de negocio en otros proyectos, ya sea una app móvil o usando Blazor.
publicclassMovie { public required Guid Id { get; set; } public required string Title { get; init; } public required int YearOfRelease { get; init; } public required List<string> Genres { get; init; } = new (); }
Repository Pattern
El patrón de repositorio es una abstracción que nos permite separar la lógica de acceso a datos de la lógica de negocio. Primero que todo, vamos a instalar el paquete NuGet Microsoft.Extensions.DependencyInjection.Abstractions para poder registrar los servicios en el contenedor de dependencias.
Vamos a crear la interfaz del repositorio IMovieRepository:
1 2 3 4 5 6 7 8 9 10 11 12 13
// Movies.Application/Repositories/IMovieRepository.cs using Movies.Application.Models;
public Task<bool> DeleteByIdAsync(Guid id) { var removedCount = _movies.RemoveAll(m => m.Id == id); var movieRemoved = removedCount > 0; return Task.FromResult(movieRemoved); } }
Ahora, vamos a registrar el servicio en el contenedor de dependencias, pero para hacer esto, vamos a hacer uso de los métodos de extensión del IServiceCollection:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// Movies.Application/ApplicationServiceCollectionExtensions.cs using Microsoft.Extensions.DependencyInjection; using Movies.Application.Repositories;
Y en el Movies.API, vamos a registrar el servicio en la clase Program.cs del Movie.API. Haciendo esto, encapsulamos la lógica de registro de servicios en la capa de application:
// Add services to the container. builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer();
// No registrar los servicios aquí. Debemos encapsular la lógica de la capa // Crear métodos de extensión en su lugar builder.Services.AddApplication();
var app = builder.Build();
// Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.MapOpenApi(); }
// Movies.API/Controllers/MoviesController.cs using Microsoft.AspNetCore.Mvc; using Movies.Application.Models; using Movies.Application.Repositories; using Movies.Contracts.Request; using Movies.Contracts.Responses;
[HttpPost("movies")] publicasync Task<IActionResult> Create([FromBody] CreateMovieRequest request) { var movie = new Movie { Id = Guid.NewGuid(), Title = request.Title, YearOfRelease = request.YearOfRelease, Genres = request.Genres.ToList() }; await _movieRepository.CreateAsync(movie);
return Created($"/api/movies/{movie.Id}", new MovieResponse { Id = movie.Id, Title = movie.Title, YearOfRelease = movie.YearOfRelease, Genres = movie.Genres }); }
Probablemente con esta implementación vas a pensar: “Esto se ve bastante feo”. Y tienes razón, vamos a mejorar la implementación. Pero primero vamos a ajustar el mapping de objetos.
Mapping de objetos
Vamos de nuevo a recurrir a los métodos de extensión para encapsular un poco de esta lógica que estamos viendo. Podemos usar Automapper que es ampliamente usado en la comunidad de .NET, pero considero que muchas veces el mapeo debería ser explícito y claro de entender:
publicstaticclassMovies { privateconststring Base = $"{ApiBase}/movies";
publicconststring Create = Base; } }
Vamos a modificar nuestro controlador para que use esta clase. Quitamos el [Route] del controlador y vamos a usar la clase ApiEndpoints para definir las rutas en cada endpoint. Para el Create endpoint, vamos a actualizar el [HttpPost("movies")] por [HttpPost(ApiEndpoints.Movies.Create)]:
[HttpGet(ApiEndpoints.Movies.Get)] publicasync Task<IActionResult> GetById([FromRoute] Guid id) { var movie = await _movieRepository.GetByIdAsync(id); if (movie isnull) { return NotFound(); } return Ok(movie.MapToResponse()); }
[HttpGet(ApiEndpoints.Movies.GetAll)] publicasync Task<IActionResult> GetAll() { var movies = await _movieRepository.GetAllAsync(); var moviesResponse = movies.ToMoviesResponse(); return Ok(moviesResponse); }
[HttpPut(ApiEndpoints.Movies.Update)] publicasync Task<IActionResult> Update([FromRoute] Guid id, [FromBody] UpdateMovieRequest request) { var movie = request.MapToMovie(id); var updated = await _movieRepository.UpdateAsync(movie); if (!updated) return NotFound();
var response = movie.MapToResponse(); return Ok(response); }
[HttpDelete(ApiEndpoints.Movies.Delete)] publicasync Task<IActionResult> Delete([FromRoute] Guid id) { var updated = await _movieRepository.DeleteByIdAsync(id); if (!updated) return NotFound();
return Ok(); }
Creemos un nuevo map del UpdateMovieRequest para mapear a la entidad Movie:
1 2 3 4 5 6 7 8 9 10
publicstatic Movie MapToMovie(this UpdateMovieRequest request, Guid movieId) { returnnew Movie { Id = movieId, Title = request.Title, YearOfRelease = request.YearOfRelease, Genres = request.Genres.ToList() }; }
Uso de Slug
Vamos a agregar un slug a nuestra entidad Movie. El slug es una cadena que representa el título de la película en formato URL.
¿Cómo funciona?
Imaginemos que tenemos una película llamada “The Matrix” y el año de lanzamiento es 1999. El slug sería the-matrix-1999. Así que para evitar tener un endpoint como este /api/movies/ac69d00d-f99b-47bb-b618-37ffa4b120a0, podríamos hacerlo de la siguiente manera /api/movies/the-matrix-1999. Así tendríamos un endpoint más amigable para los usuarios y los motores de búsqueda. Para integrar los slugs, tenemos que hacer unas modificaciones:
publicclassMovieResponse { public required Guid Id { get; init; } public required string Title { get; init; } public required int YearOfRelease { get; init; } public required IEnumerable<string> Genres { get; init; } = []; }
Ahora nuestra API soporta https://localhost:7001/api/movies/fight-club-1999
Conectarse a PostgreSQL con Dapper
Vamos a agregar soporte para conectarnos a una base de datos PostgreSQL. Para esto vamos a instalar los paquetes NuGet Npgsql y Dapper en el proyecto de Movies.Application.
Vamos a crear una clase que nos permita conectarnos a la base de datos. En Movies.Application vamos a crear la carpeta Database y crearemos la clase NpgsqlConnectionFactory:
// Movies.Application/ApplicationServiceCollectionExtensions.cs using System.Data; using Microsoft.Extensions.DependencyInjection; using Movies.Application.Database; using Movies.Application.Repositories;
//It's singleton because NpgsqlConnectionFactory doesn't need to be anything else //Because the factory will return a new connection every time with CreateConnectionAsync() //It's effectively a singleton masking a trasient services.AddSingleton<IDbConnectionFactory>(_ => new NpgsqlConnectionFactory(connectionString)); services.AddSingleton<DbInitializer>(); return services; } }
Vamos a crear una clase que nos permita inicializar la base de datos. Esta clase ejecutará los scripts al inicializar la aplicación, creando la tabla movies y un índice único en el campo slug. Hay que tener en cuenta que todos los scripts que se ejecuten aqui tendran que validar si existen las tablas o indices antes de crearlos. Por eso se usa el create table if not exist y create index concurrently if not exists.
publicasync Task InitializeAsync() { usingvar connection = await _connectionFactory.CreateConnectionAsync(); await connection.ExecuteAsync(""" CREATE TABLE IF NOT EXISTS movies( id UUID PRIMARY KEY, slug TEXT NOT NULL, title TEXT NOT NULL, yearOfRelease INTEGER NOT NULL ); """); await connection.ExecuteAsync(""" CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS movies_slug_idx ON movies USING btree(slug) """);
await connection.ExecuteAsync(""" CREATE TABLE IF NOT EXISTS genres( movieId UUID REFERENCES movies (id), name TEXT NOT NULL ) """); } }
Ahora vamos a registrarlo en el Program.cs del Movies.API agregando builder.Services.AddDatabase(config["Database:ConnectionString"]!); y el dbInitializer antes del app.Run();:
//Since I am going to insert into two tables (Movies and Genres), we need to use a transaction usingvar transaction = connection.BeginTransaction();
var result = await connection.ExecuteAsync(new CommandDefinition(""" INSERT INTO movies (id, slug, title, yearofrelease) VALUES (@Id, @Slug, @Title, @YearOfRelease) """, movie)); if (result > 0) { foreach (var genre in movie.Genres) { await connection.ExecuteAsync(new CommandDefinition(""" INSERT INTO genres (movieId, name) VALUES (@MovieId, @Name) """, new { MovieId = movie.Id, Name = genre })); } }
transaction.Commit(); return result > 0; }
publicasync Task<Movie?> GetByIdAsync(Guid id) { usingvar connection = await _dbConnection.CreateConnectionAsync(); var movie = await connection.QuerySingleOrDefaultAsync<Movie>( new CommandDefinition("""SELECT * FROM movies WHERE id = @id""", new { id }));
if (movie isnull) { returnnull; }
var genres = await connection.QueryAsync<string>( new CommandDefinition("""SELECT name FROM genres WHERE movieId = @id""", new { id })); movie.Genres.AddRange(genres); return movie; }
publicasync Task<Movie?> GetBySlugAsync(string slug) { usingvar connection = await _dbConnection.CreateConnectionAsync(); var movie = await connection.QuerySingleOrDefaultAsync<Movie>( new CommandDefinition("""SELECT * FROM movies WHERE slug = @slug""", new { slug }));
if (movie isnull) { returnnull; }
var genres = await connection.QueryAsync<string>( new CommandDefinition("""SELECT name FROM genres WHERE movieId = @id""", new { id = movie.Id })); movie.Genres.AddRange(genres); return movie; }
publicasync Task<IEnumerable<Movie>> GetAllAsync() { usingvar connection = await _dbConnection.CreateConnectionAsync(); var result = await connection.QueryAsync( new CommandDefinition(""" SELECT m.*, STRING_AGG(g.name, ',') AS genres FROM movies m LEFT JOIN genres g ON m.id = g.movieId GROUP BY id """));
return result.Select(x => new Movie { Id = x.id, Title = x.title, YearOfRelease = x.yearofrelease, Genres = x.genres.Split(',').ToList(), }); }
var exist = await ExistsByIdAsync(movie.Id); if (!exist) thrownew KeyNotFoundException(); await connection.ExecuteAsync(new CommandDefinition( """ DELETE FROM genres WHERE movieId = @id """, new { id = movie.Id }));
foreach (var genre in movie.Genres) { await connection.ExecuteAsync(new CommandDefinition( """ INSERT INTO genres (movieId, name) VALUES (@MovieId, @Name) """, new { MovieId = movie.Id, Name = genre })); }
var result = await connection.ExecuteAsync (new CommandDefinition( """ UPDATE movies set slug = @slug, title = @title, yearOfRelease = @yearOfRelease """, movie)); transaction.Commit(); return result > 0; }
await connection.ExecuteAsync(new CommandDefinition( """ DELETE FROM genres WHERE movieId = @id """, new { id = id }));
var result = await connection.ExecuteAsync(new CommandDefinition( """ DELETE FROM movies WHERE id = @id """, new { id }));
transaction.Commit(); return result > 0; }
publicasync Task<bool> ExistsByIdAsync(Guid id) { usingvar connection = await _dbConnection.CreateConnectionAsync(); returnawait connection.ExecuteScalarAsync<bool>(new CommandDefinition(""" SELECT count(1) FROM Movies WHERE id = @id """, new { id })); } }
Listo, ahora pasemos a las validaciones
Manejo de validaciones con FluentValidation
Vamos a agregar validaciones a nuestro endpoint de creación de películas. Para esto vamos a realizar dos cosas: instalar el paquete NuGet FluentValidation.DependencyInjectionExtensions en el proyecto de Movies.Application y agregar una capa de servicios.
1 2
cd Movies.Application && \ dotnet add package FluentValidation.DependencyInjectionExtensions
Para los servicios, vamos a crear una carpeta Services en Movies.Application y vamos a crear la clase MovieService:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// Movies.Application/Services/MovieService.cs using Movies.Application.Models;
// Movies.Application/ApplicationServiceCollectionExtensions.cs using System.Data; using Microsoft.Extensions.DependencyInjection; using Movies.Application.Database; using Movies.Application.Repositories; using Movies.Application.Services;
// Movies.API/Controllers/MoviesController.cs using Microsoft.AspNetCore.Mvc; using Movies.API.Mapping; using Movies.Application.Repositories; using Movies.Application.Services; using Movies.Contracts.Request;
[HttpPost(ApiEndpoints.Movies.Create)] publicasync Task<IActionResult> Create([FromBody] CreateMovieRequest request) { var movie = request.MapToMovie(); await _movieService.CreateAsync(movie); return CreatedAtAction(nameof(GetById), new { idOrSlug = movie.Slug }, movie.MapToToResponse()); }
[HttpGet(ApiEndpoints.Movies.Get)] publicasync Task<IActionResult> GetById([FromRoute] string idOrSlug) { var movie = Guid.TryParse(idOrSlug, outvar id) ? await _movieService.GetByIdAsync(id) : await _movieService.GetBySlugAsync(idOrSlug); if (movie isnull) { return NotFound(); } return Ok(movie.MapToToResponse()); } [HttpGet(ApiEndpoints.Movies.GetAll)] publicasync Task<IActionResult> GetAll() { var movies = await _movieService.GetAllAsync(); var moviesResponse = movies.ToMoviesResponse(); return Ok(moviesResponse); }
[HttpPut(ApiEndpoints.Movies.Update)] publicasync Task<IActionResult> Update([FromRoute] Guid id, [FromBody] UpdateMovieRequest request) { var movie = request.MapToMovie(id); var updated = await _movieService.UpdateAsync(movie); if (updated isnull) return NotFound();
var response = updated.MapToToResponse(); return Ok(response); } [HttpDelete(ApiEndpoints.Movies.Delete)] publicasync Task<IActionResult> Delete([FromRoute] Guid id) { var updated = await _movieService.DeleteByIdAsync(id); if (!updated) return NotFound();
return Ok(); } }
Ahora sí, vamos a hacer las validaciones. Pero antes vamos a crear un middleware en Movies.API dentro del folder Mapping para poder manejar las excepciones de validación y que la respuesta de la API sea más clara:
publicclassValidationResponse { public required string PropertyName { get; init; } public required string Message { get; init; } }
Ahora todas las respuestas de validación van a ser más o menos así:
1 2 3 4 5 6 7 8 9 10 11 12
{ "errors": [ { "propertyName": "Title", "message": "'Title' must not be empty." }, { "propertyName": "YearOfRelease", "message": "'Year Of Release' must be greater than '0'." } ] }
Y agreguemos ese middleware app.UseMiddleware<ValidationMappingMiddleware>(); en nuestro Program.cs de Movies.API:
1 2 3 4 5 6 7 8 9 10
using Movies.API.Mapping; using Movies.Application; using Movies.Application.Database;
En esta clase tenemos los RuleFor que son las reglas de validación. También podemos hacer validaciones más complejas como el de ValidateSlug que valida si el slug ya existe en la base de datos.
Aunque ya hemos creado la clase MovieValidator, no hemos registrado el servicio en el contenedor de dependencias. Vamos a hacerlo en ApplicationServiceCollectionExtensions:
// Movies.Application/ApplicationServiceCollectionExtensions.cs using System.Data; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Movies.Application.Database; using Movies.Application.Repositories; using Movies.Application.Services;
Al usar un ValidateAndThrowAsync, esto va a lanzar una excepción si la validación falla. Y como tenemos un middleware que maneja las excepciones de validación, el usuario va a recibir una respuesta clara de que ha fallado la validación.
Aquí podremos buscar más documentación al respecto FluentValidation.
Uso del cancellationToken
El CancellationToken es una estructura que se utiliza para notificar a un hilo que se debe cancelar. Es una forma de comunicación entre dos hilos. En el caso de un hilo que está realizando un trabajo y otro hilo que quiere cancelar ese trabajo.
Todos los controladores tienen un CancellationToken como parámetro. Vamos a ajustar el controlador, pero la idea es que pasemos este parámetro hasta los servicios y repositorios. Casi todos los métodos lo aceptan, hasta el ValidateAndThrowAsync o el ExecuteAsync de Dapper.
// Movies.API/Controllers/MoviesController.cs using Microsoft.AspNetCore.Mvc; using Movies.API.Mapping; using Movies.Application.Repositories; using Movies.Application.Services; using Movies.Contracts.Request;
[HttpPost(ApiEndpoints.Movies.Create)] publicasync Task<IActionResult> Create([FromBody] CreateMovieRequest request, CancellationToken cancellationToken) { var movie = request.MapToMovie(); await _movieService.CreateAsync(movie, cancellationToken); return CreatedAtAction(nameof(GetById), new { idOrSlug = movie.Slug }, movie.MapToToResponse()); }
[HttpGet(ApiEndpoints.Movies.Get)] publicasync Task<IActionResult> GetById([FromRoute] string idOrSlug, CancellationToken cancellationToken) { var movie = Guid.TryParse(idOrSlug, outvar id) ? await _movieService.GetByIdAsync(id, cancellationToken) : await _movieService.GetBySlugAsync(idOrSlug, cancellationToken); if (movie isnull) { return NotFound(); } return Ok(movie.MapToToResponse()); } [HttpGet(ApiEndpoints.Movies.GetAll)] publicasync Task<IActionResult> GetAll(CancellationToken cancellationToken) { var movies = await _movieService.GetAllAsync(cancellationToken); var moviesResponse = movies.ToMoviesResponse(); return Ok(moviesResponse); }
[HttpPut(ApiEndpoints.Movies.Update)] publicasync Task<IActionResult> Update([FromRoute] Guid id, [FromBody] UpdateMovieRequest request, CancellationToken cancellationToken) { var movie = request.MapToMovie(id); var updated = await _movieService.UpdateAsync(movie, cancellationToken); if (updated isnull) return NotFound();
var response = updated.MapToToResponse(); return Ok(response); } [HttpDelete(ApiEndpoints.Movies.Delete)] publicasync Task<IActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) { var updated = await _movieService.DeleteByIdAsync(id, cancellationToken); if (!updated) return NotFound();
return Ok(); } }
JWT, Autenticación y Autorización
Actualmente, el tema de la autenticación y autorización es un tema que solía ser muy dispendioso, pero actualmente, con servicios como Auth0, Okta, Firebase, etc., podemos hacerlo de una manera más sencilla. Pero todos, en esencia, manejan un Token, que es un JWT (JSON Web Token), que es un estándar abierto (RFC-7519) que define un formato compacto y autónomo para transmitir información entre las partes como un objeto JSON.
Vamos a revisar la estructura de un JWT. Hay una página bastante útil llamada jwt.io donde podemos ver la estructura de un JWT. Este tiene tres partes:
Header
Payload
Signature
Los JWT Tokens pueden ser revisados en el cliente, pero no pueden ser modificados. El header contiene el tipo de token y el algoritmo de firma. El payload contiene la información del usuario o claims, y la firma es la que valida que el token no ha sido modificado.
En el curso de Nick Chapsas, nos muestra cómo crear un JWT desde cero; este token es el mismo que se generaría con servicios como los mencionados anteriormente. En este caso, el crearía una API para generar un token y sería algo así como esto:
using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens;
[HttpPost("token")] public IActionResult GenerateToken( [FromBody]TokenGenerationRequest request) { var tokenHandler = new JwtSecurityTokenHandler(); var key = Encoding.UTF8.GetBytes(TokenSecret);
var claims = new List<Claim> { new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new(JwtRegisteredClaimNames.Sub, request.Email), new(JwtRegisteredClaimNames.Email, request.Email), new("userid", request.UserId.ToString()) }; foreach (var claimPair in request.CustomClaims) { var jsonElement = (JsonElement)claimPair.Value; var valueType = jsonElement.ValueKind switch { JsonValueKind.True => ClaimValueTypes.Boolean, JsonValueKind.False => ClaimValueTypes.Boolean, JsonValueKind.Number => ClaimValueTypes.Double, _ => ClaimValueTypes.String }; var claim = new Claim(claimPair.Key, claimPair.Value.ToString()!, valueType); claims.Add(claim); } var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(claims), Expires = DateTime.UtcNow.Add(TokenLifetime), Issuer = "https://id.nickchapsas.com", Audience = "https://movies.nickchapsas.com", SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature) }; var token = tokenHandler.CreateToken(tokenDescriptor);
var jwt = tokenHandler.WriteToken(token); return Ok(jwt); } }
En este API, estamos enviando la información que tendrá el token internamente. Para poder usar este API, vamos a usar Postman; el HTTP Post sería algo así como este:
Ahora vamos a ajustar nuestro controlador para entender el token. Para esto vamos a agregar un nuevo paquete de NuGet llamado Microsoft.AspNetCore.Authentication.JwtBearer en el proyecto de Movies.API.
En el Program.cs vamos a agregar la configuración del JWT:
//... Código después del app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();
Esta configuración va a leer del appsettings.json la siguiente información que debería estar guardada en un lugar más seguro, pero por fines prácticos:
Hasta aquí, vamos bien. Hemos asegurado nuestro API con JWT; el token es válido y eso está bien, pero ahora vamos a ver las Policy que nos permitirán hacer una autorización más granular.
Policy
Recuerda que cuando creamos el token, inyectamos una propiedad llamada admin y trusted_member. Vamos a crear una policy que valide si el usuario tiene el claim admin o trusted_member.
Para esto vamos a ajustar el Program.cs para definir los Policy del Authorization, antes teníamos builder.Services.AddAuthorization() y ahora lo vamos a ajustar un poco:
Pero antes de eso, vamos a crear unas nuevas constantes. En el Movies.API crearemos un nuevo archivo llamado AuthConstants.cs y lo llenamos con lo siguiente:
builder.Services.AddAuthorization(x => { x.AddPolicy(AuthConstants.AdminUserPolicyName, p => p.RequireClaim(AuthConstants.AdminUserClaimName, "true")); x.AddPolicy(AuthConstants.TrustedMemberPolicyName, p => p.RequireAssertion(c => c.User.HasClaim(m => m is { Type : AuthConstants.AdminUserClaimName, Value: "true" }) || c.User.HasClaim(m => m is { Type : AuthConstants.TrustedMemberClaimName, Value: "true" }) )); });
Ahora en nuestro controlador de MoviesController removeremos el [Authorize] y vamos a ajustar los endpoints con [Authorize] específicos que utilizarán las policies:
En el proceso del request HTTP a alguno de estos endpoints, el middleware de autenticación va a validar el token y, si es válido, va a validar las policies. Si no tiene el claim correspondiente, el middleware de autorización va a devolver un 403 Forbidden.
Ahora, lo importante de tener un token es poder obtener información del usuario. Para esto vamos a crear un nuevo método de extensión para poder obtener el claim del usuario. En el Movies.API creamos un nuevo folder llamado auth y un archivo llamado IdentityExtensions.cs y lo llenamos con lo siguiente:
Ahora, en el controlador de MoviesController vamos a usar este método para obtener el id del usuario y lo vamos a agregar al endpoint GetById para que traiga información del usuario al cargar la película, por ejemplo, mis ratings.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
[HttpGet(ApiEndpoints.Movies.Get)] publicasync Task<IActionResult> GetById([FromRoute] string idOrSlug, CancellationToken cancellationToken) { var userId = HttpContext.GetUserId(); var movie = Guid.TryParse(idOrSlug, outvar id) ? await _movieService.GetByIdAsync(id, userId, cancellationToken) : await _movieService.GetBySlugAsync(idOrSlug, userId, cancellationToken); if (movie isnull) { return NotFound(); } //Resto del código }
El método de extensión se hizo desde el HttpContext. Esto es especialmente útil para obtener rápidamente información de los claims del JWT.
Sorting y Paginación
Hay muchas formas de filtrar información de un API. La técnica que vamos a usar aquí es ajustar el endpoint de GetAll para que acepte un objeto desde el [FromQuery] y escalar esos parámetros al query que estamos construyendo con Dapper. Vamos a ver cómo podría hacerse:
Cuando usamos un [FromQuery] en un endpoint, el API va a recibir los parámetros como query string. Por ejemplo, si tenemos un endpoint como https://localhost:5001/api/movies/getall?title=batman&year=2023, el API va a recibir un objeto que tiene esos dos parámetros.
var options = request.MapToOptions() .WithUser(userId); var movies = await _movieService.GetAllAsync(options, cancellationToken); var moviesResponse = movies.ToMoviesResponse(); return Ok(moviesResponse); }
Ahora el API va a recibir un objeto GetAllMoviesRequest que tiene los siguientes parámetros:
publicclassGetAllMoviesRequest { public required string? Title { get; init; }
public required int? Year { get; init; } }
Pero también tendremos que crear otro objeto. Así que antes de seguir, vamos a convertir el objeto Request a un objeto GetAllMoviesOptions que es el que vamos a usar para hacer la consulta a la base de datos y ejecutar las validaciones. Este objeto tiene los siguientes parámetros:
A diferencia del GetAllMoviesRequest, este objeto tiene un UserId que es el que vamos a usar para filtrar la consulta. Este es un ejemplo básico, pero es importante saber separar los objetos externos que van a llegar al API y los objetos internos que vamos a usar para hacer la consulta. Para esto tenemos que hacer un mapping entre los dos objetos con métodos de extensión. Vamos a usar nuestro archivo ContractMapping.cs en Movies.API/Mapping y lo llenamos con lo siguiente:
Usarlos es fácil, por ejemplo en el endpoint GetAll de MoviesController se agrego lo siguiente:
1 2 3
var options = request.MapToOptions() .WithUser(userId);
Esto podría ser equivalente a:
1 2 3 4 5 6
var options = new GetAllMoviesOptions { Title = request.Title, YearOfRelease = request.Year, UserId = userId };
Teniendo este objeto Options, vamos a tener que validar la información que recibimos un poco. Digamos que el año no puede ser menor al año actual y el UserId no puede ser vacío. Vamos a crear un nuevo validator para esto usando FluentValidation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
//Movies.Application/Validators/GetAllMoviesOptionsValidator.cs using FluentValidation; using Movies.Application.Models;
Ahora, en el MovieService vamos a ajustar el método GetAllAsync para que reciba el objeto GetAllMoviesOptions, lo vamos a validar con nuestro nuevo validador y, en caso de error, hacer un ThrowAsync y después enviarlo al MovieRepository. Aquí vamos a ajustar el query para que reciba los nuevos parámetros y los use para filtrar la consulta:
Para validar el título WHERE (@title is null or m.title like ('%' || @title || '%')) y para el año AND (@yearofrelease is null or m.yearofrelease = @yearofrelease.
publicasync Task<IEnumerable<Movie>> GetAllAsync(GetAllMoviesOptions options, CancellationToken cancellationToken = default) { usingvar connection = await _dbConnection.CreateConnectionAsync(cancellationToken); var result = await connection.QueryAsync( new CommandDefinition(""" select m.*, string_agg(g.name, ',') as genres, round(avg(r.rating), 1) as rating, myr.rating as userrating from Movies m left join genres g on m.id = g.movieId left join ratings r on m.id = r.movieid left join ratings myr on m.id = myr.movieid and myr.userid = @userid WHERE (@title is null or m.title like ('%' || @title || '%')) AND (@yearofrelease is null or m.yearofrelease = @yearofrelease) group by id, myr.rating """, new { userId = options.UserId , title = options.Title, yearofrelease = options.YearOfRelease, }, cancellationToken: cancellationToken));
return result.Select(x => new Movie { Id = x.id, Title = x.title, Rating = (float?)x.rating, UserRating = (int?)x.userrating, YearOfRelease = x.yearofrelease, Genres = Enumerable.ToList(x.genres.Split(',')), }); }
Aquí solamente estamos filtrando la consulta por el title y el yearOfRelease. Si no se envía nada, simplemente no se filtra. Pero ahora me gustaría hacer un sorting de la información. Para esto, vamos a ajustar el objeto GetAllMoviesRequest.cs para incluir la propiedad SortBy:
1 2 3 4 5 6 7
//Movies.Contracts/Request/GetAllMoviesRequest.cs
publicclassGetAllMoviesRequest { //... otras propiedades public required string? SortBy { get; set; } }
Y en el GetAllMoviesOptions vamos a agregar dos nuevas propiedades SortOrder y SortField:
Ahora, como vamos a convertir el SortBy en un SortField y un SortOrder, bueno, vamos a ajustar el mapping. En el ContractMapping.cs vamos a agregar lo siguiente:
Un ejemplo sería ?sortBy=+title o ?sortBy=-title, el primero ordena ascendente y el segundo descendente. Si no se envía nada, simplemente no se ordena.
Ahora me gustaría validar que el SortField sea un campo válido para filtrar. Para esto vamos a crear una nueva validación en nuestro archivo Movies.Application/Validators/GetAllMoviesOptionsValidator.cs que valide el SortField:
publicGetAllMoviesOptionsValidator() { //... otras validaciones RuleFor(x => x.SortField) .Must(x => x isnull || AcceptableSortFields.Contains(x, StringComparer.Ordinal)) .WithMessage("You can only sort by 'title' or 'yearofrelease'"); } }
Al validar esto, vamos a estar más tranquilos de que solo envíen lo que tenemos permitido. Ahora sí, vamos al MovieRepository y ajustamos el query para que use el SortField y el SortOrder:
var orderClause = string.Empty; if (options.SortField isnotnull) { orderClause = $""" , m.{options.SortField} order by m.{options.SortField} {(options.SortOrder == SortOrder.Ascending ? "asc" : "desc")} """; } var result = await connection.QueryAsync( new CommandDefinition($""" select m.*, string_agg(g.name, ',') as genres, round(avg(r.rating), 1) as rating, myr.rating as userrating from Movies m left join genres g on m.id = g.movieId left join ratings r on m.id = r.movieid left join ratings myr on m.id = myr.movieid and myr.userid = @userid WHERE (@title is null or m.title like ('%' || @title || '%')) AND (@yearofrelease is null or m.yearofrelease = @yearofrelease) group by id, myr.rating {orderClause} """, new { userId = options.UserId , title = options.Title, yearofrelease = options.YearOfRelease, }, cancellationToken: cancellationToken));
return result.Select(x => new Movie { Id = x.id, Title = x.title, Rating = (float?)x.rating, UserRating = (int?)x.userrating, YearOfRelease = x.yearofrelease, Genres = Enumerable.ToList(x.genres.Split(',')), }); }
En este caso, si no se envía nada, simplemente no se ordena. Si se envía un SortField y un SortOrder, se ordena por el campo que se envía.
Finalmente, vamos a agregar la paginación. Para esto, como el objeto de paginación podrá usarse en cualquier endpoint, vamos a crear un nuevo objeto llamado PagedRequest que tiene los siguientes parámetros:
1 2 3 4 5 6 7 8 9
//Movies.Contracts/Request/PagedRequest.cs namespaceMovies.Contracts.Request; publicclassPagedRequest { public required int Page { get; init; } = 1;
public required int PageSize { get; init; } = 10; }
Ahora a nuestro objeto GetAllMoviesRequest le vamos a agregar el PagedRequest. Aquí usando un poco de herencia, podemos reusar el objeto PagedRequest y no tener que repetir el mismo código en todos los endpoints.
1 2 3 4 5 6
//Movies.Contracts/Request/GetAllMoviesRequest.cs
publicclassGetAllMoviesRequest : PagedRequest { //... otras propiedades }
En este caso, la respuesta importa. Así que vamos a ajustar el response con la misma filosofía. Vamos a crear un nuevo objeto llamado PagedResponse que tiene los siguientes parámetros:
Ahora tenemos las propiedades que no teníamos antes. Vamos a ajustar el controlador y veamos qué falta.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
//Movies.API/Controllers/MoviesController.cs [HttpGet(ApiEndpoints.Movies.GetAll)] publicasync Task<IActionResult> GetAll( [FromQuery] GetAllMoviesRequest request, CancellationToken cancellationToken) { var userId = HttpContext.GetUserId(); var options = request.MapToOptions() .WithUser(userId); var movies = await _movieService.GetAllAsync(options, cancellationToken); //var moviesResponse = movies.ToMoviesResponse(); var movieCount = await _movieService.GetCountAsync(options.Title, options.YearOfRelease, cancellationToken); var moviesResponse = movies.ToMoviesResponse(request.Page, request.PageSize, movieCount); return Ok(moviesResponse); }
Ahora necesitamos obtener la cantidad total de las películas. Para esto vamos a usar el método GetCountAsync. Ya con esto, vamos a ajustar el método de extensión ToMoviesResponse:
Ya con esta información en nuestro objeto GetAllMoviesOptions, vamos a ajustar las validaciones para evitar números locos. En el GetAllMoviesOptionsValidator.cs vamos a agregar lo siguiente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
publicclassGetAllMoviesOptionsValidator : AbstractValidator<GetAllMoviesOptions> { publicGetAllMoviesOptionsValidator() { //... otras validaciones RuleFor(x => x.Page) .GreaterThanOrEqualTo(1); RuleFor(x => x.PageSize) .InclusiveBetween(1, 25) .WithMessage("You can get between 1 and 25 movies per page"); } }
Ahora sí, vamos a ajustar el query para que use la paginación. Vamos a agregar un OFFSET y un LIMIT al query:
var orderClause = string.Empty; if (options.SortField isnotnull) { orderClause = $""" , m.{options.SortField} order by m.{options.SortField} {(options.SortOrder == SortOrder.Ascending ? "asc" : "desc")} """; }
var result = await connection.QueryAsync( new CommandDefinition($""" select m.*, string_agg(g.name, ',') as genres, round(avg(r.rating), 1) as rating, myr.rating as userrating from Movies m left join genres g on m.id = g.movieId left join ratings r on m.id = r.movieid left join ratings myr on m.id = myr.movieid and myr.userid = @userid WHERE (@title is null or m.title like ('%' || @title || '%')) AND (@yearofrelease is null or m.yearofrelease = @yearofrelease) group by id, myr.rating {orderClause} limit @pageSize offset @pageOffset """, new { userId = options.UserId, title = options.Title, yearofrelease = options.YearOfRelease, pageSize = options.PageSize, pageOffset = (options.Page - 1) * options.PageSize, }, cancellationToken: cancellationToken));
return result.Select(x => new Movie { Id = x.id, Title = x.title, Rating = (float?)x.rating, UserRating = (int?)x.userrating, YearOfRelease = x.yearofrelease, Genres = Enumerable.ToList(x.genres.Split(',')), }); }
Ahora sí, ajustando el código, un ejemplo de un request sería algo así:
1
GET https://localhost:5001/api/movies/getall?title=batman&year=2023&sortBy=-title&page=1&pageSize=10