diff --git a/README.md b/README.md index 094f530..15b770b 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseCloudEvents(); // when using Dapr app.UseAuthentication(); app.UseAuthorization(); - app.UseErrorHandlerMiddleware(); + app.UseDefaultMiddleware(_options); app.UseDefaultEndpoints(_options); } ``` diff --git a/src/Wemogy.AspNet.Tests/FluentValidation/FluentValidationExceptionHandlerMiddlewareTests.cs b/src/Wemogy.AspNet.Tests/FluentValidation/FluentValidationExceptionHandlerMiddlewareTests.cs new file mode 100644 index 0000000..afa04b5 --- /dev/null +++ b/src/Wemogy.AspNet.Tests/FluentValidation/FluentValidationExceptionHandlerMiddlewareTests.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Wemogy.AspNet.Middlewares; +using Xunit; + +namespace Wemogy.AspNet.Tests.Middlewares; + +public class FluentValidationExceptionHandlerMiddlewareTests +{ + [Fact] + public async Task AssertStatusCodeForFluentValidationExceptionAsync() + { + // Arrange + var context = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider() + }; + var next = new RequestDelegate(_ => throw new ValidationException(new List())); + var middleware = new ErrorExceptionHandlerMiddleware(next); + + // Act + await middleware.InvokeAsync(context); + + // Assert + context.Response.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + } +} diff --git a/src/Wemogy.AspNet.Tests/Middlewares/ErrorExceptionHandlerMiddlewareTests.cs b/src/Wemogy.AspNet.Tests/Middlewares/ErrorExceptionHandlerMiddlewareTests.cs new file mode 100644 index 0000000..fdd2fdb --- /dev/null +++ b/src/Wemogy.AspNet.Tests/Middlewares/ErrorExceptionHandlerMiddlewareTests.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Wemogy.AspNet.Middlewares; +using Wemogy.Core.Errors.Exceptions; +using Xunit; + +namespace Wemogy.AspNet.Tests.Middlewares; + +public class ErrorExceptionHandlerMiddlewareTests +{ + [Theory] + [InlineData(typeof(AuthorizationErrorException), HttpStatusCode.Forbidden)] + [InlineData(typeof(ConflictErrorException), HttpStatusCode.Conflict)] + [InlineData(typeof(FailureErrorException), HttpStatusCode.BadRequest)] + [InlineData(typeof(NotFoundErrorException), HttpStatusCode.NotFound)] + [InlineData(typeof(PreconditionFailedErrorException), HttpStatusCode.PreconditionFailed)] + [InlineData(typeof(UnexpectedErrorException), HttpStatusCode.InternalServerError)] + [InlineData(typeof(ValidationErrorException), HttpStatusCode.BadRequest)] + public async Task AssertStatusCodeForErrorAsync(Type errorExceptionType, HttpStatusCode expectedHttpStatusCode) + { + // Arrange + if (Activator.CreateInstance(errorExceptionType, "Test", "Test description", null) is not ErrorException errorException) + { + throw new Exception($"The type {errorExceptionType} is not a valid ErrorException"); + } + + var context = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider() + }; + var next = new RequestDelegate(_ => throw errorException); + var middleware = new ErrorExceptionHandlerMiddleware(next); + + // Act + await middleware.InvokeAsync(context); + + // Assert + context.Response.StatusCode.Should().Be((int)expectedHttpStatusCode); + } +} diff --git a/src/Wemogy.AspNet.Tests/Middlewares/ErrorHandlerMiddlewareTests.cs b/src/Wemogy.AspNet.Tests/Middlewares/ErrorHandlerMiddlewareTests.cs deleted file mode 100644 index 0da5066..0000000 --- a/src/Wemogy.AspNet.Tests/Middlewares/ErrorHandlerMiddlewareTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using FluentValidation; -using FluentValidation.Results; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.DependencyInjection; -using Wemogy.AspNet.Middlewares; -using Wemogy.Core.Errors.Exceptions; -using Xunit; - -namespace Wemogy.AspNet.Tests.Middlewares; - -public class ErrorHandlerMiddlewareTests -{ - [Theory] - [InlineData( - typeof(AuthorizationErrorException), - HttpStatusCode.Forbidden)] - [InlineData( - typeof(ConflictErrorException), - HttpStatusCode.Conflict)] - [InlineData( - typeof(FailureErrorException), - HttpStatusCode.BadRequest)] - [InlineData( - typeof(NotFoundErrorException), - HttpStatusCode.NotFound)] - [InlineData( - typeof(PreconditionFailedErrorException), - HttpStatusCode.PreconditionFailed)] - [InlineData( - typeof(UnexpectedErrorException), - HttpStatusCode.InternalServerError)] - [InlineData( - typeof(ValidationErrorException), - HttpStatusCode.BadRequest)] - public async Task AssertStatusCodeForErrorAsync( - Type errorExceptionType, - HttpStatusCode expectedHttpStatusCode) - { - // Arrange - if (Activator.CreateInstance(errorExceptionType, "Test", "Test description", null) is not ErrorException errorException) - { - throw new Exception($"The type {errorExceptionType} is not a valid ErrorException"); - } - - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider() - }; - var next = new RequestDelegate(_ => throw errorException); - var middleware = new ErrorHandlerMiddleware(next); - - // Act - await middleware.InvokeAsync( - context); - - // Assert - context.Response.StatusCode.Should().Be((int)expectedHttpStatusCode); - } - - [Fact] - public async Task AssertStatusCodeForFluentValidationExceptionAsync() - { - // Arrange - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider() - }; - var next = new RequestDelegate(_ => throw new ValidationException(new List())); - var middleware = new ErrorHandlerMiddleware(next); - - // Act - await middleware.InvokeAsync( - context); - - // Assert - context.Response.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); - } - - [Fact] - public async Task CanceledRequestShouldBeIgnored() - { - // Arrange - var abortedCts = new CancellationTokenSource(); - var context = new DefaultHttpContext - { - RequestServices = new ServiceCollection() - .AddLogging() - .BuildServiceProvider(), - RequestAborted = abortedCts.Token - }; - var next = new RequestDelegate( - c => - { - // simulate that somewhere in the pipeline we are checking for cancellation - c.RequestAborted.ThrowIfCancellationRequested(); - return Task.CompletedTask; - }); - var middleware = new ErrorHandlerMiddleware(next); - - // Act - abortedCts.Cancel(); // simulate that the request was aborted - var exception = await Record.ExceptionAsync(() => middleware.InvokeAsync(context)); - - // Assert - exception.Should().BeNull(); - } -} diff --git a/src/Wemogy.AspNet.Tests/Middlewares/SystemExceptionHandlerMiddleware.cs b/src/Wemogy.AspNet.Tests/Middlewares/SystemExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..fb6293e --- /dev/null +++ b/src/Wemogy.AspNet.Tests/Middlewares/SystemExceptionHandlerMiddleware.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Wemogy.AspNet.Middlewares; +using Wemogy.Core.Errors.Exceptions; +using Xunit; + +namespace Wemogy.AspNet.Tests.Middlewares; + +public class SystemExceptionHandlerMiddleware +{ + [Fact] + public async Task CanceledRequestShouldBeIgnored() + { + // Arrange + var abortedCts = new CancellationTokenSource(); + var context = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider(), + RequestAborted = abortedCts.Token + }; + var next = new RequestDelegate( + c => + { + // simulate that somewhere in the pipeline we are checking for cancellation + c.RequestAborted.ThrowIfCancellationRequested(); + return Task.CompletedTask; + }); + var middleware = new ErrorExceptionHandlerMiddleware(next); + + // Act + abortedCts.Cancel(); // simulate that the request was aborted + var exception = await Record.ExceptionAsync(() => middleware.InvokeAsync(context)); + + // Assert + exception.Should().BeNull(); + } +} diff --git a/src/Wemogy.AspNet.Tests/Refit/RefitExceptionHandlerMiddlewareTests.cs b/src/Wemogy.AspNet.Tests/Refit/RefitExceptionHandlerMiddlewareTests.cs new file mode 100644 index 0000000..853ff95 --- /dev/null +++ b/src/Wemogy.AspNet.Tests/Refit/RefitExceptionHandlerMiddlewareTests.cs @@ -0,0 +1,42 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Refit; +using Wemogy.AspNet.Refit; +using Xunit; + +namespace Wemogy.AspNet.Tests.Middlewares; + +public class RefitExceptionHandlerMiddlewareTests +{ + [Theory] + [InlineData(HttpStatusCode.NotFound)] + public async Task AssertStatusCodeForErrorAsync(HttpStatusCode expectedHttpStatusCode) + { + // Arrange + var context = new DefaultHttpContext + { + RequestServices = new ServiceCollection() + .AddLogging() + .BuildServiceProvider() + }; + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost"); + var response = new HttpResponseMessage(expectedHttpStatusCode) + { + Content = new StringContent("Bar") + }; + var apiException = await ApiException.Create(message, HttpMethod.Get, response, new RefitSettings()); + var next = new RequestDelegate(_ => throw apiException); + var middleware = new RefitExceptionHandlerMiddleware(next); + + // Act + await middleware.InvokeAsync(context); + + // Assert + context.Response.StatusCode.Should().Be((int)expectedHttpStatusCode); + } +} diff --git a/src/Wemogy.AspNet/FluentValidation/FluentValidationExceptionHandlerMiddleware.cs b/src/Wemogy.AspNet/FluentValidation/FluentValidationExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..340ad0a --- /dev/null +++ b/src/Wemogy.AspNet/FluentValidation/FluentValidationExceptionHandlerMiddleware.cs @@ -0,0 +1,43 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentValidation; +using Microsoft.AspNetCore.Http; + +namespace Wemogy.AspNet.FluentValidation +{ + public class FluentValidationExceptionHandlerMiddleware + { + private readonly RequestDelegate _next; + + public FluentValidationExceptionHandlerMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (ValidationException exception) + { + var response = context.Response; + response.StatusCode = (int)HttpStatusCode.BadRequest; + await response.WriteAsJsonAsync(exception.Errors); + } + + // Catch OperationCanceledException, when the request is aborted + catch (OperationCanceledException) + { + if (context.RequestAborted.IsCancellationRequested) + { + return; + } + + throw; + } + } + } +} diff --git a/src/Wemogy.AspNet/Middlewares/DependencyInjection.cs b/src/Wemogy.AspNet/Middlewares/DependencyInjection.cs deleted file mode 100644 index fb4d3cc..0000000 --- a/src/Wemogy.AspNet/Middlewares/DependencyInjection.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace Wemogy.AspNet.Middlewares -{ - public static class DependencyInjection - { - public static void UseErrorHandlerMiddleware(this IApplicationBuilder applicationBuilder) - { - applicationBuilder.UseMiddleware(); - } - } -} diff --git a/src/Wemogy.AspNet/Middlewares/ErrorHandlerMiddleware.cs b/src/Wemogy.AspNet/Middlewares/ErrorExceptionHandlerMiddleware.cs similarity index 95% rename from src/Wemogy.AspNet/Middlewares/ErrorHandlerMiddleware.cs rename to src/Wemogy.AspNet/Middlewares/ErrorExceptionHandlerMiddleware.cs index b943474..010732f 100644 --- a/src/Wemogy.AspNet/Middlewares/ErrorHandlerMiddleware.cs +++ b/src/Wemogy.AspNet/Middlewares/ErrorExceptionHandlerMiddleware.cs @@ -7,11 +7,11 @@ namespace Wemogy.AspNet.Middlewares { - public class ErrorHandlerMiddleware + public class ErrorExceptionHandlerMiddleware { private readonly RequestDelegate _next; - public ErrorHandlerMiddleware(RequestDelegate next) + public ErrorExceptionHandlerMiddleware(RequestDelegate next) { _next = next; } diff --git a/src/Wemogy.AspNet/Middlewares/SystemExceptionHandlerMiddleware.cs b/src/Wemogy.AspNet/Middlewares/SystemExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..1f6c274 --- /dev/null +++ b/src/Wemogy.AspNet/Middlewares/SystemExceptionHandlerMiddleware.cs @@ -0,0 +1,37 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentValidation; +using Microsoft.AspNetCore.Http; +using Wemogy.Core.Errors.Exceptions; + +namespace Wemogy.AspNet.Middlewares +{ + public class SystemExceptionHandlerMiddleware + { + private readonly RequestDelegate _next; + + public SystemExceptionHandlerMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (OperationCanceledException) + { + // Catch OperationCanceledException, when the request is aborted + if (context.RequestAborted.IsCancellationRequested) + { + return; + } + + throw; + } + } + } +} diff --git a/src/Wemogy.AspNet/Refit/ApiExceptionFilter.cs b/src/Wemogy.AspNet/Refit/ApiExceptionFilter.cs deleted file mode 100644 index 9f79191..0000000 --- a/src/Wemogy.AspNet/Refit/ApiExceptionFilter.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Refit; - -namespace Wemogy.AspNet.Refit -{ - public class ApiExceptionFilter : IActionFilter, IOrderedFilter - { - public int Order { get; set; } = int.MaxValue - 10; - - public void OnActionExecuting(ActionExecutingContext context) - { - } - - public void OnActionExecuted(ActionExecutedContext context) - { - if (context.Exception is ApiException exception) - { - context.Result = new ObjectResult(exception.Content) - { - StatusCode = Convert.ToInt32(exception.StatusCode), - }; - context.ExceptionHandled = true; - } - } - } -} diff --git a/src/Wemogy.AspNet/Refit/RefitExceptionHandlerMiddleware.cs b/src/Wemogy.AspNet/Refit/RefitExceptionHandlerMiddleware.cs new file mode 100644 index 0000000..457a4ff --- /dev/null +++ b/src/Wemogy.AspNet/Refit/RefitExceptionHandlerMiddleware.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Refit; + +namespace Wemogy.AspNet.Refit +{ + public class RefitExceptionHandlerMiddleware + { + private readonly RequestDelegate _next; + + public RefitExceptionHandlerMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (ApiException exception) + { + context.Response.StatusCode = Convert.ToInt32(exception.StatusCode); + context.Response.ContentType = "text/plain; charset=utf-8"; + if (exception.Content != null) + { + await context.Response.WriteAsync(exception.Content); + } + } + } + } +} diff --git a/src/Wemogy.AspNet/Startup/StartupExtensions.cs b/src/Wemogy.AspNet/Startup/StartupExtensions.cs index 9d73236..acb2522 100644 --- a/src/Wemogy.AspNet/Startup/StartupExtensions.cs +++ b/src/Wemogy.AspNet/Startup/StartupExtensions.cs @@ -5,10 +5,12 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Wemogy.AspNet.FluentValidation; using Wemogy.AspNet.Formatters; using Wemogy.AspNet.Json; using Wemogy.AspNet.Middlewares; using Wemogy.AspNet.Monitoring; +using Wemogy.AspNet.Refit; using Wemogy.AspNet.Swagger; using Wemogy.AspNet.Transformers; @@ -118,11 +120,7 @@ public static void UseDefaultSetup(this IApplicationBuilder applicationBuilder, applicationBuilder.UseAuthentication(); applicationBuilder.UseAuthorization(); - applicationBuilder.UseErrorHandlerMiddleware(); - foreach (var middleware in options.Middlewares) - { - applicationBuilder.UseMiddleware(middleware); - } + applicationBuilder.UseDefaultMiddleware(options); applicationBuilder.UseDefaultEndpoints(options); } @@ -137,6 +135,33 @@ public static void UseDefaultCors(this IApplicationBuilder applicationBuilder) applicationBuilder.UseCors(); } + /// + /// Adds the default exception handling middleware from this library + /// and additional middlewares defined in the StartupOptions to the pipeline. + /// + public static void UseDefaultMiddleware(this IApplicationBuilder applicationBuilder, StartupOptions options) + { + // Standard exception handling middleware + applicationBuilder.UseExceptionHandlerMiddleware(); + + // Additional optional middleware + foreach (var middleware in options.Middlewares) + { + applicationBuilder.UseMiddleware(middleware); + } + } + + /// + /// Adds the default exception handling middleware from this library to the pipeline. + /// + public static void UseExceptionHandlerMiddleware(this IApplicationBuilder applicationBuilder) + { + applicationBuilder.UseMiddleware(); + applicationBuilder.UseMiddleware(); + applicationBuilder.UseMiddleware(); + applicationBuilder.UseMiddleware(); + } + public static void UseDefaultEndpoints(this IApplicationBuilder applicationBuilder, StartupOptions options) { applicationBuilder.UseEndpoints(endpoints =>