10

Lab 10 - Many-to-Many, Middleware și Serilog

Partea 1 din 4

CTI — Dezvoltarea Aplicațiilor Web — Laborator 10

Many-to-Many, Middleware și logging structurat cu Serilog


Obiective

Laboratorul continuă proiectul News Portal din Lab 9. La finalul acestui laborator, aplicația va avea tag-uri pentru articole (relație Many-to-Many cu junction table explicit), un pipeline de middleware custom (logging request/response + global exception handling) și logging structurat prin Serilog cu sinks Console și File.

Ce facem:

  • Many-to-Many în EF Core — entitatea Tag, junction explicit ArticleTag, configurare HasMany().WithMany().UsingEntity<>(), migrație, JOIN cu Tags prin Include()
  • UI — afișare tag-uri ca badge-uri pe Details, multi-select pe Create/Edit
  • Pipeline-ul de middleware ASP.NET Core — ce e un middleware, în ce ordine rulează, cum scriem LoggingMiddleware cu Stopwatch și ILogger<T>
  • Global exception handlingExceptionHandlingMiddleware care prinde tot, mapează tipuri de excepții la status codes și răspunde cu JSON
  • Serilog — instalare, configurare cu sinks Console + File rolling pe zi, integrare prin builder.Host.UseSerilog()
  • Logging structurat — diferența între LogInformation("X: {Id}", id) și interpolare $"X: {id}"

Punctaj (4p)

  • 3 exerciții: Ex 1 = 1p, Ex 2 = 1p, Ex 3 = 2p.
  • Ex 1 acoperă Partea 1 (Many-to-Many), Ex 2 Partea 2 (middleware), Ex 3 Partea 3 (Serilog).

Definițiile complete sunt la finalul fiecărei părți.

Recapitulare Lab 9

Din laboratoarele anterioare avem:

  • Lab 6: Repository Pattern + Service Layer + MVC + async/await
  • Lab 7: ASP.NET Core Identity, Roles (Admin, User), content ownership pe AuthorId
  • Lab 8: Web API RESTful cu DTOs, Swagger, JWT Bearer pe scrieri
  • Lab 9: Proiect de teste — Lab09.Tests/ cu unit tests pe ArticleService (EF InMemory) + integration tests prin WebApplicationFactory<Program> cu JWT real

Lab10_start continuă fără să strice nimic din ce s-a construit. Modelul Article rămâne la fel, controller-ele sunt neschimbate. Adăugăm o entitate nouă (Tag), două middleware-uri și înlocuim sistemul de logging cu Serilog.

În Lab10_start aveți deja:

  • Models/Tag.cs și Models/ArticleTag.cs — clase goale cu TODO-uri
  • Middleware/LoggingMiddleware.cs și Middleware/ExceptionHandlingMiddleware.cs — schelete cu InvokeAsync pass-through
  • Serilog.AspNetCore, Serilog.Sinks.Console, Serilog.Sinks.File — preinstalate în Lab10.csproj (nu trebuie dotnet add package)
  • TODO-uri în Program.cs la locurile unde trebuie configurat Serilog și înregistrate middleware-urile

De ce un junction table explicit?

EF Core suportă două stiluri pentru Many-to-Many:

Stil Configurare Junction Când
Implicit HasMany().WithMany() Tabel auto-generat ArticleTag cu doar ArticleId + TagId Când nu vă pasă de junction (cazul comun)
Explicit UsingEntity<ArticleTag>(...) Clasă ArticleTag pe care o controlați Când junction-ul are coloane proprii (AddedAt, AddedByUserId, ordering) sau când vreți acces direct la el

Forma completă a configurării implicite arată așa: HasMany(a => a.Tags).WithMany(t => t.Articles).

Pentru tag-urile noastre, explicit e mai mult din motive pedagogice — vedeți toate cele trei tabele în BD (Articles, Tags, ArticleTags), înțelegeți ce face EF în spate și aveți unde să adăugați coloane în lab-urile viitoare. La un proiect real, dacă junction-ul rămâne pur „cuplaj fără date", stilul implicit e suficient.

Cum încărcăm tag-urile (recap din Lab 6)

Tags din Article e o proprietate de navigare. Fără .Include(a => a.Tags) în query, EF nu o populează — article.Tags rămâne goală chiar dacă în BD există relațiile. Soluția: cerem explicit JOIN-ul prin Include — apare în SQL log, predictibil, fără surprize la runtime.

var article = await _context.Articles
    .Include(a => a.Tags)        // JOIN cu Tags
    .FirstOrDefaultAsync(a => a.Id == id);

De ce middleware?

Pipeline-ul ASP.NET Core e o listă de funcții care se apelează una pe alta — fiecare poate face ceva înainte și după restul pipeline-ului:

Pipeline middleware ASP.NET Core Request-ul intra prin Exception Handling, trece prin Logging, Routing, Auth, ajunge la Controller. Response-ul iese in ordine inversa. REQUEST cod inainte de await _next(ctx) Exception Handling primul - prinde tot custom Logging durata + status custom Routing match controller built-in Auth cookie / JWT built-in Controller action handler endpoint RESPONSE cod dupa await _next(ctx)

Ordinea în care apelați app.UseMiddleware<X>() e ordinea în care X vede request-ul. Pe response, ordinea e inversă. De-asta ExceptionHandling trebuie să fie primul — vrea să prindă tot ce poate arunca în spatele lui.

Cazuri tipice:

Middleware Ce face Built-in?
UseHttpsRedirection redirect HTTP → HTTPS da
UseStaticFiles servește fișiere din wwwroot da
UseRouting identifică ce controller/action răspunde da
UseAuthentication / UseAuthorization auth real (cookie / JWT) da
LoggingMiddleware (custom) logăm method, path, status, durată nu — îl scriem noi
ExceptionHandlingMiddleware (custom) prindem excepții → JSON + status nu — îl scriem noi

De ce Serilog?

ILogger<T> built-in funcționează — dar:

  • Sinks limitate — built-in scrie în Console și Debug. Pentru File / Seq / Elasticsearch / Application Insights, trebuie pachete.
  • Structured logging funcționează cu built-in, dar arhivarea e simplistă — nu poți filtra după {Id} decât parsând text.
  • Configurare — built-in are o singură secțiune Logging în appsettings.json. Serilog acceptă config bogat (sinks multiple, enrichers, filtre per-sink).

Serilog e standardul de-facto în ecosistemul .NET pentru aplicații care chiar țin la log-uri. Sintaxa e identică cu ILogger<T> — doar adăugați câteva linii la pornire.

Notă: Serilog înlocuiește logger-ul built-in. Toate ILogger<T> injectate (în service-uri, controller-e, middleware) merg automat prin Serilog după ce apelați builder.Host.UseSerilog(). Nu modificați nimic în service-urile existente.

Parte 1 — Many-to-Many cu junction explicit

Pasul 1 — Modelul Tag

Un articol poate avea mai multe tag-uri; un tag poate eticheta mai multe articole. Asta e Many-to-Many.

În Lab10_start/Models/Tag.cs aveți o clasă goală cu TODO-uri. Completați-o:

using System.ComponentModel.DataAnnotations;

namespace Lab10.Models;

public class Tag : BaseEntity
{
    [Required]
    [MinLength(2)]
    public string Name { get; set; } = string.Empty;

    // Navigare Many-to-Many catre Article
    public List<Article> Articles { get; set; } = new();
}

Articles e proprietate de navigare — EF o populează când cereți Include(t => t.Articles). Fără Include, lista rămâne goală.

De ce : BaseEntity? Pentru că Repository<T> (din Lab 6) are constraint where T : BaseEntity. BaseEntity doar expune Id - moștenirea ne dă Id automat și ne face Tag compatibil cu repository-ul generic.

Pasul 2 — Junction-ul explicit ArticleTag

În Lab10_start/Models/ArticleTag.cs, definiți junction-ul:

using System.ComponentModel.DataAnnotations.Schema;

namespace Lab10.Models;

[Table("ArticleTags")]
public class ArticleTag
{
    public int ArticleId { get; set; }
    public Article Article { get; set; } = null!;

    public int TagId { get; set; }
    public Tag Tag { get; set; } = null!;
}

Cele două perechi (FK + navigare) sunt convenția EF pentru junction. EF deduce că PK-ul compus este (ArticleId, TagId) din configurarea pe care o adăugăm în OnModelCreating.

De ce [Table("ArticleTags")]? Implicit EF folosește numele clasei pentru numele tabelului - aici ar fi ArticleTag (singular). Pentru consistență cu celelalte tabele plurale (Tags, Articles), forțăm numele plural cu atributul [Table]. Alternativ, puteți folosi j.ToTable("ArticleTags") în configurarea UsingEntity<> din OnModelCreating.

De ce null!? Cu <Nullable>enable</Nullable> în .csproj, compilatorul cere ca proprietățile non-nullable să fie inițializate. La entități EF, navigările nu pot fi inițializate la construcție — sunt setate de EF la materializare. null! e promisiunea către compilator: „știu, n-o să fie null când o citesc".

Pasul 3 — Article primește o navigare către Tags

Deschideți Models/Article.cs și adăugați navigarea Many-to-Many (alături de cele existente către Category și Author):

public class Article : BaseEntity
{
    // ... proprietati existente (Title, Content, PublishedAt, CategoryId, AuthorId, ...) ...

    // Navigare Many-to-Many catre Tag
    public List<Tag> Tags { get; set; } = new();
}

Nu adăugați List<ArticleTag> ArticleTags. EF descoperă junction-ul prin configurarea din OnModelCreating; navigarea „primară" (cea pe care o folosiți în cod) e direct către List<Tag>.

Pasul 4 — Configurare în AppDbContext

În Data/AppDbContext.cs, adăugați DbSet<Tag> și configurarea Many-to-Many:

public DbSet<Tag> Tags { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    // ... configurari existente (Article <-> Category, Article <-> Author) ...

    // Many-to-Many: Article <-> Tag, prin junction explicit ArticleTag
    modelBuilder.Entity<Article>()
        .HasMany(a => a.Tags)
        .WithMany(t => t.Articles)
        .UsingEntity<ArticleTag>(
            j => j.HasOne(at => at.Tag).WithMany().HasForeignKey(at => at.TagId),
            j => j.HasOne(at => at.Article).WithMany().HasForeignKey(at => at.ArticleId),
            j => j.HasKey(at => new { at.ArticleId, at.TagId }));
}

Cele trei lambda-uri ale lui UsingEntity<ArticleTag> configurează:

  1. Cum e legat junction-ul de Tag (FK către Tags.Id)
  2. Cum e legat junction-ul de Article (FK către Articles.Id)
  3. PK-ul compus al junction-ului

Pasul 5 — Migrația

cd C:\Daniel\ore\DAW-2025-2026\labs\lab10\Lab10_start
dotnet ef migrations add AddTagsAndManyToMany
dotnet ef database update

Migrația ar trebui să creeze două tabele:

  • Tags (Id, Name)
  • ArticleTags (ArticleId, TagId, PK compus, FK-uri către Articles și Tags)

Verificați în SQL Server Object Explorer (sau echivalentul vostru) că ambele apar.

Pasul 6 — Seed pentru tag-uri

În Data/SeedData.cs, după seed-ul de categorii și articole, adăugați un seed pentru tag-uri și asociați-le la articolele existente:

if (!await context.Tags.AnyAsync())
{
    var tags = new[]
    {
        new Tag { Name = "C#" },
        new Tag { Name = "Web" },
        new Tag { Name = "Tutorial" },
        new Tag { Name = ".NET" },
        new Tag { Name = "Database" }
    };

    context.Tags.AddRange(tags);
    await context.SaveChangesAsync();

    // Atasam cateva tag-uri la primul articol seed-uit
    var firstArticle = await context.Articles
        .Include(a => a.Tags)
        .FirstOrDefaultAsync();

    if (firstArticle != null)
    {
        firstArticle.Tags.Add(tags[0]); // C#
        firstArticle.Tags.Add(tags[3]); // .NET
        await context.SaveChangesAsync();
    }
}

Pasul 7 — Include Tags în repository (toate cele 4 metode)

În Repositories/ArticleRepository.cs, toate metodele care fac JOIN cu Category + Author trebuie să adauge și .Include(a => a.Tags). Sunt patru: GetAllWithDetailsAsync, GetByIdWithDetailsAsync, GetByCategoryAsync, GetPagedAsync. Dacă uitați una, articolele întoarse de acea metodă vor avea Tags lista goală chiar dacă în BD există relațiile.

public async Task<List<Article>> GetAllWithDetailsAsync(CancellationToken cancellationToken = default)
{
    return await _context.Articles
        .Include(a => a.Category)
        .Include(a => a.Author)
        .Include(a => a.Tags)              // <-- nou
        .OrderByDescending(a => a.PublishedAt)
        .ToListAsync(cancellationToken);
}

public async Task<Article?> GetByIdWithDetailsAsync(int id, CancellationToken cancellationToken = default)
{
    return await _context.Articles
        .Include(a => a.Category)
        .Include(a => a.Author)
        .Include(a => a.Tags)              // <-- nou
        .FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
}

public async Task<List<Article>> GetByCategoryAsync(int categoryId, CancellationToken cancellationToken = default)
{
    return await _context.Articles
        .Where(a => a.CategoryId == categoryId)
        .Include(a => a.Category)
        .Include(a => a.Author)
        .Include(a => a.Tags)              // <-- nou
        .OrderByDescending(a => a.PublishedAt)
        .ToListAsync(cancellationToken);
}

public async Task<List<Article>> GetPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default)
{
    return await _context.Articles
        .Include(a => a.Category)
        .Include(a => a.Author)
        .Include(a => a.Tags)              // <-- nou
        .OrderByDescending(a => a.PublishedAt)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync(cancellationToken);
}

Atenție: dacă faceți .Include(a => a.Category).Include(a => a.Tags) pe o listă mare, EF generează un singur JOIN cu cartesian explosion — pentru N articole × M tag-uri ies N×M rânduri. La volum mic e ok; la volum mare folosiți AsSplitQuery() (un round-trip per Include).

Pasul 8 — Afișare pe Details.cshtml

Tag-urile au navigare; ViewModel-ul trebuie să le expună. În ViewModels/ArticleViewModel.cs:

public class ArticleViewModel
{
    // ... proprietati existente ...
    public List<string> Tags { get; set; } = new();
}

În controller (ArticlesController.Details), la mapare:

Tags = article.Tags?.Select(t => t.Name).ToList() ?? new()

În Views/Articles/Details.cshtml, adăugați un bloc care afișează tag-urile ca badge-uri:

@if (Model.Tags?.Any() == true)
{
    <div class="mt-3">
        <strong>Etichete:</strong>
        @foreach (var tag in Model.Tags)
        {
            <span class="badge bg-secondary">@tag</span>
        }
    </div>
}

Pasul 9a — Repository + Service pentru Tag (consistență cu arhitectura Lab 6+)

Pentru multi-select avem nevoie de acces la lista de tag-uri din controller. Pentru a păstra arhitectura layered (controller → service → UoW → repository) introdusă în Lab 6, adăugăm un mini-stack pentru Tag (parallel cu Category).

Repositories/ITagRepository.cs

namespace Lab10.Repositories;

using Lab10.Models;

public interface ITagRepository : IRepository<Tag>
{
}

Repositories/TagRepository.cs

using Lab10.Data;
using Lab10.Models;

namespace Lab10.Repositories;

public class TagRepository : Repository<Tag>, ITagRepository
{
    public TagRepository(AppDbContext context) : base(context) { }
}

Update IUnitOfWork:

public interface IUnitOfWork
{
    IArticleRepository ArticleRepository { get; }
    ICategoryRepository CategoryRepository { get; }
    ITagRepository TagRepository { get; }   // <-- nou
    Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

Update UnitOfWork:

private ITagRepository? _tagRepository;
// ...
public ITagRepository TagRepository
    => _tagRepository ??= new TagRepository(_context);

Services/ITagService.cs

namespace Lab10.Services;

using Lab10.Models;

public interface ITagService
{
    Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken = default);
}

Services/TagService.cs

using Lab10.Models;
using Lab10.Repositories;

namespace Lab10.Services;

public class TagService : ITagService
{
    private readonly IUnitOfWork _unitOfWork;
    public TagService(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;

    public async Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken = default)
        => (await _unitOfWork.TagRepository.GetAllAsync(cancellationToken))
            .OrderBy(t => t.Name).ToList();
}

Înregistrare în Program.cs:

builder.Services.AddScoped<ITagService, TagService>();

Pasul 9 — Multi-select pe Create / Edit

Pentru a permite atașarea de tag-uri la creare/editare, ViewModel-ul de scriere primește o listă de ID-uri selectate:

public class CreateArticleViewModel
{
    // ... proprietati existente ...
    public List<int> SelectedTagIds { get; set; } = new();
    public List<Tag> AvailableTags { get; set; } = new();
}

În ArticlesController, injectați ITagService (pe lângă IArticleService/ICategoryService deja existente) și populați AvailableTags în LoadDropdownsAsync (sau direct în acțiunile Create [GET] / Edit [GET]):

private readonly ITagService _tagService;

public ArticlesController(
    IArticleService articleService,
    ICategoryService categoryService,
    ITagService tagService,
    IWebHostEnvironment env)
{
    _articleService = articleService;
    _categoryService = categoryService;
    _tagService = tagService;
    _env = env;
}

private async Task LoadDropdownsAsync(CreateArticleViewModel viewModel, CancellationToken cancellationToken)
{
    var categories = await _categoryService.GetAllAsync(cancellationToken);
    viewModel.Categories = categories
        .Select(c => new SelectListItem { Value = c.Id.ToString(), Text = c.Name })
        .ToList();
    viewModel.AvailableTags = await _tagService.GetAllAsync(cancellationToken);
}

În Views/Articles/Create.cshtml, multi-select clasic:

<div class="mb-3">
    <label class="form-label">Tag-uri</label>
    <select asp-for="SelectedTagIds" class="form-select" multiple
            asp-items="@(new SelectList(Model.AvailableTags, "Id", "Name"))">
    </select>
</div>

În Create [POST], după ce salvați articolul, atașați tag-urile selectate. Folosim repository-ul deja existent pentru a încărca entitățile Tag corespunzătoare ID-urilor primite din formular - alternativă la a expune încă o metodă în service, putem accesa IUnitOfWork printr-un mic refactor sau adăuga GetByIdsAsync(IEnumerable<int>) în ITagService. Pentru simplitate alegem a doua variantă.

Adăugăm în ITagService:

Task<List<Tag>> GetByIdsAsync(IEnumerable<int> ids, CancellationToken cancellationToken = default);

Implementare în TagService:

public async Task<List<Tag>> GetByIdsAsync(IEnumerable<int> ids, CancellationToken cancellationToken = default)
{
    var idSet = ids.ToHashSet();
    var all = await _unitOfWork.TagRepository.GetAllAsync(cancellationToken);
    return all.Where(t => idSet.Contains(t.Id)).ToList();
}

Apoi în controller, după _articleService.AddAsync(article, cancellationToken):

if (viewModel.SelectedTagIds.Any())
{
    var selected = await _tagService.GetByIdsAsync(viewModel.SelectedTagIds, cancellationToken);
    article.Tags = selected;
    await _articleService.UpdateAsync(article, cancellationToken);
}

Edit — același pattern, plus 3 detalii

EditArticleViewModel din starter moștenește CreateArticleViewModel, deci SelectedTagIds și AvailableTags sunt deja disponibile pe Edit. Nu mai e nevoie să le redeclarați. Dar trebuie să interveniți în trei locuri:

1. Edit [GET] în ArticlesController — pre-completați SelectedTagIds cu ID-urile tag-urilor pe care articolul le are deja, ca multi-select-ul să apară cu ele bifate când se deschide pagina:

var viewModel = new EditArticleViewModel
{
    Id = article.Id,
    Title = article.Title,
    // ... celelalte campuri ...
    SelectedTagIds = article.Tags?.Select(t => t.Id).ToList() ?? new()
};

await LoadDropdownsAsync(viewModel, cancellationToken);
return View(viewModel);

2. Edit [POST] — relațiile vechi nu se ștergeu automat când re-asignați. Folosiți Clear() + Add() pe colecția trackată, ca EF să șteargă doar ce dispare și să insereze doar ce e nou:

article.Tags ??= new();
article.Tags.Clear();
if (viewModel.SelectedTagIds.Any())
{
    var selectedTags = await _tagService.GetByIdsAsync(viewModel.SelectedTagIds, cancellationToken);
    foreach (var tag in selectedTags)
        article.Tags.Add(tag);
}

await _articleService.UpdateAsync(article, cancellationToken);

3. Views/Articles/Edit.cshtml — exact aceleași 5 linii de multi-select ca în Create.cshtml (același asp-for="SelectedTagIds", același asp-items din Model.AvailableTags):

<div class="mb-3">
    <label class="form-label">Tag-uri</label>
    <select asp-for="SelectedTagIds" class="form-select" multiple
            asp-items="@(new SelectList(Model.AvailableTags, "Id", "Name"))">
    </select>
</div>

Tag Helper-ul Razor face automat marcarea opțiunilor din SelectedTagIds ca selected.

De ce mini-stack-ul Tag? Pentru a respecta separarea de layere stabilită în Lab 6 - controller-ul nu cunoaște direct AppDbContext. Categoriile au deja ICategoryRepository/ICategoryService; tag-urile primesc același tratament. La proiecte mici unde tag-urile sunt strict lookup, o variantă mai pragmatică ar fi să injectați AppDbContext direct doar pentru această lookup - dar mixează pattern-uri în același controller, motiv pentru care preferăm aici varianta consistentă.


Exercițiul 1 — Many-to-Many end-to-end (1p)

Implementați tot fluxul Pașii 1–9:

  • [ ] Tag și ArticleTag completate corect (Pașii 1–2)
  • [ ] Article are navigarea List<Tag> Tags (Pasul 3)
  • [ ] AppDbContext configurează M2M cu UsingEntity<ArticleTag> (Pasul 4)
  • [ ] Migrația AddTagsAndManyToMany rulează clean — vedeți Tags și ArticleTags în BD (Pasul 5)
  • [ ] Seed cu 5 tag-uri și cel puțin un articol primește tag-uri (Pasul 6)
  • [ ] Repository-ul include Tags în toate cele 4 metode care fac JOIN cu detaliile (Pasul 7)
  • [ ] Details.cshtml afișează badge-uri (Pașii 7–8)
  • [ ] Create are multi-select funcțional — atașarea persistă la SaveChanges (Pasul 9)
  • [ ] Edit are multi-select cu tag-urile existente pre-bifate; salvarea modifică relațiile (Pasul 9, sub-secțiunea Edit)

Validare: porniți aplicația, navigați la /Articles/Details/{id} pentru un articol cu tag-uri seed-uite — vedeți badge-urile. Creați un articol nou cu 2 tag-uri selectate — refresh la Details, badge-urile apar. Editați același articol, deselectați un tag și selectați altul, salvați — Details reflectă noua listă.