mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-01 17:33:14 +00:00
136 lines
5.4 KiB
C#
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,
|
|
}));
|
|
}
|
|
}
|