using System.Text.Json; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Mvc; namespace SatelliteProvider.Api; public sealed class GlobalExceptionHandler : IExceptionHandler { private readonly ILogger _logger; public GlobalExceptionHandler(ILogger logger) { _logger = logger; } public async ValueTask TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { // Framework-level request-binding/parsing failures carry their own HTTP status // (typically 400/415). Honor that status so we don't promote a client error to 5xx. if (exception is BadHttpRequestException badRequest) { await WriteClientErrorAsync(httpContext, badRequest, cancellationToken); return true; } var correlationId = httpContext.TraceIdentifier; _logger.LogError( exception, "Unhandled exception while processing {Method} {Path} (correlationId={CorrelationId})", httpContext.Request.Method, httpContext.Request.Path, correlationId); httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; var problem = new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "Internal Server Error", Detail = "An unexpected error occurred. Use the correlationId to look up the server log entry.", Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-500-internal-server-error", }; problem.Extensions["correlationId"] = correlationId; await httpContext.Response.WriteAsJsonAsync( problem, options: null, contentType: "application/problem+json", cancellationToken: cancellationToken); return true; } private static async Task WriteClientErrorAsync( HttpContext httpContext, BadHttpRequestException badRequest, CancellationToken cancellationToken) { httpContext.Response.StatusCode = badRequest.StatusCode; // AZ-795: deserialization failures (unknown field via UnmappedMemberHandling.Disallow, // type mismatch, malformed JSON) surface here as BadHttpRequestException with a // System.Text.Json `JsonException` somewhere in the inner-exception chain. Convert // them to RFC 7807 ValidationProblemDetails so wire-format errors share the same // shape as FluentValidation business-rule errors — see // `_docs/02_document/contracts/api/error-shape.md`. var deserializationErrors = TryExtractDeserializationErrors(badRequest); if (deserializationErrors is not null && badRequest.StatusCode == StatusCodes.Status400BadRequest) { var validation = new ValidationProblemDetails(deserializationErrors) { Status = badRequest.StatusCode, Title = "One or more validation errors occurred.", Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1", }; await httpContext.Response.WriteAsJsonAsync( validation, options: null, contentType: "application/problem+json", cancellationToken: cancellationToken); return; } var problem = new ProblemDetails { Status = badRequest.StatusCode, Title = "Bad Request", Detail = badRequest.Message, }; await httpContext.Response.WriteAsJsonAsync( problem, options: null, contentType: "application/problem+json", cancellationToken: cancellationToken); } private static IDictionary? TryExtractDeserializationErrors(BadHttpRequestException ex) { var current = ex.InnerException; while (current is not null) { if (current is JsonException jsonEx) { var path = NormalizeJsonPath(jsonEx.Path); var message = string.IsNullOrEmpty(jsonEx.Message) ? "Invalid JSON." : jsonEx.Message; return new Dictionary { [path] = new[] { message } }; } current = current.InnerException; } return null; } private static string NormalizeJsonPath(string? path) { if (string.IsNullOrEmpty(path)) return "$"; return path.StartsWith("$.", StringComparison.Ordinal) ? path.Substring(2) : path; } }