REST API in .Net handbook

TLDR: La idea de este post es consolidar varios temas útiles que suelen surgir cuando uno está construyendo una API REST con .Net


Tabla de contenido

  1. Creación de un proyecto
  2. Repository Pattern
  3. Prácticas para el manejo de URLs
  4. Uso de Slug
  5. Conectarse a PostgreSQL con Dapper
  6. Manejo de validaciones con FluentValidation
  7. Uso del cancellationToken
  8. JWT, Autenticación y Autorización
  9. Sorting y Paginación

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.

Git Commits

Creación de un proyecto

La idea inicial del proyecto tiene la siguiente estructura:

1
2
3
4
5
6
7
8
9
RestApiMovies.sln
├── Movies.API/
│ ├── Program.cs
│ ├── Movies.API.csproj
├── Movies.Application/
│ └── Movies.Application.csproj
├── Movies.Contracts/
│ └── Movies.Contracts.csproj
├── .gitignore

Tú puedes crear los proyectos con VS o Rider, pero aquí dejo los comandos para hacerlo con la CLI de .NET:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dotnet new sln -n RestApiMovies

dotnet new webapi -n Movies.API -f net9.0
dotnet new classlib -n Movies.Application -f net9.0
dotnet new classlib -n Movies.Contracts -f net9.0

dotnet sln add Movies.API/Movies.API.csproj
dotnet sln add Movies.Application/Movies.Application.csproj
dotnet sln add Movies.Contracts/Movies.Contracts.csproj

cd Movies.API && \
dotnet add package Microsoft.AspNetCore.OpenApi --version 9.0.0 && \
dotnet add reference ../Movies.Application/Movies.Application.csproj && \
dotnet add reference ../Movies.Contracts/Movies.Contracts.csproj && \
cd ..

dotnet new gitignore

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.

Creación de los primeros contratos

Ahora vamos a crear los primeros contratos:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Movies.Contracts/Request/CreateMovieRequest.cs
namespace Movies.Contracts.Request;

public class CreateMovieRequest
{
public required string Title { get; init; }
public required int YearOfRelease { get; init; }
public required IEnumerable<string> Genres { get; init; } = [];
}

// Movies.Contracts/Response/MovieResponse.cs
namespace Movies.Contracts.Responses;

public class MovieResponse
{
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; } = [];
}

// Movies.Contracts/Response/MoviesResponse.cs
namespace Movies.Contracts.Responses;

public class MoviesResponse
{
public required IEnumerable<MovieResponse> Items { get; init; } = [];
}

También creemos la entidad Movie, que va a estar asociada con la tabla de la base de datos:

1
2
3
4
5
6
7
8
9
10
// Movies.Application/Models/Movie.cs
namespace Movies.Application.Models;

public class Movie
{
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;

namespace Movies.Application.Repositories;

public interface IMovieRepository
{
Task<bool> CreateAsync(Movie movie);
Task<Movie?> GetByIdAsync(Guid id);
Task<IEnumerable<Movie>> GetAllAsync();
Task<bool> UpdateAsync(Movie movie);
Task<bool> DeleteByIdAsync(Guid id);
}

Ahora hagamos una implementación del IMovieRepository con una lista en memoria:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Movies.Application/Repositories/MovieRepository.cs
using Movies.Application.Models;

namespace Movies.Application.Repositories;

public class MovieRepository : IMovieRepository
{
private readonly List<Movie> _movies = new();

public Task<bool> CreateAsync(Movie movie)
{
_movies.Add(movie);
return Task.FromResult(true);
}

public Task<Movie?> GetByIdAsync(Guid id)
{
var movie = _movies.FirstOrDefault(m => m.Id == id);
return Task.FromResult(movie);
}

public Task<IEnumerable<Movie>> GetAllAsync()
{
return Task.FromResult(_movies.AsEnumerable());
}

public Task<bool> UpdateAsync(Movie movie)
{
var movieIndex = _movies.FindIndex(m => m.Id == movie.Id);
if (movieIndex == -1)
{
return Task.FromResult(false);
}

_movies[movieIndex] = movie;
return Task.FromResult(true);
}

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;

namespace Movies.Application;

public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddSingleton<IMovieRepository, MovieRepository>();
return services;
}
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using Movies.Application;

var builder = WebApplication.CreateBuilder(args);

// 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();
}

app.UseHttpsRedirection();
app.MapControllers();
app.Run();

Prácticas para el manejo de URLs

Vamos a crear el controlador para nuestro CRUD de películas:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 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;

namespace Movies.API.Controller;

[ApiController]
[Route("api/")]
public class MoviesController : ControllerBase
{
private readonly IMovieRepository _movieRepository;

public MoviesController(IMovieRepository movieRepository)
{
_movieRepository = movieRepository;
}

[HttpPost("movies")]
public async 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
});
}

[HttpGet("movies")]
public async Task<IActionResult> GetAll()
{
return Ok(await _movieRepository.GetAllAsync());
}
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Movies.API/Mapping/ContractMapping.cs
using Movies.Application.Models;
using Movies.Contracts.Request;
using Movies.Contracts.Responses;

namespace Movies.API.Mapping;

public static class ContractMapping
{
public static Movie MapToMovie(this CreateMovieRequest request)
{
return new Movie
{
Id = Guid.NewGuid(),
Title = request.Title,
YearOfRelease = request.YearOfRelease,
Genres = request.Genres.ToList()
};
}

public static MovieResponse MapToResponse(this Movie movie)
{
return new MovieResponse
{
Id = movie.Id,
Title = movie.Title,
YearOfRelease = movie.YearOfRelease,
Genres = movie.Genres.ToList()
};
}

public static MoviesResponse ToMoviesResponse(this IEnumerable<Movie> movies)
{
return new MoviesResponse()
{
Items = movies.Select(MapToResponse)
};
}
}

Y ahora vamos a usar estos métodos de extensión en nuestro controlador. Ahora luce más limpio y entendible:

1
2
3
4
5
6
7
8
9
// Movies.API/Controllers/MoviesController.cs

[HttpPost(ApiEndpoints.Movies.Create)]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request)
{
var movie = request.MapToMovie();
await _movieRepository.CreateAsync(movie);
return Created($"/{ApiEndpoints.Movies.Create}/{movie.Id}", movie.MapToResponse());
}

Creación del ApiEndpoints class

Vamos a crear una clase donde vamos a definir las rutas de nuestra API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Movies.API/ApiEndpoints.cs
namespace Movies.API;

public static class ApiEndpoints
{
private const string ApiBase = "api";

public static class Movies
{
private const string Base = $"{ApiBase}/movies";

public const string 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)]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace Movies.API.Controller;

[ApiController]
public class MoviesController : ControllerBase
{
private readonly IMovieRepository _movieRepository;

public MoviesController(IMovieRepository movieRepository)
{
_movieRepository = movieRepository;
}

[HttpPost(ApiEndpoints.Movies.Create)]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request)
{
var movie = request.MapToMovie();
await _movieRepository.CreateAsync(movie);
return Created($"/{ApiEndpoints.Movies.Create}/{movie.Id}", movie.MapToResponse());
}

Vamos a ajustar este endpoint de crear un poco más con el uso del CreatedAtAction:

1
2
3
4
5
6
7
[HttpPost(ApiEndpoints.Movies.Create)]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request)
{
var movie = request.MapToMovie();
await _movieRepository.CreateAsync(movie);
return CreatedAtAction(nameof(GetById), new { id = movie.Id }, movie.MapToResponse());
}

Vamos a hacer los otros endpoints para ver cómo podrían lucir. Agreguemos las constantes para los otros endpoints:

1
2
3
4
5
6
7
// Movies.API/ApiEndpoints.cs

public const string Get = $"{Base}/{{id:guid}}";
public const string GetAll = Base;

public const string Update = $"{Base}/{{id:guid}}";
public const string Delete = $"{Base}/{{id:guid}}";

Ajustemos el controlador para que use estas constantes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[HttpGet(ApiEndpoints.Movies.Get)]
public async Task<IActionResult> GetById([FromRoute] Guid id)
{
var movie = await _movieRepository.GetByIdAsync(id);
if (movie is null)
{
return NotFound();
}
return Ok(movie.MapToResponse());
}

[HttpGet(ApiEndpoints.Movies.GetAll)]
public async Task<IActionResult> GetAll()
{
var movies = await _movieRepository.GetAllAsync();
var moviesResponse = movies.ToMoviesResponse();
return Ok(moviesResponse);
}

[HttpPut(ApiEndpoints.Movies.Update)]
public async 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)]
public async 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
public static Movie MapToMovie(this UpdateMovieRequest request, Guid movieId)
{
return new 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:

Modificar la entidad Movie:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Movies.Application/Models/Movie.cs
using System.Text.RegularExpressions;

namespace Movies.Application.Models;

public partial class Movie
{
// Otras propiedades

public string Slug => GenerateSlug();

private string GenerateSlug()
{
var sluggedTitle = SlugRegex().Replace(Title, string.Empty)
.ToLower().Replace(" ", "-");
return $"{sluggedTitle}-{YearOfRelease}";
}

[GeneratedRegex("[^0-9A-Za-z _-]", RegexOptions.NonBacktracking, 5)]
private static partial Regex SlugRegex();
}

Ajustemos el ApiEndpoints para que use el slug o el id. Antes era así public const string Get = $"{Base}/{{id:guid}}":

1
2
3
// Movies.API/ApiEndpoints.cs

public const string Get = $"{Base}/{{idOrSlug}}";

En el controlador:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Movies.API/Controllers/MoviesController.cs

[HttpGet(ApiEndpoints.Movies.Get)]
public async Task<IActionResult> GetById([FromRoute] string idOrSlug)
{
var movie = Guid.TryParse(idOrSlug, out var id)
? await _movieRepository.GetByIdAsync(id)
: await _movieRepository.GetBySlugAsync(idOrSlug);
if (movie is null)
...
}

// Y ajustamos el CreatedAtAction del endpoint Create
[HttpPost(ApiEndpoints.Movies.Create)]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request)
{
var movie = request.MapToMovie();
await _movieRepository.CreateAsync(movie);
return CreatedAtAction(nameof(GetById), new { idOrSlug = movie.Slug }, movie.MapToResponse());
}

Ajustemos el mapping para agregar la nueva propiedad:

1
2
3
4
5
6
7
8
9
10
11
public static MovieResponse MapToResponse(this Movie movie)
{
return new MovieResponse
{
Id = movie.Id,
Title = movie.Title,
Slug = movie.Slug,
YearOfRelease = movie.YearOfRelease,
Genres = movie.Genres.ToList()
};
}

Ahora vamos al IMovieRepository:

1
2
// Movies.Application/Repositories/IMovieRepository.cs
Task<Movie?> GetBySlugAsync(string slug);

Y la implementación en específico:

1
2
3
4
5
6
// Movies.Application/Repositories/MovieRepository.cs
public Task<Movie?> GetBySlugAsync(string slug)
{
var movie = _movies.FirstOrDefault(m => m.Slug == slug);
return Task.FromResult(movie);
}

Y finalmente el contrato de MovieResponse:

1
2
3
4
5
6
7
8
9
10
// Movies.Contracts/Response/MovieResponse.cs
namespace Movies.Contracts.Responses;

public class MovieResponse
{
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.

1
2
3
cd Movies.Application && \
dotnet add package Dapper --version 2.1.66 && \
dotnet add package Npgsql --version 9.0.3

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Movies.Application/Database/IDbConnectionFactory.cs
using System.Data;
using Npgsql;

namespace Movies.Application.Database;

public interface IDbConnectionFactory
{
Task<IDbConnection> CreateConnectionAsync();
}

public class NpgsqlConnectionFactory : IDbConnectionFactory
{
private readonly string _connectionString;

public NpgsqlConnectionFactory(string connectionString)
{
_connectionString = connectionString;
}

public async Task<IDbConnection> CreateConnectionAsync()
{
var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
return connection;
}
}

En nuestra clase ApplicationServiceCollectionExtensions vamos a registrar el servicio IDbConnectionFactory:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Movies.Application/ApplicationServiceCollectionExtensions.cs
using System.Data;
using Microsoft.Extensions.DependencyInjection;
using Movies.Application.Database;
using Movies.Application.Repositories;

namespace Movies.Application;

public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddDatabase(this IServiceCollection services,
string connectionString)
{

//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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Movies.Application/Database/DbInitializer.cs
using Dapper;

namespace Movies.Application.Database;

public class DbInitializer
{
private readonly IDbConnectionFactory _connectionFactory;

public DbInitializer(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}

public async Task InitializeAsync()
{
using var 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();:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using Movies.Application;
using Movies.Application.Database;

var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddApplication();
builder.Services.AddDatabase(config["Database:ConnectionString"]!);

var app = builder.Build();

// Other methods

app.MapControllers();

var dbInitializer = app.Services.GetRequiredService<DbInitializer>();
await dbInitializer.InitializeAsync();

app.Run();

Agreguemos el connection string al appsettings.json:

1
2
3
4
5
{
"Database": {
"ConnectionString": "Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=Movies;Include Error Detail=true"
}
}

Ahora actualizamos la clase repository para que use Dapper:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// Movies.Application/Repositories/MovieRepository.cs
using Dapper;
using Movies.Application.Database;
using Movies.Application.Models;

namespace Movies.Application.Repositories;

public class MovieRepository : IMovieRepository
{
private readonly IDbConnectionFactory _dbConnection;

public MovieRepository(IDbConnectionFactory dbConnection)
{
_dbConnection = dbConnection;
}

public async Task<bool> CreateAsync(Movie movie)
{
using var connection = await _dbConnection.CreateConnectionAsync();

//Since I am going to insert into two tables (Movies and Genres), we need to use a transaction
using var 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;
}

public async Task<Movie?> GetByIdAsync(Guid id)
{
using var connection = await _dbConnection.CreateConnectionAsync();
var movie = await connection.QuerySingleOrDefaultAsync<Movie>(
new CommandDefinition("""SELECT * FROM movies WHERE id = @id""", new { id }));

if (movie is null)
{
return null;
}

var genres = await connection.QueryAsync<string>(
new CommandDefinition("""SELECT name FROM genres WHERE movieId = @id""", new { id }));
movie.Genres.AddRange(genres);
return movie;
}

public async Task<Movie?> GetBySlugAsync(string slug)
{
using var connection = await _dbConnection.CreateConnectionAsync();
var movie = await connection.QuerySingleOrDefaultAsync<Movie>(
new CommandDefinition("""SELECT * FROM movies WHERE slug = @slug""", new { slug }));

if (movie is null)
{
return null;
}

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;
}

public async Task<IEnumerable<Movie>> GetAllAsync()
{
using var 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(),
});
}

public async Task<bool> UpdateAsync(Movie movie)
{
using var connection = await _dbConnection.CreateConnectionAsync();
using var transaction = connection.BeginTransaction();

var exist = await ExistsByIdAsync(movie.Id);
if (!exist)
throw new 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;
}

public async Task<bool> DeleteByIdAsync(Guid id)
{
using var connection = await _dbConnection.CreateConnectionAsync();/
using var transaction = connection.BeginTransaction();

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;
}

public async Task<bool> ExistsByIdAsync(Guid id)
{
using var connection = await _dbConnection.CreateConnectionAsync();
return await 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;

namespace Movies.Application.Services;

public interface IMovieService
{
Task<bool> CreateAsync(Movie movie);
Task<Movie?> GetByIdAsync(Guid id);
Task<Movie?> GetBySlugAsync(string slug);
Task<IEnumerable<Movie>> GetAllAsync();
Task<Movie?> UpdateAsync(Movie movie);
Task<bool> DeleteByIdAsync(Guid id);
}

Su respectiva implementación inyectando el IMovieRepository:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// Movies.Application/Services/MovieService.cs
using Movies.Application.Models;
using Movies.Application.Repositories;

namespace Movies.Application.Services;

public class MovieService : IMovieService
{
private readonly IMovieRepository _movieRepository;

public MovieService(IMovieRepository movieRepository)
{
_movieRepository = movieRepository;
}

public async Task<bool> CreateAsync(Movie movie)
{
return await _movieRepository.CreateAsync(movie);
}

public async Task<Movie?> GetByIdAsync(Guid id)
{
return await _movieRepository.GetByIdAsync(id);
}

public async Task<Movie?> GetBySlugAsync(string slug)
{
return await _movieRepository.GetBySlugAsync(slug);
}

public async Task<IEnumerable<Movie>> GetAllAsync()
{
return await _movieRepository.GetAllAsync();
}

public async Task<Movie?> UpdateAsync(Movie movie)
{
var movieExist = await _movieRepository.ExistByIdAsync(movie.Id);
if (!movieExist)
{
return null;
}

await _movieRepository.UpdateAsync(movie);
return movie;
}

public async Task<bool> DeleteByIdAsync(Guid id)
{
return await _movieRepository.DeleteByIdAsync(id);
}
}

Registremos este nuevo servicio:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Movies.Application/ApplicationServiceCollectionExtensions.cs
using System.Data;
using Microsoft.Extensions.DependencyInjection;
using Movies.Application.Database;
using Movies.Application.Repositories;
using Movies.Application.Services;

namespace Movies.Application;

//Install Microsoft.Extensions.DependencyInjection.Abstractions
public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddSingleton<IMovieRepository, MovieRepository>();
services.AddSingleton<IMovieService, MovieService>();

return services;
}
}

Y ajustemos nuestro controlador para que use el servicio en lugar del repositorio:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 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;

namespace Movies.API.Controllers;

[ApiController]
public class MoviesController : ControllerBase
{
private readonly IMovieService _movieService;

public MoviesController(IMovieService movieService)
{
_movieService = movieService;
}

[HttpPost(ApiEndpoints.Movies.Create)]
public async 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)]
public async Task<IActionResult> GetById([FromRoute] string idOrSlug)
{
var movie = Guid.TryParse(idOrSlug, out var id)
? await _movieService.GetByIdAsync(id)
: await _movieService.GetBySlugAsync(idOrSlug);
if (movie is null)
{
return NotFound();
}
return Ok(movie.MapToToResponse());
}

[HttpGet(ApiEndpoints.Movies.GetAll)]
public async Task<IActionResult> GetAll()
{
var movies = await _movieService.GetAllAsync();
var moviesResponse = movies.ToMoviesResponse();
return Ok(moviesResponse);
}

[HttpPut(ApiEndpoints.Movies.Update)]
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] UpdateMovieRequest request)
{
var movie = request.MapToMovie(id);
var updated = await _movieService.UpdateAsync(movie);
if (updated is null)
return NotFound();

var response = updated.MapToToResponse();
return Ok(response);
}

[HttpDelete(ApiEndpoints.Movies.Delete)]
public async 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Movies.API/Mapping/ValidationMappingMiddleware.cs
using FluentValidation;
using Movies.Contracts.Responses;

namespace Movies.API.Mapping;

public class ValidationMappingMiddleware
{
private readonly RequestDelegate _next;

public ValidationMappingMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task InvokeAsync(HttpContext httpContext)
{
try
{
await _next(httpContext);
}
catch (ValidationException e)
{
httpContext.Response.StatusCode = 400;
var validationFailureResponse = new ValidationFailureResponse
{
Errors = e.Errors.Select(x => new ValidationResponse
{
PropertyName = x.PropertyName,
Message = x.ErrorMessage
})
};
await httpContext.Response.WriteAsJsonAsync(validationFailureResponse);
}
}
}

Hay que agregar el ValidationFailureResponse y ValidationResponse en Movies.Contracts:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Movies.Contracts/Response/ValidationFailureResponse.cs
namespace Movies.Contracts.Responses;

public class ValidationFailureResponse
{
public required IEnumerable<ValidationResponse> Errors { get; init; }
}

public class ValidationResponse
{
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;

// Other methods
app.UseHttpsRedirection();

app.UseAuthorization();
app.UseMiddleware<ValidationMappingMiddleware>();
app.MapControllers();

Ahora sí vamos a agregar el primer validador. Esto lo haremos dentro del Movies.Application en la carpeta Validators:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Movies.Application/Validators/MovieValidator.cs
using FluentValidation;
using Movies.Application.Models;
using Movies.Application.Repositories;

namespace Movies.Application.Validators;

public class MovieValidator : AbstractValidator<Movie>
{
private readonly IMovieRepository _movieRepository;


public MovieValidator(IMovieRepository repository)
{
_movieRepository = repository;
RuleFor(m => m.Id)
.NotEmpty();

RuleFor(x => x.Genres)
.NotEmpty();

RuleFor(x => x.Title)
.NotEmpty();

RuleFor(x => x.YearOfRelease)
.LessThanOrEqualTo(DateTime.UtcNow.Year);

RuleFor(x => x.Slug)
.MustAsync(ValidateSlug)
.WithMessage("This movie already exists.");
}

private async Task<bool> ValidateSlug(Movie movie, string slug, CancellationToken cancellationToken = default)
{
var existingMovie = await _movieRepository.GetBySlugAsync(slug);
if (existingMovie is not null)
{
return existingMovie.Id == movie.Id;
}

return existingMovie is null;
}
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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;

namespace Movies.Application;

//Install Microsoft.Extensions.DependencyInjection.Abstractions
public static class ApplicationServiceCollectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddSingleton<IMovieRepository, MovieRepository>();
services.AddSingleton<IMovieService, MovieService>();

//Add validators
services.AddValidatorsFromAssemblyContaining<IApplicationMarker>(ServiceLifetime.Singleton);

return services;
}
}

Cuando usamos el FromAssemblyContaining, estamos diciendo que busque todos los validadores en el mismo ensamblado que la interfaz IApplicationMarker.

Ahora vamos a usarlo en el servicio MovieService:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using FluentValidation;
using Movies.Application.Models;
using Movies.Application.Repositories;

namespace Movies.Application.Services;

public class MovieService : IMovieService
{
private readonly IMovieRepository _movieRepository;
private readonly IValidator<Movie> _movieValidator;

public MovieService(IMovieRepository movieRepository, IValidator<Movie> movieValidator)
{
_movieRepository = movieRepository;
_movieValidator = movieValidator;
}


public async Task<bool> CreateAsync(Movie movie)
{
await _movieValidator.ValidateAndThrowAsync(movie);
return await _movieRepository.CreateAsync(movie);
}

public async Task<Movie?> UpdateAsync(Movie movie)
{
await _movieValidator.ValidateAndThrowAsync(movie);
var movieExist = await _movieRepository.ExistByIdAsync(movie.Id);
//...
}
}

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 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;

namespace Movies.API.Controllers;

[ApiController]
public class MoviesController : ControllerBase
{
private readonly IMovieService _movieService;

public MoviesController(IMovieService movieService)
{
_movieService = movieService;
}

[HttpPost(ApiEndpoints.Movies.Create)]
public async 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)]
public async Task<IActionResult> GetById([FromRoute] string idOrSlug, CancellationToken cancellationToken)
{
var movie = Guid.TryParse(idOrSlug, out var id)
? await _movieService.GetByIdAsync(id, cancellationToken)
: await _movieService.GetBySlugAsync(idOrSlug, cancellationToken);
if (movie is null)
{
return NotFound();
}
return Ok(movie.MapToToResponse());
}

[HttpGet(ApiEndpoints.Movies.GetAll)]
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
{
var movies = await _movieService.GetAllAsync(cancellationToken);
var moviesResponse = movies.ToMoviesResponse();
return Ok(moviesResponse);
}

[HttpPut(ApiEndpoints.Movies.Update)]
public async 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 is null)
return NotFound();

var response = updated.MapToToResponse();
return Ok(response);
}

[HttpDelete(ApiEndpoints.Movies.Delete)]
public async 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;

namespace Identity.Api.Controllers;

[ApiController]
public class IdentityController : ControllerBase
{
private const string TokenSecret = "ForTheLoveOfGodStoreAndLoadThisSecurely";
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(8);

[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:

POST /token

Y el body:

1
2
3
4
5
6
7
8
{
"userid": "d8566de3-b1a6-4a9b-b842-8e3887a82e41",
"email": "hola@gmail.com",
"customClaims": {
"admin": true,
"trusted_member": true
}
}

El token sería algo así:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIxZDNmMTRhNS05NTVlLTRmOTgtOTlhYS1jNDVlNTI2MTViYzUiLCJzdWIiOiJob2xhQGdtYWlsLmNvbSIsImVtYWlsIjoiaG9sYUBnbWFpbC5jb20iLCJ1c2VyaWQiOiJkODU2NmRlMy1iMWE2LTRhOWItYjg0Mi04ZTM4ODdhODJlNDEiLCJhZG1pbiI6dHJ1ZSwidHJ1c3RlZF9tZW1iZXIiOnRydWUsIm5iZiI6MTc0NDUxNjMzOCwiZXhwIjoxNzQ0NTQ1MTM4LCJpYXQiOjE3NDQ1MTYzMzgsImlzcyI6Imh0dHBzOi8vaWQubmlja2NoYXBzYXMuY29tIiwiYXVkIjoiaHR0cHM6Ly9tb3ZpZXMubmlja2NoYXBzYXMuY29tIn0.UxBx8zu1vMKCpH23g8dgAv4NeLPcImT-2iAw5KNwG4U

Vamos a ver la decodificación de este token. La parte del header es la siguiente:

JWT Token decoded

Como podemos ver en el Header está:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

Y en el decoded payload está lo interesante; podemos ver toda la información que enviamos en el body del post.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"jti": "1d3f14a5-955e-4f98-99aa-c45e52615bc5",
"sub": "hola@gmail.com",
"email": "hola@gmail.com",
"userid": "d8566de3-b1a6-4a9b-b842-8e3887a82e41",
"admin": true,
"trusted_member": true,
"nbf": 1744516338,
"exp": 1744545138,
"iat": 1744516338,
"iss": "https://id.nickchapsas.com",
"aud": "https://movies.nickchapsas.com"
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
builder.Services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(x =>
{
x.TokenValidationParameters = new TokenValidationParameters
{
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:TokenKey"]!)),
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = config["Jwt:Issuer"],
ValidAudience = config["Jwt:Audience"],
ValidateIssuer = true,
ValidateAudience = true,
};
});

builder.Services.AddAuthorization();

//... 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:

1
2
3
4
5
6
7
8
9
10
{
"Database": {
"ConnectionString" : "Server="
},
"Jwt": {
"TokenKey": "ForTheLoveOfGodStoreAndLoadThisSecurely",
"Issuer" : "https://id.nickchapsas.com",
"Audience" : "https://movies.nickchapsas.com"
},
}

Y finalmente, en el controlador vamos a agregar el decorador [Authorize]:

1
2
3
4
5
6
7
8
9
10
using Microsoft.AspNetCore.Mvc;
using Movies.API.Mapping;
using Movies.Application.Repositories;

namespace Movies.API.Controllers;

[Authorize]
[ApiController]
public class MoviesController : ControllerBase
//...

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.

1
2
3
4
5
6
7
8
{
"userid": "d8566de3-b1a6-4a9b-b842-8e3887a82e41",
"email": "hola@gmail.com",
"customClaims": {
"admin": true,
"trusted_member": true
}
}

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:

1
2
3
4
5
6
7
8
9
10
11
//Movies.API/AuthConstants.cs
namespace Movies.API;

public static class AuthConstants
{
public const string AdminUserPolicyName = "Admin";
public const string AdminUserClaimName = "admin";

public const string TrustedMemberPolicyName = "Trusted";
public const string TrustedMemberClaimName = "trusted_member";
}

Ahora sí, vamos a ajustar el Program.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

[Authorize(AuthConstants.TrustedMemberPolicyName)]
[HttpPost(ApiEndpoints.Movies.Create)]
public async Task<IActionResult> Create([FromBody] CreateMovieRequest request,
CancellationToken cancellationToken)
{
}

[Authorize(AuthConstants.TrustedMemberPolicyName)]
[HttpPut(ApiEndpoints.Movies.Update)]
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] UpdateMovieRequest request, CancellationToken cancellationToken)
{
}

[Authorize(AuthConstants.AdminUserPolicyName)]
[HttpDelete(ApiEndpoints.Movies.Delete)]
public async Task<IActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken)
{
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Movies.API/Auth/IdentityExtensions.cs
using System.Security.Claims;

namespace Movies.API;

public static class IdentityExtensions
{
public static Guid? GetUserId(this HttpContext context)
{
var userId = context.User.Claims.SingleOrDefault(x => x.Type == "userid");

if(Guid.TryParse(userId?.Value, out Guid result))
{
return result;
}
return null;
}
}

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)]
public async Task<IActionResult> GetById([FromRoute] string idOrSlug, CancellationToken cancellationToken)
{
var userId = HttpContext.GetUserId();

var movie = Guid.TryParse(idOrSlug, out var id)
? await _movieService.GetByIdAsync(id, userId, cancellationToken)
: await _movieService.GetBySlugAsync(idOrSlug, userId, cancellationToken);
if (movie is null)
{
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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14

[HttpGet(ApiEndpoints.Movies.GetAll)]
public async 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();
return Ok(moviesResponse);
}

Ahora el API va a recibir un objeto GetAllMoviesRequest que tiene los siguientes parámetros:

1
2
3
4
5
6
7
8
9
//Movies.Contracts/Request/GetAllMoviesRequest.cs
namespace Movies.Contracts.Request;

public class GetAllMoviesRequest
{
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:

1
2
3
4
5
6
7
8
9
//Movies.Application/Models/GetAllMoviesOptions.cs
namespace Movies.Application.Models;

public class GetAllMoviesOptions
{
public string? Title { get; set; }
public int? YearOfRelease { get; set; }
public Guid? UserId { get; set; }
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//Movies.API/Mapping/ContractMapping.cs
public static GetAllMoviesOptions MapToOptions(this GetAllMoviesRequest request)
{
return new GetAllMoviesOptions
{
Title = request.Title,
YearOfRelease = request.Year
};
}

public static GetAllMoviesOptions WithUser(this GetAllMoviesOptions options, Guid? userId)
{
options.UserId = userId;
return options;
}

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;

namespace Movies.Application.Validators;

public class GetAllMoviesOptionsValidator : AbstractValidator<GetAllMoviesOptions>
{

public GetAllMoviesOptionsValidator()
{
RuleFor(x => x.UserId).NotEmpty();

RuleFor(x => x.YearOfRelease)
.LessThanOrEqualTo(DateTime.UtcNow.Year);
}

}

Listo, ahora sí vamos al MovieService.cs para recibir estos parámetros nuevos y validar el objeto options.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//Movies.Application/Services/MovieService.cs

private readonly IMovieRepository _movieRepository;
//Inyectamos el validator
private readonly IValidator<GetAllMoviesOptions> _optionsValidator;

public MovieService(IMovieRepository movieRepository, IValidator<Movie> movieValidator,
IRatingRepository ratingRepository, IValidator<GetAllMoviesOptions> optionsValidator)
{
_movieRepository = movieRepository;
_movieValidator = movieValidator;
_ratingRepository = ratingRepository;
_optionsValidator = optionsValidator;
}


public async Task<IEnumerable<Movie>> GetAllAsync(GetAllMoviesOptions options = default,
CancellationToken cancellationToken = default)
{
await _optionsValidator.ValidateAndThrowAsync(options, cancellationToken);

return await _movieRepository.GetAllAsync(options, cancellationToken);
}

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//Movies.Application/Repositories/IMovieRepository.cs
public interface IMovieRepository
{
Task<IEnumerable<Movie>> GetAllAsync(GetAllMoviesOptions options, CancellationToken cancellationToken = default);
}

//Movies.Application/Repositories/MovieRepository.cs

public async Task<IEnumerable<Movie>> GetAllAsync(GetAllMoviesOptions options,
CancellationToken cancellationToken = default)
{
using var 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

public class GetAllMoviesRequest
{
//... otras propiedades
public required string? SortBy { get; set; }
}

Y en el GetAllMoviesOptions vamos a agregar dos nuevas propiedades SortOrder y SortField:

1
2
3
4
5
6
7
8
9
10
11
12
13
//Movies.Application/Models/GetAllMoviesOptions.cs
public class GetAllMoviesOptions
{
public string? SortField { get; set; }
public SortOrder? SortOrder { get; set; }
}

public enum SortOrder
{
Ascending,
Descending,
Unsorted
}

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:

1
2
3
4
5
6
7
8
9
10
11
12

public static GetAllMoviesOptions MapToOptions(this GetAllMoviesRequest request)
{
return new GetAllMoviesOptions
{
Title = request.Title,
YearOfRelease = request.Year,
SortField = request.SortBy?.Trim('+', '-'),
SortOrder = request.SortBy is null ? SortOrder.Unsorted :
(request.SortBy.StartsWith('-') ? SortOrder.Descending : SortOrder.Ascending),
};
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class GetAllMoviesOptionsValidator : AbstractValidator<GetAllMoviesOptions>
{
private static readonly string[] AcceptableSortFields =
{
"title", "yearofrelease"
};

public GetAllMoviesOptionsValidator()
{
//... otras validaciones

RuleFor(x => x.SortField)
.Must(x => x is null || 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//Movies.Application/Repositories/MovieRepository.cs

public async Task<IEnumerable<Movie>> GetAllAsync(GetAllMoviesOptions options,
CancellationToken cancellationToken = default)
{
using var connection = await _dbConnection.CreateConnectionAsync(cancellationToken);

var orderClause = string.Empty;
if (options.SortField is not null)
{
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
namespace Movies.Contracts.Request;

public class PagedRequest
{
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

public class GetAllMoviesRequest : 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
///Movies.Contracts/Responses/PagedResponse.cs
namespace Movies.Contracts.Responses;

public class PagedResponse<TResponse>
{
public required IEnumerable<TResponse> Items { get; init; } = [];

public required int PageSize { get; init; }

public required int Page { get; init; }

public required int Total { get; init; }

public bool HasNextPage => Total > (Page * PageSize);

}

El objeto que retornará la API será más o menos así:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"items": [
{
"id": "d8566de3-b1a6-4a9b-b842-8e3887a82e41",
"title": "Batman",
"rating": 4.5,
"userRating": 5,
"yearOfRelease": 2023,
"genres": [
"Action",
"Adventure"
]
}
],
"pageSize": 10,
"page": 1,
"total": 100,
"hasNextPage": true
}

Ahora ajustemos la clase MoviesResponse para que use el nuevo objeto PagedResponse:

1
2
3
4
5
6
7
8
//Movies.Contracts/Responses/MoviesResponse.cs
namespace Movies.Contracts.Responses;

public class MoviesResponse : PagedResponse<MovieResponse>
{
//Removemos el objeto Items
//public required IEnumerable<MovieResponse> Items { get; init; } = [];
}

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)]
public async 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//Movies.API/Mapping/ContractMapping.cs

public static MoviesResponse ToMoviesResponse(this IEnumerable<Movie> movies,
int page, int pageSize, int totalCount)
{
return new MoviesResponse()
{
Items = movies.Select(MapToToResponse),
Page = page,
PageSize = pageSize,
Total = totalCount,
};
}

public static GetAllMoviesOptions MapToOptions(this GetAllMoviesRequest request)
{
return new GetAllMoviesOptions
{
Title = request.Title,
YearOfRelease = request.Year,
SortField = request.SortBy?.Trim('+', '-'),
SortOrder = request.SortBy is null ? SortOrder.Unsorted :
(request.SortBy.StartsWith('-') ? SortOrder.Descending : SortOrder.Ascending),
Page = request.Page,
PageSize = request.PageSize,
};
}

//Movies.Application/Models/GetAllMoviesOptions.cs
//Agreguemos las nuevas propiedades

public class GetAllMoviesOptions
{
//... otras propiedades
public int Page { get; set; }
public int PageSize { get; set; }
}

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
public class GetAllMoviesOptionsValidator : AbstractValidator<GetAllMoviesOptions>
{
public GetAllMoviesOptionsValidator()
{
//... 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

public async Task<IEnumerable<Movie>> GetAllAsync(GetAllMoviesOptions options,
CancellationToken cancellationToken = default)
{
using var connection = await _dbConnection.CreateConnectionAsync(cancellationToken);

var orderClause = string.Empty;
if (options.SortField is not null)
{
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

Y el response sería algo así:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"items": [
{
"id": "d8566de3-b1a6-4a9b-b842-8e3887a82e41",
"title": "Batman",
"rating": 4.5,
"userRating": 5,
"yearOfRelease": 2023,
"genres": [
"Action",
"Adventure"
]
}
],
"pageSize": 10,
"page": 1,
"total": 100,
"hasNextPage": true
}