10

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

Partea 2 din 4

Parte 2 — Middleware: logging și exception handling

Pasul 10 — Structura unui middleware

Un middleware ASP.NET Core e o clasă cu trei piese:

  1. Un constructor care primește RequestDelegate next (referința către restul pipeline-ului) + alte dependențe injectate
  2. O metodă InvokeAsync(HttpContext context) care primește request-ul curent
  3. Un apel await _next(context) care dă mai departe pipeline-ului

Tot ce scrieți înainte de await _next(context) se execută la request. Tot ce scrieți după se execută la response. Pe orice diagonală, e o oportunitate.

public class FoiMiddleware
{
    private readonly RequestDelegate _next;

    public FoiMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        // PRE: rulat la request
        await _next(context);
        // POST: rulat la response
    }
}

Pasul 11 — LoggingMiddleware cu timing

Scopul: la fiecare request loga method + path + status code + durată.

În Lab10_start/Middleware/LoggingMiddleware.cs aveți scheletul. Completați:

using System.Diagnostics;

namespace Lab10.Middleware;

public class LoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<LoggingMiddleware> _logger;

    public LoggingMiddleware(RequestDelegate next, ILogger<LoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();
        var method = context.Request.Method;
        var path = context.Request.Path;

        _logger.LogInformation("-> {Method} {Path}", method, path);

        await _next(context);

        stopwatch.Stop();
        var statusCode = context.Response.StatusCode;
        _logger.LogInformation("<- {Method} {Path} -> {StatusCode} ({Duration}ms)",
            method, path, statusCode, stopwatch.ElapsedMilliseconds);
    }
}

Câteva detalii:

  • Stopwatch > DateTime.UtcNow pentru durate. Stopwatch folosește contoare de înaltă rezoluție.
  • Structured logging{Method}, {Path}, {StatusCode}, {Duration} sunt placeholder-e nominale, nu format string-uri C#. ILogger le stochează ca proprietăți separate (vedeți efectul în Partea 3).
  • Citim status code-ul DUPĂ await _next — controller-ul nu a rulat încă pe partea „PRE".

Pasul 12 — ExceptionHandlingMiddleware

Scopul: prindem orice excepție care iese din pipeline, logăm și răspundem cu JSON + status code corespunzător.

În Lab10_start/Middleware/ExceptionHandlingMiddleware.cs:

namespace Lab10.Middleware;

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unhandled exception on {Method} {Path}",
                context.Request.Method, context.Request.Path);
            await HandleExceptionAsync(context, ex);
        }
    }

    private static Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var (statusCode, message) = exception switch
        {
            KeyNotFoundException     => (StatusCodes.Status404NotFound,            "Resursa nu a fost gasita."),
            UnauthorizedAccessException => (StatusCodes.Status403Forbidden,        "Acces interzis."),
            ArgumentException        => (StatusCodes.Status400BadRequest,          "Cerere invalida."),
            _                        => (StatusCodes.Status500InternalServerError, "A aparut o eroare interna.")
        };

        context.Response.ContentType = "application/json";
        context.Response.StatusCode = statusCode;

        return context.Response.WriteAsJsonAsync(new
        {
            success = false,
            message
        });
    }
}

switch expression pe tipul excepției e cel mai compact mod de a face mapping-ul. Dacă vreți mai multe tipuri custom (NotFoundException, ValidationException, etc.), adăugați rânduri.

Ce aruncă pipeline-ul - convenții pentru servicii

ExceptionHandlingMiddleware e consumatorul. Dar cine produce excepțiile? Controller-ele MVC standard returnează NotFound() / BadRequest() direct, fără să arunce. Serviciile pot arunca dacă proiectați așa:

  • KeyNotFoundException - resursa cerută cu id specific nu există (middleware mapează la 404)
  • ArgumentException - input invalid (400)
  • UnauthorizedAccessException - acces nepermis (403)
  • orice altceva neașteptat - 500

Exemplu: GetByIdAsync care aruncă în loc să returneze null

// Varianta cu throw - semnatura non-nullable = "garantez ca returnez ceva valid"
public async Task<Article> GetByIdAsync(int id, CancellationToken cancellationToken = default)
{
    var article = await _unitOfWork.ArticleRepository.GetByIdWithDetailsAsync(id, cancellationToken);
    if (article is null)
        throw new KeyNotFoundException($"Articolul cu id={id} nu a fost gasit.");
    return article;
}

Null vs throw - când alegem ce

Situație Abordare recomandată Cine răspunde cu 404
Căutare cu filtru - pot fi 0 rezultate Returnează null / listă goală Controller-ul (explicit NotFound())
Lookup după id cunoscut - trebuie să existe Aruncă KeyNotFoundException Middleware-ul (automat 404 JSON)

În proiectul nostru, controller-ele MVC verifică null și returnează NotFound(). Endpoint-urile de API beneficiază mai mult de throw + middleware, pentru că răspunsul JSON e consistent automat fără cod suplimentar în controller.

Excepții custom

Pentru erori de domeniu specifice, putem defini excepții proprii:

public class ArticleNotFoundException : Exception
{
    public ArticleNotFoundException(int id)
        : base($"Articolul cu id={id} nu a fost gasit.") { }
}

Avantaj: putem face catch (ArticleNotFoundException) fără să prindem orice KeyNotFoundException din sistem (de ex. dintr-un Dictionary care lipsește).

Result Pattern - o alternativă

O alternativă la excepții pentru erori așteptate este Result Pattern: returnezi un obiect Result<T> care poate fi succes sau eroare, fără să arunci.

// pseudocod - nu facem asa in proiectul nostru
Result<Article> result = await service.GetByIdAsync(id);
if (result.IsSuccess)
    return Ok(result.Value);
else
    return NotFound(result.Error);

Avantaje: eroarea e explicită în semnătura metodei, compilatorul te forțează să o tratezi.
Dezavantaje: boilerplate suplimentar, incompatibil cu middleware-ul standard ASP.NET Core de error handling.

Nu îl folosim în proiect - preferăm convențiile standard (null + 404, sau excepție + middleware). Există librării care îl implementează (FluentResults, ErrorOr) dacă îl vreți într-un proiect viitor.

Pasul 13 — Înregistrare în Program.cs

În Lab10_start/Program.cs aveți TODO-ul. Înlocuiți-l cu:

// Custom middleware - ordinea conteaza
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseMiddleware<LoggingMiddleware>();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

Ordinea — de ce ExceptionHandling primul? Pentru că vrea să prindă inclusiv ce se întâmplă în LoggingMiddleware, în UseRouting, în controller-e și în EF Core. Dacă LoggingMiddleware ar fi înainte și ar arunca, ExceptionHandling nu l-ar vedea — pipeline-ul l-ar fi depășit deja.

Pasul 14 — Test rapid: provocăm un 404 și un 500

Cel mai ușor mod să verificați că ExceptionHandlingMiddleware funcționează — un endpoint care aruncă pe purpose. Adăugați temporar într-un controller existent (ex. HomeController):

[HttpGet("/test/notfound")]
public IActionResult TestNotFound()
{
    throw new KeyNotFoundException("Test 404");
}

[HttpGet("/test/boom")]
public IActionResult TestBoom()
{
    throw new InvalidOperationException("Test 500");
}

Porniți aplicația și navigați la /test/notfound — ar trebui să vedeți JSON cu success: false și status 404. La /test/boom, status 500. În consolă apar log-urile din LoggingMiddleware și ExceptionHandlingMiddleware.

După ce ați validat, ștergeți cele două endpoint-uri de test (sau lăsați-le într-un #if DEBUG — alegerea voastră).


Exercițiul 2 — Middleware pipeline complet (1p)

  • [ ] LoggingMiddleware măsoară durata cu Stopwatch și logează method/path/status/durata (Pasul 11)
  • [ ] ExceptionHandlingMiddleware prinde excepții și răspunde cu JSON + status code mapat din tip (Pasul 12)
  • [ ] Ambele înregistrate în Program.cs în ordinea corectă (Pasul 13)
  • [ ] Verificat manual cu cel puțin două scenarii: un request normal (vedeți log-urile pereche în consolă) și un request care aruncă (KeyNotFoundException → 404 JSON) (Pasul 14)

Validare: în consolă trebuie să vedeți log-uri de forma:

-> GET /Articles
<- GET /Articles -> 200 (47ms)

Și pentru request-ul care aruncă:

-> GET /test/notfound
fail: Lab10.Middleware.ExceptionHandlingMiddleware[0]
      Unhandled exception on GET /test/notfound
      System.Collections.Generic.KeyNotFoundException: Test 404
<- GET /test/notfound -> 404 (12ms)