using System.Text.Json; using FluentValidation; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Prefab.Handler; using Prefab.Catalog.Domain.Exceptions; using Prefab.Shared; using Microsoft.EntityFrameworkCore; namespace Prefab.Web.Shared; public class ExceptionHandler(RequestDelegate next, ILogger logger, IOptions jsonOptions) { private readonly JsonSerializerOptions _jsonOptions = jsonOptions?.Value?.SerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); public async Task InvokeAsync(HttpContext context) { try { await next(context); } catch (Exception ex) { if (context.Response.HasStarted) { logger.LogWarning("Response already started for {Method} {Path}. Rethrowing exception.", context.Request.Method, context.Request.Path); throw; } await HandleExceptionAsync(context, ex); } } private async Task HandleExceptionAsync(HttpContext context, Exception exception) { var (status, problem) = MapException(context, exception); problem.Status ??= status; problem.Instance ??= context.Request.Path; problem.Extensions["traceId"] = context.TraceIdentifier; if (!problem.Extensions.ContainsKey("method")) { problem.Extensions["method"] = context.Request.Method; } if (context.RequestServices.GetService()?.Current is { } handlerContext) { if (!string.IsNullOrWhiteSpace(handlerContext.CorrelationId)) { problem.Extensions["correlationId"] = handlerContext.CorrelationId; } if (!string.IsNullOrWhiteSpace(handlerContext.RequestId)) { problem.Extensions["requestId"] = handlerContext.RequestId; } } else if (context.Items.TryGetValue(HandlerContextItems.HttpContextKey, out var ctxObj) && ctxObj is HandlerContext storedContext) { if (!string.IsNullOrWhiteSpace(storedContext.CorrelationId)) { problem.Extensions["correlationId"] = storedContext.CorrelationId; } if (!string.IsNullOrWhiteSpace(storedContext.RequestId)) { problem.Extensions["requestId"] = storedContext.RequestId; } } else if (!problem.Extensions.ContainsKey("correlationId")) { problem.Extensions["correlationId"] = context.TraceIdentifier; } if (problem is ValidationProblemDetails validation) { if (!problem.Extensions.ContainsKey("errors")) { problem.Extensions["errors"] = validation.Errors; } if (string.IsNullOrWhiteSpace(problem.Detail)) { var firstError = validation.Errors.Values.SelectMany(values => values) .FirstOrDefault(message => !string.IsNullOrWhiteSpace(message)); if (!string.IsNullOrWhiteSpace(firstError)) { problem.Detail = firstError; } } } context.Response.StatusCode = status; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsync(JsonSerializer.Serialize(problem, _jsonOptions)); } private (int StatusCode, ProblemDetails Problem) MapException(HttpContext context, Exception exception) => exception switch { ValidationException validationException => MapValidationException(validationException), CatalogNotFoundException notFound => MapNotFoundException(notFound), RemoteProblemException remoteException => MapRemoteProblemException(remoteException), DomainException domainException => MapDomainException(domainException), DbUpdateConcurrencyException concurrencyException => MapConcurrencyException(concurrencyException), _ => MapUnexpectedException(exception) }; private static (int StatusCode, ProblemDetails Problem) MapValidationException(ValidationException exception) { var errors = exception.Errors .GroupBy(error => string.IsNullOrWhiteSpace(error.PropertyName) ? string.Empty : error.PropertyName) .ToDictionary( group => group.Key, group => group.Select(error => error.ErrorMessage ?? string.Empty).ToArray()); var details = new ValidationProblemDetails(errors) { Title = "Validation failed.", Type = "https://prefab.dev/problems/validation-error", Status = StatusCodes.Status400BadRequest }; return (details.Status.Value, details); } private static (int StatusCode, ProblemDetails Problem) MapNotFoundException(CatalogNotFoundException exception) { var details = new ProblemDetails { Title = "Resource not found.", Detail = exception.Message, Type = "https://prefab.dev/problems/not-found", Status = StatusCodes.Status404NotFound }; details.Extensions["resource"] = exception.Resource; details.Extensions["identifier"] = exception.Identifier; return (details.Status.Value, details); } private (int StatusCode, ProblemDetails Problem) MapRemoteProblemException(RemoteProblemException exception) { var status = (int)exception.StatusCode; ProblemDetails details; if (!string.IsNullOrWhiteSpace(exception.ResponseBody)) { try { details = JsonSerializer.Deserialize(exception.ResponseBody, new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? new ProblemDetails(); } catch (JsonException) { details = new ProblemDetails { Detail = exception.ResponseBody }; } } else { details = new ProblemDetails(); } details.Status ??= status; details.Title ??= "Remote request failed."; details.Detail ??= "The downstream service returned an error."; details.Type ??= "https://prefab.dev/problems/remote-error"; return (details.Status.Value, details); } private static (int StatusCode, ProblemDetails Problem) MapDomainException(DomainException exception) { var details = new ProblemDetails { Title = "Request could not be processed.", Detail = exception.Message, Type = "https://prefab.dev/problems/domain-error", Status = StatusCodes.Status400BadRequest }; return (details.Status.Value, details); } private (int StatusCode, ProblemDetails Problem) MapConcurrencyException(DbUpdateConcurrencyException exception) { logger.LogWarning(exception, "Concurrency conflict encountered."); var details = new ProblemDetails { Title = "Concurrency conflict.", Detail = "The resource was modified by another process. Refresh and retry your request.", Type = "https://prefab.dev/problems/concurrency-conflict", Status = StatusCodes.Status409Conflict }; return (details.Status.Value, details); } private (int StatusCode, ProblemDetails Problem) MapUnexpectedException(Exception exception) { logger.LogError(exception, "Unhandled exception encountered."); var details = new ProblemDetails { Title = "An unexpected error occurred.", Detail = exception.Message, Type = "https://prefab.dev/problems/unexpected-error", Status = StatusCodes.Status500InternalServerError }; return (details.Status.Value, details); } } public static class ExceptionHandlingExtensions { public static IApplicationBuilder UseExceptionHandling(this IApplicationBuilder app) { return app.UseMiddleware(); } }