Files
satellite-provider/SatelliteProvider.Api/GlobalExceptionHandler.cs
T

134 lines
4.8 KiB
C#

using System.Text.Json;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace SatelliteProvider.Api;
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private const string JsonFieldErrorMessage = "The field value is invalid.";
private const string BadRequestDetailMessage = "The request could not be processed.";
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> 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 = BadRequestDetailMessage,
};
await httpContext.Response.WriteAsJsonAsync(
problem,
options: null,
contentType: "application/problem+json",
cancellationToken: cancellationToken);
}
private static IDictionary<string, string[]>? TryExtractDeserializationErrors(BadHttpRequestException ex)
{
var current = ex.InnerException;
while (current is not null)
{
if (current is JsonException jsonEx)
{
var path = NormalizeJsonPath(jsonEx.Path);
return new Dictionary<string, string[]>
{
[path] = new[] { JsonFieldErrorMessage }
};
}
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;
}
}