using ExpenseTracker.Application.Common.Exceptions; using Microsoft.AspNetCore.Mvc; using System.Diagnostics; namespace ExpenseTracker.Api.Middlewares; public class GlobalExceptionHandlerMiddleware : IMiddleware { class ProblemDetailsWithTraceId : ProblemDetails { public string TraceId { get; init; } = Activity.Current?.TraceId.ToString(); } private readonly Dictionary> _exceptionHandlers; private readonly ILogger _logger; public GlobalExceptionHandlerMiddleware(ILogger logger) { // Register known exception types and handlers. _exceptionHandlers = new() { { typeof(ValidationException), HandleValidationException }, { typeof(RegistrationException), HandleRegistrationException }, { typeof(LoginException), HandleLoginException }, { typeof(RenewAccessTokenException), HandleRenewAccessTokenException }, { typeof(RevokeRefreshTokenException), HandleRevokeRefreshTokenException }, { typeof(NotFoundException), HandleNotFoundException }, { typeof(UnAuthorizedException), HandleUnAuthorizedException }, { typeof(ForbiddenException), HandleForbiddenException }, { typeof(CurrencyConverterException), HandleCurrencyConverterException }, }; _logger = logger; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) { try { await next(context); } catch (Exception exception) { var exceptionType = exception.GetType(); _logger.LogInformation( "{@DateUtc} {@TimeUtc} {@TraceId} {@SpanId} Interrupted with {@ExceptionType}.", DateTime.UtcNow.ToString("yyyy-MM-dd"), DateTime.UtcNow.ToString("HH:mm:ss.FFF"), Activity.Current?.TraceId.ToString(), Activity.Current?.SpanId.ToString(), exceptionType); if (_exceptionHandlers.ContainsKey(exceptionType)) { await _exceptionHandlers[exceptionType].Invoke(context, exception); return; } await HandleUnhandledExceptionException(context, exception); } } private async Task HandleValidationException(HttpContext context, Exception exception) { var ex = (ValidationException)exception; context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new HttpValidationProblemDetails(ex.Errors) { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", Title = "One or more validation errors occurred.", Detail = "Provided data doesn't satisfy validation requirements." }); } private async Task HandleRegistrationException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request", Title = "Registration failed.", Detail = "Check your credentials." }); } private async Task HandleLoginException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request", Title = "Login failed.", Detail = "Provided email and/or password are invalid." }); } private async Task HandleRenewAccessTokenException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request", Title = "Access token renewal failed.", Detail = "Check validity of your refresh token." }); } private async Task HandleRevokeRefreshTokenException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status400BadRequest; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status400BadRequest, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request", Title = "Refresh token revocation failed.", Detail = "Check validity of your refresh token." }); } private async Task HandleNotFoundException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status404NotFound; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status404NotFound, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-404-not-found", Title = "One or more resources was not found.", Detail = "Check validity of input data." }); } private async Task HandleUnAuthorizedException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status401Unauthorized, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-401-unauthorized", Title = "Request lacks valid authentication credentials for the target resource.", }); } private async Task HandleForbiddenException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status403Forbidden; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status403Forbidden, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-403-forbidden", Title = "Server refuses to fulfill the request.", }); } private async Task HandleCurrencyConverterException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status500InternalServerError, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-500-internal-server-error", Title = "Unable to communicate with currency exchage api.", Detail = "Use default currency to query items without conversion." }); } private async Task HandleUnhandledExceptionException(HttpContext context, Exception exception) { context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new ProblemDetailsWithTraceId() { Status = StatusCodes.Status500InternalServerError, Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-500-internal-server-error", Title = "One or more internal server errors occured.", Detail = "Report this error to service's support team.", }); _logger.LogError( "{@DateUtc} {@TimeUtc} {@TraceId} {@SpanId} Unhandled exception.\n{@ExceptionMessage}.\n{@ExceptionStackTrace}.", DateTime.UtcNow.ToString("yyyy-MM-dd"), DateTime.UtcNow.ToString("HH:mm:ss.FFF"), Activity.Current?.TraceId.ToString(), Activity.Current?.SpanId.ToString(), exception.Message, exception.StackTrace); } }