Objectif 🎯
Dans cette partie de la série, nous allons faire un interlude dédié au développement d’un exemple d’application en WebApi ASP.NET simple, dont l’objectif sera son intégration complète à notre toute nouvelle infrastructure flambant neuve, incluant tests d’intégration sur une réelle base de données PostgreSQL, qualité / métriques de code et couverture de tests.
Initialisation
Créer un répertoire projet vide nommé conduit puis utiliser Mise pour installer la dernière version du sdk dotnet via la commande mise use dotnet@10. Enfin, initialisez le projet comme suit :
dotnet new webapi -o Conduit.WebApidotnet new gitignoredotnet new editorconfigdotnet new slndotnet sln add Conduit.WebApidotnet new classlib -o Conduit.Businessdotnet sln add Conduit.Businessdotnet add Conduit.WebApi reference Conduit.BusinessCréer le repo Git ohmytalos/conduit (ohmytalos en tant qu’organisation ici, mais rien n’empêche d’utiliser votre propre compte) sur votre nouvelle instance Gitea bien fraîche et poussez-y votre code :
g initgaagcmsg "init"git remote add origin git@ssh.ohmytalos.io:ohmytalos/conduit.gitgit push -u origin mainNote (Remarque)
On utilise SSH pour le push de code sur Gitea, assurez-vous donc d’avoir ajouté vos clés SSH publiques à votre compte Gitea.
Entités et EF Core
Créez 2 entités :
using System.ComponentModel.DataAnnotations;
namespace Conduit.Business.Models;
public class User{ private readonly List<Article> _articles = [];
public int Id { get; set; }
[MaxLength(255)] public required string Name { get; set; }
[MaxLength(255)] public required string Email { get; set; }
public string? Bio { get; set; }
[MaxLength(255)] public string? Image { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public virtual IReadOnlyCollection<Article> Articles => _articles;}using System.ComponentModel.DataAnnotations;
using Microsoft.EntityFrameworkCore;
namespace Conduit.Business.Models;
[Index("Slug", IsUnique = true)]public class Article{ public int Id { get; set; }
public int AuthorId { get; set; }
public required virtual User Author { get; set; }
[MaxLength(255)] public required string Title { get; set; }
[MaxLength(255)] public required string Slug { get; set; }
public required string Description { get; set; }
public required string Body { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }}Installer les dépendances ORM :
dotnet add Conduit.Business package Microsoft.Extensions.Configuration.Abstractionsdotnet add Conduit.Business package Microsoft.Extensions.DependencyInjection.Abstractionsdotnet add Conduit.Business package Microsoft.EntityFrameworkCoredotnet add Conduit.Business package Npgsql.EntityFrameworkCore.PostgreSQLdotnet add Conduit.Business package Microsoft.EntityFrameworkCore.Proxies
dotnet add Conduit.WebApi package Microsoft.EntityFrameworkCore.Designusing Conduit.Business.Models;
using Microsoft.EntityFrameworkCore;
namespace Conduit.Business.Contexts;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options){ public DbSet<User> Users => Set<User>(); public DbSet<Article> Articles => Set<Article>();}using Conduit.Business.Contexts;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.Configuration;using Microsoft.Extensions.DependencyInjection;
namespace Conduit.Business.Extensions;
public static class BusinessServicesExtensions{ public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration) { return services .AddDbContext<AppDbContext>(options => options .UseLazyLoadingProxies() .UseNpgsql( configuration.GetConnectionString("DefaultConnection") ) ); }}{ "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=conduit;Username=conduit;Password=conduit" }}Installer les outils de migration DB et de qualité de code :
dotnet new tool-manifestdotnet tool install dotnet-efdotnet tool install dotnet-sonarscannerdotnet tool install coverlet.consoleLancer un postgres local :
services: db: image: postgres:18 environment: POSTGRES_USER: conduit POSTGRES_PASSWORD: conduit POSTGRES_DB: conduit ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data
volumes: pgdata:docker compose up -dÀ ce stade, nous pouvons générer les fichiers de migration et les appliquer. Nous utiliserons les tasks intégrées dans mise pour centraliser les commandes récurrentes.
[tools]dotnet = "10"
[tasks.dev]run = "dotnet run --project Conduit.WebApi"
[tasks.migrations-add]usage = '''arg "<name>" help="Name of migration"'''run = '''dotnet ef migrations add ${usage_name?} --project Conduit.Business --startup-project Conduit.WebApi'''
[tasks.migrations-remove]run = "dotnet ef migrations remove --project Conduit.Business --startup-project Conduit.WebApi"
[tasks.db-update]run = "dotnet ef database update --project Conduit.Business --startup-project Conduit.WebApi"mise migrations-add InitialCreatemise db-updateLes tables Articles et Users sont maintenant créées dans la base de données, avec la clé étrangère qui va bien.
Prévoyez dès maintenant d’exclure les fichiers générés par EF Core de l’analyse de code :
#...
[**/Migrations/*.cs]generated_code = trueOn en profite pour inscrire les bonnes pratiques en rajoutant un fichier Directory.Build.props à la racine du repo pour configurer les paramètres globaux à tous les projets, incluant l’enrichissement de l’analyseur de code Roslyn .NET existant avec SonarAnalyzer, le traitement des warnings en erreurs, le verrouillage des versions de packages NuGet :
<Project> <PropertyGroup> <AnalysisLevel>latest-Recommended</AnalysisLevel> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup> <ItemGroup> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.15.0.120848" PrivateAssets="all" Condition="$(MSBuildProjectExtension) == '.csproj'" /> </ItemGroup> <ItemGroup> <SonarQubeSetting Include="sonar.exclusions"> <Value>**/Migrations/*.cs</Value> </SonarQubeSetting> <SonarQubeSetting Include="sonar.coverage.exclusions"> <Value>**/Migrations/*.cs</Value> </SonarQubeSetting> </ItemGroup></Project>Minimal API
Installer les dépendances (explorateur OpenAPI et validations DTOs) :
dotnet add Conduit.WebApi package Scalar.AspNetCoredotnet add Conduit.WebApi package FluentValidationdotnet add Conduit.WebApi package FluentValidation.DependencyInjectionExtensionsCréez les DTOs :
using System.ComponentModel.DataAnnotations;
using Conduit.Business.Models;
namespace Conduit.WebApi.Dtos;
public class AuthorDto{ public required string Name { get; set; }
public required string Email { get; set; }
public string? Bio { get; set; }
public string? Image { get; set; }}
public static class AuthorDtoExtensions{ public static AuthorDto ToDto(this User author) { return new AuthorDto { Name = author.Name, Email = author.Email, Bio = author.Bio, Image = author.Image }; }}using Conduit.Business.Models;
namespace Conduit.WebApi.Dtos;
public class ArticleDto{ public required string Title { get; set; }
public required string Slug { get; set; }
public required string Description { get; set; }
public required string Body { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public required AuthorDto Author { get; set; }}
public static class ArticleDtoExtensions{ public static ArticleDto ToDto(this Article article) { return new ArticleDto { Title = article.Title, Slug = article.Slug, Description = article.Description, Body = article.Body, CreatedAt = article.CreatedAt, UpdatedAt = article.UpdatedAt, Author = article.Author.ToDto() }; }}using Conduit.Business.Models;
namespace Conduit.WebApi.Dtos;
public class ArticlesDto{ public required List<ArticleDto> Items { get; set; } public required int Total { get; set; }}using Conduit.Business.Contexts;using Conduit.Business.Models;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace Conduit.WebApi.Dtos;
public class NewArticleDto{ public required string Title { get; set; }
public required string Description { get; set; }
public required string Body { get; set; }
public required string Author { get; set; }}
public class NewArticleValidator : AbstractValidator<NewArticleDto>{ public NewArticleValidator(AppDbContext context) { RuleFor(x => x.Title) .NotEmpty() .MaximumLength(100);
RuleFor(x => x.Description) .NotEmpty() .MaximumLength(250);
RuleFor(x => x.Body) .NotEmpty();
RuleFor(x => x.Author) .NotEmpty();
RuleFor(x => x.Title).MustAsync( async (title, cancellationToken) => !await context.Articles .Where(x => x.Slug == title.ToSlug()) .AnyAsync(cancellationToken) ) .WithMessage("Slug with this title already used");
RuleFor(x => x.Author).MustAsync( async (authorName, cancellationToken) => await context.Users .Where(u => u.Name == authorName) .AnyAsync(cancellationToken) ) .WithMessage("Author not found"); }}
public static class NewArticleDtoExtensions{ public static Article ToModel(this NewArticleDto article, User author) { return new Article { Title = article.Title, Slug = article.Title.ToSlug(), Description = article.Description, Body = article.Body, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, Author = author }; }
public static string ToSlug(this string title) { return title .ToLowerInvariant() .Replace(" ", "-") .Replace(".", "") .Replace(",", "") .Replace("!", "") .Replace("?", ""); }}Un petit ensemble d’endpoints minimal API, avec récupération d’articles paginés, vue détail et création d’article avec validation :
using Conduit.Business.Contexts;using Conduit.WebApi.Dtos;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;
namespace Conduit.WebApi.Endpoints;
public static class ArticlesEndpoints{ public static void MapArticlesEndpoints(this WebApplication app) { app.MapGet("/articles", async (AppDbContext dbContext, int offset = 0, int limit = 20) => { var articles = await dbContext.Articles .Skip(offset) .Take(Math.Min(limit, 100)) .Include(a => a.Author) .ToListAsync();
var total = await dbContext.Articles.CountAsync();
return Results.Ok(new ArticlesDto { Items = [.. articles.Select(a => a.ToDto())], Total = total }); }) .Produces<ArticlesDto>(StatusCodes.Status200OK);
app.MapGet("/articles/{slug}", async (AppDbContext dbContext, string slug) => { var article = await dbContext.Articles .FirstOrDefaultAsync(a => a.Slug == slug);
return article is null ? Results.NotFound() : Results.Ok(article.ToDto()); }) .Produces<NotFoundResult>(StatusCodes.Status404NotFound) .Produces<ArticleDto>(StatusCodes.Status200OK);
app.MapPost("/articles", async (AppDbContext dbContext, NewArticleDto articleDto, IValidator<NewArticleDto> validator) => { var validationResult = await validator.ValidateAsync(articleDto); if (!validationResult.IsValid) { return Results.ValidationProblem(validationResult.ToDictionary()); }
var author = await dbContext.Users .FirstOrDefaultAsync(u => u.Name == articleDto.Author);
if (author is null) { return Results.NotFound(); }
var article = articleDto.ToModel(author); dbContext.Articles.Add(article);
await dbContext.SaveChangesAsync();
return Results.Created($"/articles/{article.Slug}", article.ToDto()); }) .Produces<ValidationProblemDetails>(StatusCodes.Status400BadRequest) .Produces<NotFoundResult>(StatusCodes.Status404NotFound) .Produces<ArticleDto>(StatusCodes.Status201Created); }}Puis le point d’entrée du programme :
using System.Reflection;
using Conduit.Business.Extensions;using Conduit.WebApi.Endpoints;
using FluentValidation;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services .AddBusinessServices(builder.Configuration) .AddOpenApi() .AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
var app = builder.Build();
app.MapOpenApi();
app.MapScalarApiReference();
app.UseHttpsRedirection();
app.MapArticlesEndpoints();
await app.RunAsync();
public partial class Program{ protected Program() { }}Lancer mise dev devrait fonctionner, et aller sur /articles, il devrait retourner une liste vide.
Seed de données
On va maintenant se créer une petite application console pour seed les données.
dotnet new console -o Conduit.Consoledotnet sln add Conduit.Console
dotnet add Conduit.Console reference Conduit.Business
dotnet add Conduit.Console package Microsoft.Extensions.Hostingdotnet add Conduit.Console package Bogusdotnet add Conduit.Console package Respawn{ "ConnectionStrings": { "DefaultConnection": "Host=localhost;Port=5432;Database=conduit;Username=conduit;Password=conduit" }}using Bogus;
using Conduit.Business.Contexts;using Conduit.Business.Models;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Respawn;
namespace Conduit.Console.Commands;
public class SeederCommand( AppDbContext dbContext){ public async Task SeedAsync() { await RefreshDatabase();
var users = new Faker<User>() .RuleFor(u => u.Name, f => f.Internet.UserName()) .RuleFor(u => u.Email, (f, u) => f.Internet.Email(u.Name)) .RuleFor(u => u.Bio, f => f.Lorem.Sentence()) .RuleFor(u => u.Image, f => f.Internet.Avatar()) .Generate(50);
dbContext.Users.AddRange(users);
await dbContext.SaveChangesAsync();
dbContext.Articles.AddRange( new Faker<Article>() .RuleFor(a => a.Title, f => f.Lorem.Sentence()) .RuleFor(a => a.Description, f => f.Lorem.Sentences(2)) .RuleFor(a => a.Body, f => f.Lorem.Paragraphs(3)) .RuleFor(a => a.CreatedAt, f => f.Date.Past().ToUniversalTime()) .RuleFor(a => a.UpdatedAt, (f, a) => a.CreatedAt) .RuleFor(a => a.Slug, (f, a) => f.Lorem.Slug()) .RuleFor(a => a.Author, f => f.PickRandom(users)) .Generate(1000) );
await dbContext.SaveChangesAsync(); }
private async Task RefreshDatabase() { using var connection = new NpgsqlConnection( dbContext .Database .GetConnectionString() );
await connection.OpenAsync();
var respawner = await Respawner.CreateAsync(connection, new RespawnerOptions { TablesToIgnore = [ "__EFMigrationsHistory" ], SchemasToInclude = [ "public" ], DbAdapter = DbAdapter.Postgres });
await respawner.ResetAsync(connection); }}using Conduit.Business.Extensions;using Conduit.Console.Commands;using Microsoft.Extensions.Configuration;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;
var host = Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration((hostingContext, config) => { config.AddJsonFile("appsettings.json"); }) .ConfigureServices((context, services) => { services.AddBusinessServices(context.Configuration); services.AddTransient<SeederCommand>(); }) .Build();
var seeder = host.Services.GetRequiredService<SeederCommand>();await seeder.SeedAsync();# ...
[tasks.db-seed]run = "dotnet run"dir = "Conduit.Console"Lancer mise db-seed pour remplir la base de données avec des utilisateurs et des articles factices. Puis confirmer le fonctionnement de l’endpoint /articles, qui devrait désormais retourner une liste d’articles proprement paginée.
N’hésiter pas à tester que la création fonctionne bien également avec la bonne validation d’unicité du slug et de l’auteur existant.
Tests d’intégration
Nous allons utiliser l’incontournable Testcontainers pour lancer une base de données PostgreSQL éphémère lors de nos tests d’intégration. On utilisera également Respawn pour s’assurer que la base de données est propre avant chaque test.
dotnet new xunit -o Conduit.Testsdotnet sln add Conduit.Tests
dotnet add Conduit.Tests reference Conduit.WebApi
dotnet add Conduit.Tests package Microsoft.AspNetCore.Mvc.Testingdotnet add Conduit.Tests package Testcontainers.PostgreSqldotnet add Conduit.Tests package RespawnOn configure tout d’abord le Host applicatif qui sera l’instance WebApi sur lequel on exécutera tous les tests. Cette instance est configurée pour utiliser une base de données PostgreSQL lancée via Testcontainers.
using Conduit.Business.Contexts;
using Microsoft.AspNetCore.Hosting;using Microsoft.AspNetCore.Mvc.Testing;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
namespace Conduit.Tests;
public class ConduitApiFixture : WebApplicationFactory<Program>, IAsyncLifetime{ private readonly PostgreSqlContainer _postgreSqlContainer = new PostgreSqlBuilder() .WithDatabase("conduit") .WithUsername("conduit") .WithPassword("conduit") .WithImage("postgres:18") .Build();
protected override void ConfigureWebHost(IWebHostBuilder builder) { builder .UseEnvironment("Testing") .ConfigureServices(services => { var descriptor = services.SingleOrDefault( d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) { services.Remove(descriptor); }
services.AddDbContext<AppDbContext>((options) => { options .UseLazyLoadingProxies() .UseNpgsql(_postgreSqlContainer.GetConnectionString()); }); }); }
private async Task MigrateDatabase() { using var scope = Services.CreateScope();
using var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.Database.MigrateAsync(); }
public async Task InitializeAsync() { await _postgreSqlContainer.StartAsync(); await MigrateDatabase(); }
public new async Task DisposeAsync() { await _postgreSqlContainer.DisposeAsync(); await base.DisposeAsync(); }}On crée ensuite une classe de base pour toutes nos classes de tests, qui sera en charge de rafraîchir la base de données avant chaque test.
using Conduit.Business.Contexts;
using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Respawn;
using Xunit.Abstractions;
namespace Conduit.Tests;
[CollectionDefinition(nameof(DatabaseCollectionTest))]public class DatabaseCollectionTest : ICollectionFixture<ConduitApiFixture>;
[Collection(nameof(DatabaseCollectionTest))]public abstract class TestBase(ConduitApiFixture factory, ITestOutputHelper output) : IAsyncLifetime{ protected ITestOutputHelper Output => output; protected ConduitApiFixture Factory => factory; protected AppDbContext DbContext => Factory.Services.GetRequiredService<AppDbContext>();
public async Task RefreshDatabase() { using var connection = new NpgsqlConnection( DbContext .Database .GetConnectionString() );
await connection.OpenAsync();
var respawner = await Respawner.CreateAsync(connection, new RespawnerOptions { TablesToIgnore = [ "__EFMigrationsHistory" ], SchemasToInclude = [ "public" ], DbAdapter = DbAdapter.Postgres });
await respawner.ResetAsync(connection);
Output.WriteLine("Database refreshed"); }
public async Task InitializeAsync() { await RefreshDatabase(); }
public Task DisposeAsync() { return Task.CompletedTask; }}Une classe d’exemple basique pour tester la récupération d’un article via l’API.
using System.Net;using System.Net.Http.Json;
using Conduit.WebApi.Dtos;
using Xunit.Abstractions;
namespace Conduit.Tests.Articles;
public class ArticleGetTests(ConduitApiFixture factory, ITestOutputHelper output) : TestBase(factory, output){ [Fact] public async Task CannotGetNonExistentArticle() { var client = Factory.CreateClient();
var response = await client.GetAsync("/articles/slug-article");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); }
[Fact] public async Task CanGetExistentArticle() { var user = await DbContext.Users.AddAsync(new() { Name = "johndoe", Email = "johndoe@example.com", });
await DbContext.Articles.AddAsync(new() { Slug = "slug-article", Title = "Title Article", Description = "Description Article", Body = "Body Article", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, Author = user.Entity });
await DbContext.SaveChangesAsync();
var client = Factory.CreateClient();
var response = await client.GetAsync("/articles/slug-article");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var articleResponse = await response.Content.ReadFromJsonAsync<ArticleDto>();
Assert.Equivalent(new { Slug = "slug-article", Title = "Title Article", Description = "Description Article", Body = "Body Article", Author = new { Name = "johndoe", Email = "johndoe@example.com" } }, articleResponse); }}# ...
[tasks.test]run = "dotnet test --logger \"console;verbosity=detailed\""Voilà, il y a tout ce qu’il faut pour bien démarrer, lancer mise test pour exécuter les tests d’intégration. Les conteneurs de DBs seront automatiquement lancés et exécutés lors des tests et tout devrait passer au vert.
Conclusion
Dans cette partie, nous avons mis en place un exemple d’application .NET minimaliste avec une API REST, pushée sur notre instance Gitea. Nous avons vu comment configurer Entity Framework Core avec PostgreSQL, créer des entités et des DTOs, et implémenter des endpoints pour gérer les articles. Nous avons également mis en place des tests d’intégration robustes utilisant Testcontainers pour garantir la fiabilité de notre application.
Il est enfin temps d’en finir dans la dernière partie.