08

Lab 08 - Web API, DTOs, Swagger si JWT Authentication

Partea 1 din 6

CTI — Dezvoltarea Aplicațiilor Web — Laborator 8

Web API, DTOs, Swagger și JWT Authentication


Obiective

Laboratorul continuă proiectul News Portal din Lab 7. La finalul acestui laborator, aplicația va expune endpoints REST protejate cu JWT, paralel cu partea MVC bazată pe cookies.

Obiectivele laboratorului:

  • Diferența între un MVC Controller și un API Controller (Controller vs ControllerBase)
  • DTOs (Data Transfer Objects) pentru separarea contractului API de modelul EF
  • Maparea entitate ⇄ DTO prin extension methods organizate într-o clasă dedicată
  • Records din C# 9 — tipuri imutabile, perfecte pentru DTO-uri
  • CRUD complet pe API cu status codes corecte: 200, 201, 204, 400, 401, 403, 404
  • Swagger/OpenAPI pentru documentarea și testarea endpoint-urilor
  • JWT Authentication — generare, semnare, validare
  • Dual authentication scheme — cookie pentru MVC, JWT pentru API
  • Testare cu Swagger UI, Postman și decodare cu jwt.io
  • Pattern-uri async avansate: Task.WhenAll, IHostedService (introducere)

Punctaj (4p)

Lab-ul e împărțit în 4 exerciții de câte 1p. Exercițiile sunt checkpoints peste parcurs — Ex 1–3 se livrează prin parcurgerea walkthrough-ului, Ex 4 (paginare) e peste lab. Definițiile complete și criteriile de „merge / nu merge" sunt la finalul Părții 6.

Recapitulare Lab 7

Din laboratorul anterior avem:

  • ApplicationUser : IdentityUser cu FullName
  • Article cu AuthorId + Author (navigation)
  • ArticlesController MVC cu [Authorize] pe Create, Edit, Delete
  • AuthController MVC cu Register / Login / Logout pe bază de cookies
  • Roluri Admin și User, seed-uite în SeedData.InitializeAsync
  • IArticleService / ArticleService cu metode async (GetAllAsync, GetByIdAsync, AddAsync, UpdateAsync, DeleteAsync, CountAsync, GetPagedAsync)
  • Content ownership — doar autorul sau adminul poate modifica un articol (IsOwnerOrAdmin)

Lab 8 adaugă — nu modifică ce există deja. MVC continuă să folosească cookies, API-ul nou folosește JWT.


MVC vs Web API — modelul mental

Aspect MVC Controller API Controller
Clasa de bază Controller ControllerBase
Returnează View() → HTML Ok(), NotFound() → JSON
Client Browser JavaScript, mobile, alt backend
Autentificare Cookie (.AspNetCore.Identity.Application) JWT în header Authorization: Bearer ...
State Session + cookies Stateless — token-ul conține claim-urile
CSRF [ValidateAntiForgeryToken] Nu este nevoie — nu există cookie auto-trimis

De ce ControllerBase și nu Controller? Controller adaugă suport pentru Views — ViewData, ViewBag, View(). Pentru API nu avem nevoie de așa ceva. ControllerBase este versiunea „lightweight", gândită pentru răspunsuri JSON.

De ce avem nevoie de API dacă avem MVC?

Browserul nu e singurul client. O aplicație Angular (Lab 11–12), o aplicație mobilă sau un script extern nu pot consuma HTML. Au nevoie de JSON structurat și de un mecanism de autentificare care să nu depindă de cookies. De aici — JWT.

Parte 1 — DTOs, Records și Mappings

Pasul 1 — NuGet packages

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.25
dotnet add package Swashbuckle.AspNetCore --version 6.9.0

Swashbuckle.AspNetCore conține AddSwaggerGen, UseSwagger, UseSwaggerUI. Microsoft.AspNetCore.Authentication.JwtBearer conține handler-ul pentru validarea token-urilor JWT. Generarea JWT folosește System.IdentityModel.Tokens.Jwt, care vine tranzitiv cu pachetul de mai sus.

De ce pin pe versiuni: Microsoft.AspNetCore.Authentication.JwtBearer trebuie să se potrivească cu versiunea .NET-ului (8.0.25 pe .NET 8). Pentru Swashbuckle.AspNetCore, seria 6.x folosește API-ul OpenApiInfo / OpenApiReference folosit mai jos. Versiunile 10.x+ au rescris API-ul (Microsoft.OpenApi fără .Models, referințe prin tipuri dedicate) și NU sunt compatibile cu codul din acest lab.


Pasul 2 — DTOs (Data Transfer Objects)

De ce DTO și nu direct entitatea Article?

Tentația este să returnăm Article direct din API. Există trei probleme majore:

  1. Circular referencesArticle.Author.Articles.Author.Articles... — serializatorul JSON intră în buclă infinită.
  2. Expunere date interneArticle are PasswordHash (via Author), SecurityStamp, etc. Nu vrem să le trimitem în rețea.
  3. Cuplare BD ↔ API — dacă redenumiți o coloană în BD, contractul API se sparge silent. DTO-ul e un contract stabil.

Soluția: creăm o clasă separată, cu exact câmpurile pe care vrem să le expunem.

DTOs/ArticleDto.cs — fișier nou

namespace Lab08.DTOs;

public record ArticleDto(
    int Id,
    string Title,
    string Content,
    DateTime PublishedAt,
    string CategoryName,
    string AuthorName);

DTOs/CreateArticleDto.cs — fișier nou

using System.ComponentModel.DataAnnotations;

namespace Lab08.DTOs;

public record CreateArticleDto(
    [Required, MinLength(5)] string Title,
    [Required, MinLength(20)] string Content,
    [Required] int CategoryId);

DTOs/UpdateArticleDto.cs — fișier nou

using System.ComponentModel.DataAnnotations;

namespace Lab08.DTOs;

public record UpdateArticleDto(
    [Required, MinLength(5)] string Title,
    [Required, MinLength(20)] string Content,
    [Required] int CategoryId);

DTOs/LoginDto.cs — fișier nou

using System.ComponentModel.DataAnnotations;

namespace Lab08.DTOs;

public record LoginDto(
    [Required, EmailAddress] string Email,
    [Required] string Password);

Scurtă digresiune — record vs class

C# 9 a introdus tipul record. Un record este tot o clasă (reference type), dar cu câteva calități care îl fac ideal pentru DTO-uri:

Proprietate class record
Egalitate Pe referință (ReferenceEquals) Pe valoare (câmp cu câmp)
ToString() Lab08.DTOs.ArticleDto ArticleDto { Id = 1, Title = "...", ... }
Imutabilitate Opțional Implicit — proprietățile sunt init-only
Sintaxă scurtă Nu Da — constructor pozițional
Copiere Manual with expression — article with { Title = "Nou" }

Sintaxa pozițională pe care o vedeți mai sus:

public record ArticleDto(int Id, string Title, ...);

e echivalentă cu:

public class ArticleDto
{
    public int Id { get; init; }
    public string Title { get; init; }
    // + constructor + Equals + GetHashCode + ToString + Deconstruct
}

Compilatorul generează automat constructor, proprietăți init-only, Equals bazat pe valoare, GetHashCode și ToString. De aici — DTO-urile pot fi scrise pe o singură linie.

De ce record pentru DTO? Un DTO e, conceptual, o valoare — nu o entitate cu identitate. Două ArticleDto cu aceleași câmpuri sunt același lucru. Imutabilitatea previne modificarea accidentală între controller și serializator.

De ce nu și pentru entitățile EF? EF Core are nevoie de setter-uri și de proxy-uri pentru change tracking. Entitățile rămân clase normale cu { get; set; }.

Expression with — util mai târziu, când vreți să actualizați parțial un DTO:

var updated = article with { Title = "Titlu nou" };
// `article` rămâne neatins — `updated` e o copie cu Title modificat.

Pasul 3 — Extension method mappings

Maparea Article → ArticleDto și CreateArticleDto → Article se face manual — nu folosim AutoMapper.

De ce nu AutoMapper? AutoMapper ascunde maparea în convenții pe nume. Când greșiți un câmp, eroarea apare la runtime, nu la compilare. Acum, în faza de învățare, trebuie să vedeți exact ce se mapează. În plus, maparea manuală vă obligă să fiți conștienți de ce expuneți în afară.

Mappings/ArticleMappings.cs — fișier nou

using Lab08.DTOs;
using Lab08.Models;

namespace Lab08.Mappings;

public static class ArticleMappings
{
    public static ArticleDto ToDto(this Article article) => new(
        Id: article.Id,
        Title: article.Title,
        Content: article.Content,
        PublishedAt: article.PublishedAt,
        CategoryName: article.Category?.Name ?? "N/A",
        AuthorName: article.Author?.FullName ?? "N/A");

    public static List<ArticleDto> ToDtoList(this IEnumerable<Article> articles)
        => articles.Select(a => a.ToDto()).ToList();

    public static Article ToEntity(this CreateArticleDto dto) => new()
    {
        Title = dto.Title,
        Content = dto.Content,
        CategoryId = dto.CategoryId
    };

    public static void ApplyTo(this UpdateArticleDto dto, Article article)
    {
        article.Title = dto.Title;
        article.Content = dto.Content;
        article.CategoryId = dto.CategoryId;
    }
}

Observați că fiecare metodă e static și are ca prim parametru this Article article / this IEnumerable<Article> articles / this CreateArticleDto dto. Acesta e pattern-ul extension method pe care l-ați văzut în Lab 1. Acum îl folosim cu un scop real — apelul article.ToDto() se citește ca o metodă de instanță, dar de fapt e metodă statică.

De ce într-o clasă dedicată:

  • Toate regulile de mapare sunt în un singur loc.
  • Dacă se schimbă schema Article, se actualizează ArticleMappings — nu umblăm prin toate controller-ele.
  • Pattern-ul din Lab 1 (extension methods) primește o aplicație reală, nu un exemplu didactic.