proposal-system/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs

136 lines
5.4 KiB
C#

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<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> 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,
}));
}
}