230 lines
8.1 KiB
C#
230 lines
8.1 KiB
C#
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<ExceptionHandler> logger, IOptions<Microsoft.AspNetCore.Http.Json.JsonOptions> 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<IHandlerContextAccessor>()?.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<ProblemDetails>(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<ExceptionHandler>();
|
|
}
|
|
}
|
|
|
|
|