using System.Net; using System.Text.Json; using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using ProposalSystem.Application.Common; namespace ProposalSystem.Api.Middleware; public class GlobalExceptionHandler : IMiddleware { private readonly ILogger _logger; public GlobalExceptionHandler(ILogger logger) { _logger = logger; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) { try { await next(context); } catch (Exception ex) { await HandleExceptionAsync(context, ex); } } private static string Sanitize(string? value) => (value ?? "").Replace('\r', '_').Replace('\n', '_'); private static ProblemDetails MakeProblem(int status, string title, string detail, string code) { var problem = new ProblemDetails { Status = status, Title = title, Detail = detail, }; problem.Extensions["code"] = code; // serialized top-level via [JsonExtensionData] return problem; } // Must match the MVC pipeline's serialization (camelCase + string enums): // the 409 currentState embeds a ProposalResponse, and clients schema-validate // it against the same shapes their 200 responses use. private static readonly JsonSerializerOptions CamelCase = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private async Task HandleExceptionAsync(HttpContext context, Exception exception) { // Concurrency conflicts use SHOC's envelopes verbatim (ADR 0004), NOT // ProblemDetails: the guarded path embeds the reloaded currentState for // immediate client refresh; the bare-DbUpdateConcurrencyException shape // is the safety net for any unguarded write. if (exception is ProposalConcurrencyException concurrencyEx) { _logger.LogWarning("Concurrency conflict on {Method} {Path}", Sanitize(context.Request.Method), Sanitize(context.Request.Path)); context.Response.StatusCode = StatusCodes.Status409Conflict; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonSerializer.Serialize(new { message = concurrencyEx.Message, currentState = concurrencyEx.CurrentState, }, CamelCase)); return; } if (exception is DbUpdateConcurrencyException) { _logger.LogWarning("Unguarded concurrency conflict on {Method} {Path}", Sanitize(context.Request.Method), Sanitize(context.Request.Path)); context.Response.StatusCode = StatusCodes.Status409Conflict; context.Response.ContentType = "application/json"; await context.Response.WriteAsync(JsonSerializer.Serialize(new { status = "Conflict", message = "The record was modified by another user. Refresh and retry.", code = 409, }, CamelCase)); return; } // Every response carries a machine-readable "code" extension (SHOC // error-code vocabulary convention) so clients branch on codes, not // on human-readable text. var (statusCode, problemDetails) = exception switch { ValidationException validationEx => ( HttpStatusCode.BadRequest, MakeProblem(400, "Validation Error", string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)), "ValidationFailed") ), BusinessRuleException businessEx => ( HttpStatusCode.UnprocessableEntity, MakeProblem(422, "Business Rule Violation", businessEx.Message, businessEx.Code) ), KeyNotFoundException => ( HttpStatusCode.NotFound, MakeProblem(404, "Not Found", "The requested resource was not found", "NotFound") ), UnauthorizedAccessException => ( HttpStatusCode.Unauthorized, MakeProblem(401, "Unauthorized", "Authentication required", "Unauthorized") ), InvalidOperationException => ( HttpStatusCode.BadRequest, MakeProblem(400, "Invalid Operation", "The requested operation is not valid for the current state", "InvalidStateTransition") ), _ => ( HttpStatusCode.InternalServerError, MakeProblem(500, "Internal Server Error", "An unexpected error occurred", "InternalError") ), }; _logger.LogError(exception, "Exception on {Method} {Path}: {Status}", Sanitize(context.Request.Method), Sanitize(context.Request.Path), (int)statusCode); context.Response.StatusCode = (int)statusCode; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsync( JsonSerializer.Serialize(problemDetails, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, })); } }