diff --git a/.gitignore b/.gitignore index 9491a2f..8e2c0e2 100644 --- a/.gitignore +++ b/.gitignore @@ -205,6 +205,7 @@ PublishScripts/ # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets +**/nuget.config # Microsoft Azure Build Output csx/ diff --git a/ExceptionAll.APIExample/Controllers/WeatherForecastController.cs b/ExceptionAll.APIExample/Controllers/WeatherForecastController.cs index 3b90d06..25ee6bc 100644 --- a/ExceptionAll.APIExample/Controllers/WeatherForecastController.cs +++ b/ExceptionAll.APIExample/Controllers/WeatherForecastController.cs @@ -1,83 +1,100 @@ using ExceptionAll.Details; using ExceptionAll.Interfaces; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.Linq; +using System.Net; using System.Threading.Tasks; +using ExceptionAll.Models; +using Microsoft.AspNetCore.Authorization; -namespace ExceptionAll.APIExample.Controllers +namespace ExceptionAll.APIExample.Controllers; + +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase { - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase + private static readonly string[] Summaries = new[] { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; - private readonly ILogger _logger; - private readonly IActionResultService _actionResultService; + private readonly ILogger _logger; + private readonly IActionResultService _actionResultService; - public WeatherForecastController(ILogger logger, - IActionResultService actionResultService) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _actionResultService = actionResultService ?? throw new ArgumentNullException(nameof(actionResultService)); - } + public WeatherForecastController(ILogger logger, + IActionResultService actionResultService) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _actionResultService = actionResultService ?? throw new ArgumentNullException(nameof(actionResultService)); + } - [HttpGet] - public async Task GetAll() + [HttpGet] + [ProducesResponseType(typeof(ApiErrorDetails), (int)HttpStatusCode.InternalServerError)] + public async Task GetAll() + { + await Task.Delay(0); + var rng = new Random(); + var result = Enumerable.Range(1, 5).Select(index => new WeatherForecast { - await Task.Delay(0); - var rng = new Random(); - var result = Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateTime.Now.AddDays(index), - TemperatureC = rng.Next(-20, 55), - Summary = Summaries[rng.Next(Summaries.Length)] - }) - .ToArray(); - throw new Exception("This is simulating an uncaught exception"); - } + Date = DateTime.Now.AddDays(index), + TemperatureC = rng.Next(-20, 55), + Summary = Summaries[rng.Next(Summaries.Length)] + }) + .ToArray(); + throw new Exception("This is simulating an uncaught exception"); + } - [HttpGet] - [Route("api/GetNullRefError")] - public async Task GetNullRefError(string param) + [HttpGet] + [Route("api/GetNullRefError")] + public async Task GetNullRefError(string param, string otherParam) + { + param = null; + await Task.Delay(0); + throw new ArgumentNullException(nameof(param)); + } + + // If the developer doesn't want to use the template in all instances, + // wrapping the code in try catch will let you use your own logic + [HttpGet] + [Route("api/GetWithoutExceptionAllError")] + public async Task GetWithoutExceptionAllError() + { + await Task.Delay(0); + try { - param = null; - await Task.Delay(0); - throw new ArgumentNullException(nameof(param)); + throw new Exception("Some exception"); } - - // If the developer doesn't want to use the template in all instances, - // wrapping the code in try catch will let you use your own logic - [HttpGet] - [Route("api/GetWithoutExceptionAllError")] - public async Task GetWithoutExceptionAllError() + catch (Exception e) { - await Task.Delay(0); - try - { - throw new Exception("Some exception"); - } - catch (Exception e) - { - Console.WriteLine(e); - return BadRequest(e.Message); - } + Console.WriteLine(e); + return BadRequest(e.Message); } + } - // If the developer needs to manually error handle, they can call - // the 'GetResponse' manually - [HttpGet] - [Route("api/GetSomething")] - public async Task GetSomethingWithQuery([FromQuery]string test) + // If the developer needs to manually error handle, they can call + // the 'GetResponse' manually + [HttpGet] + [Route("api/GetSomething")] + [ProducesResponseType(typeof(BadRequestDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(NotFoundDetails), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(InternalServerErrorDetails), StatusCodes.Status500InternalServerError)] + public async Task GetSomethingWithQuery([FromQuery] string test) + { + await Task.Delay(0); + + var errors = new List { - await Task.Delay(0); - return _actionResultService.GetResponse(ControllerContext, - $"No item exists with name of {test}"); - } + new("Error #1", "Something wrong happened here"), + new("Error #2", "Something wrong happened there") + }; + + return _actionResultService.GetResponse( + ControllerContext, + $"No item exists with name of {test}", + errors); } } \ No newline at end of file diff --git a/ExceptionAll.APIExample/ExceptionAll.APIExample.csproj b/ExceptionAll.APIExample/ExceptionAll.APIExample.csproj index d31783c..367b673 100644 --- a/ExceptionAll.APIExample/ExceptionAll.APIExample.csproj +++ b/ExceptionAll.APIExample/ExceptionAll.APIExample.csproj @@ -2,12 +2,15 @@ net6.0 + enable - - - + + + + + diff --git a/ExceptionAll.APIExample/ExceptionAllConfiguration.cs b/ExceptionAll.APIExample/ExceptionAllConfiguration.cs index ca31ea5..2d3cc9a 100644 --- a/ExceptionAll.APIExample/ExceptionAllConfiguration.cs +++ b/ExceptionAll.APIExample/ExceptionAllConfiguration.cs @@ -1,33 +1,36 @@ -using ExceptionAll.Details; -using ExceptionAll.Dtos; -using ExceptionAll.Interfaces; +using ExceptionAll.Interfaces; +using ExceptionAll.Models; using FluentValidation; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; -namespace ExceptionAll.APIExample +namespace ExceptionAll.APIExample; + +public class ExceptionAllConfiguration : IExceptionAllConfiguration { - public static class ExceptionAllConfiguration + public List ErrorResponses => new() { - public static List GetErrorResponses() - { - return new List() - { - ErrorResponse - .CreateErrorResponse() - .WithTitle("Bad Request - Fluent Validation") - .ForException() - .WithReturnType() - .WithLogAction((x, e) => x.LogError(e, "Something bad happened")), + ErrorResponse + .CreateErrorResponse() + .WithTitle("Argument Null Exception") + .WithStatusCode(500) + .WithMessage("The developer goofed") + .ForException() + .WithLogAction((x, e) => x.LogDebug(e, "Oops I did it again")) + }; - ErrorResponse - .CreateErrorResponse() - .WithTitle("Bad Request") - .ForException() - .WithReturnType() - .WithLogAction((x, e) => x.LogError(e, "Something bad happened")) - }; + public Dictionary>? ContextConfiguration => new() + { + { "Path", x => x?.Request.Path.Value ?? string.Empty }, + { "Query", x => x.Request.QueryString.Value ?? string.Empty }, + { "TraceIdentifier", x => x?.TraceIdentifier ?? string.Empty }, + { "LocalIpAddress", x => x?.Connection.LocalIpAddress?.ToString() ?? string.Empty }, + { + "CorrelationId", + x => x.Request.Headers["x-correlation-id"] + .ToString() } - } -} + }; +} \ No newline at end of file diff --git a/ExceptionAll.APIExample/Program.cs b/ExceptionAll.APIExample/Program.cs index 6e3cf8d..0dfdd80 100644 --- a/ExceptionAll.APIExample/Program.cs +++ b/ExceptionAll.APIExample/Program.cs @@ -1,26 +1,55 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace ExceptionAll.APIExample -{ - public class Program +using ExceptionAll.APIExample; +using ExceptionAll.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.Filters; +using System.IO; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddExceptionAll() + .WithExceptionAllSwaggerExamples(); + +builder.Services.AddControllers(); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddSwaggerGen( + c => { - public static void Main(string[] args) + c.SwaggerDoc("v1", new OpenApiInfo { Title = "ExceptionAll.APIExample", Version = "v1" }); + c.EnableAnnotations(); + c.ExampleFilters(); + + if (File.Exists("/ExceptionAll-swagger.xml")) { - CreateHostBuilder(args).Build().Run(); + c.IncludeXmlComments("/ExceptionAll-swagger.xml"); } + }); + +var app = builder.Build(); + +app.Services.AddExceptionAll(); + +// Configure the HTTP request pipeline. + +app.UseSwagger(); + +app.UseSwaggerUI( + c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "ExceptionAll.APIExample v1"); + c.DisplayRequestDuration(); + c.EnableTryItOutByDefault(); + }); + +app.UseHttpsRedirection(); + +app.UseAuthorization(); + +app.MapControllers(); - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } -} +app.Run(); \ No newline at end of file diff --git a/ExceptionAll.APIExample/Startup.cs b/ExceptionAll.APIExample/Startup.cs deleted file mode 100644 index 51c52f6..0000000 --- a/ExceptionAll.APIExample/Startup.cs +++ /dev/null @@ -1,61 +0,0 @@ -using ExceptionAll.Helpers; -using ExceptionAll.Interfaces; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi.Models; -using System.Text.Json.Serialization; - -namespace ExceptionAll.APIExample -{ - public class Startup - { - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public IConfiguration Configuration { get; } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services - .AddExceptionAll() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; - }); - - services.AddSwaggerGen(c => - { - c.SwaggerDoc("v1", new OpenApiInfo { Title = "ExceptionAll.APIExample", Version = "v1" }); - }); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, - IErrorResponseService errorResponseService) - { - errorResponseService.AddErrorResponses(ExceptionAllConfiguration.GetErrorResponses()); - - // Adds CorrelationId to incoming requests for tracking. Optional - app.UseCorrelationIdMiddleware(); - - app.UseDeveloperExceptionPage(); - app.UseSwagger(); - app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "ExceptionAll.APIExample v1")); - - app.UseHttpsRedirection(); - - app.UseRouting(); - - app.UseAuthorization(); - - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - }); - } - } -} \ No newline at end of file diff --git a/ExceptionAll.Tests.EndToEnd/ExceptionAll.Tests.EndToEnd.csproj b/ExceptionAll.Tests.EndToEnd/ExceptionAll.Tests.EndToEnd.csproj new file mode 100644 index 0000000..9f77f5f --- /dev/null +++ b/ExceptionAll.Tests.EndToEnd/ExceptionAll.Tests.EndToEnd.csproj @@ -0,0 +1,23 @@ + + + + net6.0 + enable + + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/ExceptionAll.Tests.EndToEnd/UnitTest1.cs b/ExceptionAll.Tests.EndToEnd/UnitTest1.cs new file mode 100644 index 0000000..5dabf31 --- /dev/null +++ b/ExceptionAll.Tests.EndToEnd/UnitTest1.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace ExceptionAll.Tests.EndToEnd +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} \ No newline at end of file diff --git a/ExceptionAll.Tests.Unit/ExceptionAll.Tests.Unit.csproj b/ExceptionAll.Tests.Unit/ExceptionAll.Tests.Unit.csproj new file mode 100644 index 0000000..207ddb4 --- /dev/null +++ b/ExceptionAll.Tests.Unit/ExceptionAll.Tests.Unit.csproj @@ -0,0 +1,33 @@ + + + + net6.0 + enable + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/ExceptionAll.Tests.Unit/Filters/ExceptionFilterTests.cs b/ExceptionAll.Tests.Unit/Filters/ExceptionFilterTests.cs new file mode 100644 index 0000000..29cda2a --- /dev/null +++ b/ExceptionAll.Tests.Unit/Filters/ExceptionFilterTests.cs @@ -0,0 +1,53 @@ +using ExceptionAll.Filters; +using Xunit.Sdk; + +namespace ExceptionAll.Tests.Unit.Filters +{ + public class ExceptionFilterTests + { + private readonly ITestOutputHelper _testOutputHelper; + + public ExceptionFilterTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public void NewExceptionFilter_OnConstruction_SuccessfullyInitializes() + { + // Arrange + var mockActionResultService = Substitute.For(); + + // Act + var exceptionFilter = new ExceptionFilter(mockActionResultService); + + // Assess + _testOutputHelper.WriteLine("Expected exception filter object"); + _testOutputHelper.WriteLine(exceptionFilter.ToJson()); + + // Assert + Assert.NotNull(exceptionFilter); + + } + + [Fact] + public void OnException_WithExceptionContext_SuccessfullyRuns() + { + // Arrange + var mockActionResultService = Substitute.For(); + var exceptionFilter = new ExceptionFilter(mockActionResultService); + var mockExceptionContext = TestHelper.GetMockExceptionContext(); + + // Act + var action = new Action(() => exceptionFilter.OnException(mockExceptionContext.Object)); + var exception = Record.Exception(action); + + // Assess + _testOutputHelper.WriteLine("Expected exception: null"); + _testOutputHelper.WriteLine($"Actual exception: {exception?.Message ?? null}"); + + // Act + Assert.Null(exception); + } + } +} diff --git a/ExceptionAll.Tests.Unit/GlobalUsings.cs b/ExceptionAll.Tests.Unit/GlobalUsings.cs new file mode 100644 index 0000000..3f56036 --- /dev/null +++ b/ExceptionAll.Tests.Unit/GlobalUsings.cs @@ -0,0 +1,18 @@ +global using Xunit.Abstractions; +global using Xunit; +global using NSubstitute; +global using ExceptionAll.Interfaces; +global using ExceptionAll.Models; +global using ExceptionAll.Services; +global using FluentValidation; +global using Microsoft.Extensions.Logging; +global using System.Collections.Generic; +global using ExceptionAll.Details; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Mvc.Abstractions; +global using Microsoft.AspNetCore.Mvc.Filters; +global using Microsoft.AspNetCore.Routing; +global using Moq; +global using Newtonsoft.Json; +global using System; \ No newline at end of file diff --git a/ExceptionAll.Tests.Unit/Services/ErrorResponseServiceTests.cs b/ExceptionAll.Tests.Unit/Services/ErrorResponseServiceTests.cs new file mode 100644 index 0000000..3df5cb2 --- /dev/null +++ b/ExceptionAll.Tests.Unit/Services/ErrorResponseServiceTests.cs @@ -0,0 +1,101 @@ +namespace ExceptionAll.Tests.Unit.Services; + +public class ErrorResponseServiceTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public ErrorResponseServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Theory, MemberData(nameof(GetValidErrorResponses))] + public void AddErrorResponse_ShouldSuccessfullyAdd_WhenResponseDoesNotExistInContainerYet(ErrorResponse response) + { + // Arrange + var errorResponseLogger = Substitute.For>(); + var sut = new ErrorResponseService(errorResponseLogger); + + // Act + sut.AddErrorResponse(response); + + // Assess + _testOutputHelper.WriteLine($"Error Response: {response.ToJson()}"); + + // Act + Assert.NotNull(sut.GetErrorResponses()); + Assert.NotEmpty(sut.GetErrorResponses()); + } + + [Fact] + public void AddErrorResponse_WithExistingResponse_ShouldThrow() + { + // Arrange + var errorResponseLogger = Substitute.For>(); + var sut = new ErrorResponseService(errorResponseLogger); + + var response = ErrorResponse.CreateErrorResponse() + .WithTitle("Test") + .WithStatusCode(400) + .WithMessage("Model error") + .ForException() + .WithLogAction((x, e) => x.LogInformation(e, "There was a model error")); + sut.AddErrorResponse(response); + + // Act + var exception = Record.Exception(() => sut.AddErrorResponse(response)); + + // Assess + _testOutputHelper.WriteLine($"Error Response: {response.ToJson()}"); + _testOutputHelper.WriteLine($"Exception: {exception.Message}"); + + // Act + Assert.NotNull(exception); + } + + public static IEnumerable GetValidErrorResponses() + { + return new List + { + new object[] + { + ErrorResponse.CreateErrorResponse() + .WithTitle("Test") + .WithStatusCode(400) + .WithMessage("Model error") + .ForException() + .WithLogAction((x, e) => x.LogInformation(e, "There was a model error")) + }, + new object[] + { + ErrorResponse.CreateErrorResponse() + .WithTitle("Test") + .WithStatusCode(400) + .WithMessage("Model error") + .ForException() + }, + new object[] + { + ErrorResponse.CreateErrorResponse() + .WithTitle("Test") + .WithStatusCode(400) + .WithMessage("Model error") + }, + new object[] + { + ErrorResponse.CreateErrorResponse() + .WithTitle("Test") + .WithStatusCode(400) + }, + new object[] + { + ErrorResponse.CreateErrorResponse() + .WithTitle("Test") + }, + new object[] + { + ErrorResponse.CreateErrorResponse() + }, + }; + } +} \ No newline at end of file diff --git a/ExceptionAll.Tests.Unit/TestHelper.cs b/ExceptionAll.Tests.Unit/TestHelper.cs new file mode 100644 index 0000000..bf1f4b4 --- /dev/null +++ b/ExceptionAll.Tests.Unit/TestHelper.cs @@ -0,0 +1,65 @@ +namespace ExceptionAll.Tests.Unit; + +public static class TestHelper +{ + public static string ToJson(this object obj) + { + return JsonConvert.SerializeObject(obj, + Formatting.None, + new JsonSerializerSettings() + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + Formatting = Formatting.Indented + }); + } + + public static Mock GetMockActionContext(HttpContext? context = null) + { + var mockActionContext = new Mock( + context ?? GetMockHttpContext(), + new RouteData(), + new ActionDescriptor()); + return mockActionContext; + } + + public static Mock GetMockException() where T : Exception + { + var mockException = new Mock(); + mockException.Setup(x => x.StackTrace) + .Returns("Test Stacktrace"); + mockException.Setup(x => x.Message) + .Returns("Test message"); + mockException.Setup(x => x.Source) + .Returns("Test source"); + + return mockException; + } + + public static Mock GetMockExceptionContext(HttpContext? context = null, T? exception = null) + where T : Exception + { + var mockExceptionContext = new Mock( + GetMockActionContext(context).Object, + new List()); + + mockExceptionContext + .Setup(x => x.Exception) + .Returns(exception ?? GetMockException().Object); + + return mockExceptionContext; + } + + public static DefaultHttpContext GetMockHttpContext() + { + var context = new DefaultHttpContext(); + context.Request.Headers.Add("x-correlation-id", Guid.NewGuid().ToString()); + context.TraceIdentifier = Guid.NewGuid().ToString(); + return context; + } + + public static IActionResultService GetMockActionResultService(IErrorResponseService? service = null) + { + var mockErrorResponseService = Substitute.For(); + return Substitute.For(); + } +} \ No newline at end of file diff --git a/ExceptionAll.Tests/Details/BadRequestDetailsTests.cs b/ExceptionAll.Tests/Details/BadRequestDetailsTests.cs index 10fe49a..91025c9 100644 --- a/ExceptionAll.Tests/Details/BadRequestDetailsTests.cs +++ b/ExceptionAll.Tests/Details/BadRequestDetailsTests.cs @@ -1,7 +1,7 @@ using System; using ExceptionAll.Details; -using ExceptionAll.Dtos; using System.Collections.Generic; +using ExceptionAll.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Xunit; diff --git a/ExceptionAll.Tests/Details/ForbiddenDetailsTests.cs b/ExceptionAll.Tests/Details/ForbiddenDetailsTests.cs index a9d7753..a24eed9 100644 --- a/ExceptionAll.Tests/Details/ForbiddenDetailsTests.cs +++ b/ExceptionAll.Tests/Details/ForbiddenDetailsTests.cs @@ -1,7 +1,7 @@ using System; using ExceptionAll.Details; -using ExceptionAll.Dtos; using System.Collections.Generic; +using ExceptionAll.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Xunit; diff --git a/ExceptionAll.Tests/Details/InternalServerErrorDetailsTests.cs b/ExceptionAll.Tests/Details/InternalServerErrorDetailsTests.cs index e2210d9..0cad8da 100644 --- a/ExceptionAll.Tests/Details/InternalServerErrorDetailsTests.cs +++ b/ExceptionAll.Tests/Details/InternalServerErrorDetailsTests.cs @@ -1,7 +1,7 @@ using System; using ExceptionAll.Details; -using ExceptionAll.Dtos; using System.Collections.Generic; +using ExceptionAll.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Xunit; diff --git a/ExceptionAll.Tests/Details/NotFoundDetailsTests.cs b/ExceptionAll.Tests/Details/NotFoundDetailsTests.cs index 9fd8e4d..e12f5e8 100644 --- a/ExceptionAll.Tests/Details/NotFoundDetailsTests.cs +++ b/ExceptionAll.Tests/Details/NotFoundDetailsTests.cs @@ -1,7 +1,7 @@ using System; using ExceptionAll.Details; -using ExceptionAll.Dtos; using System.Collections.Generic; +using ExceptionAll.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Xunit; diff --git a/ExceptionAll.Tests/Details/UnauthorizedDetailsTests.cs b/ExceptionAll.Tests/Details/UnauthorizedDetailsTests.cs index c495b2a..aef1145 100644 --- a/ExceptionAll.Tests/Details/UnauthorizedDetailsTests.cs +++ b/ExceptionAll.Tests/Details/UnauthorizedDetailsTests.cs @@ -1,7 +1,7 @@ using System; using ExceptionAll.Details; -using ExceptionAll.Dtos; using System.Collections.Generic; +using ExceptionAll.Models; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Xunit; diff --git a/ExceptionAll.Tests/Dtos/ErrorResponseTests.cs b/ExceptionAll.Tests/Dtos/ErrorResponseTests.cs index a89d3f1..224115b 100644 --- a/ExceptionAll.Tests/Dtos/ErrorResponseTests.cs +++ b/ExceptionAll.Tests/Dtos/ErrorResponseTests.cs @@ -1,7 +1,7 @@ -using ExceptionAll.Dtos; -using ExceptionAll.Validation; +using ExceptionAll.Validation; using System.Collections.Generic; using System.Linq; +using ExceptionAll.Models; using Xunit; using Xunit.Abstractions; diff --git a/ExceptionAll.Tests/Services/ActionResultServiceTests.cs b/ExceptionAll.Tests/Services/ActionResultServiceTests.cs index d69c75c..4b69ec5 100644 --- a/ExceptionAll.Tests/Services/ActionResultServiceTests.cs +++ b/ExceptionAll.Tests/Services/ActionResultServiceTests.cs @@ -1,5 +1,4 @@ using ExceptionAll.Details; -using ExceptionAll.Dtos; using ExceptionAll.Interfaces; using ExceptionAll.Services; using Microsoft.AspNetCore.Mvc; @@ -7,6 +6,7 @@ using Moq; using System; using System.Collections.Generic; +using ExceptionAll.Models; using Xunit; using Xunit.Abstractions; @@ -50,7 +50,7 @@ public void GetResponse_WithProblemDetails_Throws() mockErrorResponseService.Object); // Act - var action = new Func(() => actionResultService.GetResponse(mockActionContext.Object)); + var action = new Func(() => actionResultService.GetResponse(mockActionContext.Object)); // Assert Assert.Throws(action); @@ -73,7 +73,7 @@ public void GetResponse_WithInvalidInheritedConstructor_Throws() Assert.Throws(action); } - private class TestDummy : BaseDetails + private class TestDummy : ApiErrorResponse { public TestDummy(ActionContext context, string title, string instance, int? status, string details, List errors) : base(context, title, instance, status, details, errors) diff --git a/ExceptionAll.Tests/Services/ErrorResponseServiceTests.cs b/ExceptionAll.Tests/Services/ErrorResponseServiceTests.cs index 4b9f5af..e16234b 100644 --- a/ExceptionAll.Tests/Services/ErrorResponseServiceTests.cs +++ b/ExceptionAll.Tests/Services/ErrorResponseServiceTests.cs @@ -1,5 +1,4 @@ using ExceptionAll.Details; -using ExceptionAll.Dtos; using ExceptionAll.Services; using FluentValidation; using Moq; @@ -7,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using ExceptionAll.Interfaces; +using ExceptionAll.Models; using Microsoft.Extensions.Logging; using Xunit; using Xunit.Abstractions; diff --git a/ExceptionAll.Tests/TestHelper.cs b/ExceptionAll.Tests/TestHelper.cs deleted file mode 100644 index 280c655..0000000 --- a/ExceptionAll.Tests/TestHelper.cs +++ /dev/null @@ -1,151 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.AspNetCore.Routing; -using Moq; -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using ExceptionAll.Details; -using ExceptionAll.Dtos; -using ExceptionAll.Interfaces; -using ExceptionAll.Services; -using FluentValidation; -using Microsoft.Extensions.Logging; - -namespace ExceptionAll.Tests -{ - public static class TestHelper - { - public static string ToJson(this object obj) - { - return JsonConvert.SerializeObject(obj, - Formatting.None, - new JsonSerializerSettings() - { - ReferenceLoopHandling = ReferenceLoopHandling.Ignore, - Formatting = Formatting.Indented - }); - } - - public static Mock GetMockActionContext(HttpContext context = null) - { - var mockActionContext = new Mock( - context ?? GetMockHttpContext(), - new RouteData(), - new ActionDescriptor()); - return mockActionContext; - } - - public static Mock GetMockException() where T : Exception - { - var mockException = new Mock(); - mockException.Setup(x => x.StackTrace) - .Returns("Test Stacktrace"); - mockException.Setup(x => x.Message) - .Returns("Test message"); - mockException.Setup(x => x.Source) - .Returns("Test source"); - - return mockException; - } - - public static Mock GetMockExceptionContext(HttpContext context = null, T exception = null) - where T : Exception - { - var mockExceptionContext = new Mock( - GetMockActionContext(context).Object, - new List()); - - mockExceptionContext - .Setup(x => x.Exception) - .Returns(exception ?? GetMockException().Object); - - return mockExceptionContext; - } - - public static DefaultHttpContext GetMockHttpContext() - { - var context = new DefaultHttpContext(); - context.Request.Headers.Add("x-correlation-id", Guid.NewGuid().ToString()); - context.TraceIdentifier = Guid.NewGuid().ToString(); - return context; - } - - public static Mock GetMockActionResultService(IErrorResponseService service = null) - { - var mockErrorLogger = new Mock>(); - var mockActionLogger = new Mock>(); - var mockErrorResponseService = new Mock(mockErrorLogger.Object); - return new Mock( - mockActionLogger.Object, - service ?? mockErrorResponseService.Object); - } - - public static Mock GetMockErrorResponseService() - { - var mockErrorLogger = new Mock>(); - return new Mock(mockErrorLogger.Object); - } - - public static IEnumerable GetValidErrorResponses() - { - return new List - { - // Every property populated - new object[]{ - ErrorResponse - .CreateErrorResponse() - .WithTitle("Bad Request - Fluent Validation") - .ForException() - .WithReturnType() - .WithLogAction((x, e) => x.LogError("Something bad happened", e)) - }, - - // No title - new object[]{ - ErrorResponse - .CreateErrorResponse() - .ForException() - .WithReturnType() - .WithLogAction((x, e) => x.LogError("Something bad happened", e)) - }, - - // No log action - new object[]{ - ErrorResponse - .CreateErrorResponse() - .WithTitle("Bad Request - Fluent Validation") - .ForException() - .WithReturnType() - }, - - // Only details type - new object[]{ - ErrorResponse - .CreateErrorResponse() - .WithReturnType() - }, - - // Only creation method - new object[]{ - ErrorResponse.CreateErrorResponse() - }, - }; - } - - public static IEnumerable GetInvalidErrorResponses() - { - return new List - { - new object[]{ - ErrorResponse - .CreateErrorResponse() - .ForException() - .WithReturnType() - } - }; - } - } -} \ No newline at end of file diff --git a/ExceptionAll.sln b/ExceptionAll.sln index 31fbade..9093a2a 100644 --- a/ExceptionAll.sln +++ b/ExceptionAll.sln @@ -1,13 +1,15 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31402.337 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.32014.148 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExceptionAll", "ExceptionAll\ExceptionAll.csproj", "{68AAF967-4BD4-40BC-AD79-779272E168F9}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExceptionAll.APIExample", "ExceptionAll.APIExample\ExceptionAll.APIExample.csproj", "{260C4AB3-B883-496C-9DDB-15BDF4D438D8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExceptionAll.Tests", "ExceptionAll.Tests\ExceptionAll.Tests.csproj", "{61B2E458-2141-4D24-A027-183E76DFCC90}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExceptionAll.Tests.Unit", "ExceptionAll.Tests.Unit\ExceptionAll.Tests.Unit.csproj", "{1DC929F1-BC29-47CF-B628-C99D39FA4023}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExceptionAll.Tests.EndToEnd", "ExceptionAll.Tests.EndToEnd\ExceptionAll.Tests.EndToEnd.csproj", "{A22AF9F9-DEA8-4F7B-9ACD-B9D6FA79714E}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -23,10 +25,14 @@ Global {260C4AB3-B883-496C-9DDB-15BDF4D438D8}.Debug|Any CPU.Build.0 = Debug|Any CPU {260C4AB3-B883-496C-9DDB-15BDF4D438D8}.Release|Any CPU.ActiveCfg = Release|Any CPU {260C4AB3-B883-496C-9DDB-15BDF4D438D8}.Release|Any CPU.Build.0 = Release|Any CPU - {61B2E458-2141-4D24-A027-183E76DFCC90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {61B2E458-2141-4D24-A027-183E76DFCC90}.Debug|Any CPU.Build.0 = Debug|Any CPU - {61B2E458-2141-4D24-A027-183E76DFCC90}.Release|Any CPU.ActiveCfg = Release|Any CPU - {61B2E458-2141-4D24-A027-183E76DFCC90}.Release|Any CPU.Build.0 = Release|Any CPU + {1DC929F1-BC29-47CF-B628-C99D39FA4023}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1DC929F1-BC29-47CF-B628-C99D39FA4023}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DC929F1-BC29-47CF-B628-C99D39FA4023}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1DC929F1-BC29-47CF-B628-C99D39FA4023}.Release|Any CPU.Build.0 = Release|Any CPU + {A22AF9F9-DEA8-4F7B-9ACD-B9D6FA79714E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A22AF9F9-DEA8-4F7B-9ACD-B9D6FA79714E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A22AF9F9-DEA8-4F7B-9ACD-B9D6FA79714E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A22AF9F9-DEA8-4F7B-9ACD-B9D6FA79714E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ExceptionAll/Details/BadGatewayDetails.cs b/ExceptionAll/Details/BadGatewayDetails.cs new file mode 100644 index 0000000..c97ea47 --- /dev/null +++ b/ExceptionAll/Details/BadGatewayDetails.cs @@ -0,0 +1,13 @@ +namespace ExceptionAll.Details; + +public class BadGatewayDetails : IExceptionAllDetails +{ + public string Title => GetDetails().Title; + public int StatusCode => GetDetails().StatusCode; + public string Message { get; init; } = string.Empty; + public IReadOnlyDictionary? ContextDetails { get; init; } + public (int StatusCode, string Title) GetDetails() + { + return (502, "Bad Gateway"); + } +} \ No newline at end of file diff --git a/ExceptionAll/Details/BadRequestDetails.cs b/ExceptionAll/Details/BadRequestDetails.cs index 4b3f20a..b9fbd5c 100644 --- a/ExceptionAll/Details/BadRequestDetails.cs +++ b/ExceptionAll/Details/BadRequestDetails.cs @@ -1,25 +1,14 @@ namespace ExceptionAll.Details; -public class BadRequestDetails : BaseDetails +public class BadRequestDetails : IExceptionAllDetails { - public BadRequestDetails(ActionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Bad Request" : title, - context.HttpContext.Request.Path, - StatusCodes.Status400BadRequest, - string.IsNullOrEmpty (message) ? "See errors or logs for more details" : message, - errors) - { - } - public BadRequestDetails(ExceptionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Bad Request" : title, - context.HttpContext.Request.Path, - StatusCodes.Status400BadRequest, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) + public string Title => GetDetails().Title; + public int StatusCode => GetDetails().StatusCode; + public string Message { get; init; } = string.Empty; + public IReadOnlyDictionary? ContextDetails { get; init; } + public (int StatusCode, string Title) GetDetails() { + return (400, "Bad Request"); } -} + +} \ No newline at end of file diff --git a/ExceptionAll/Details/BaseDetails.cs b/ExceptionAll/Details/BaseDetails.cs deleted file mode 100644 index 02b270a..0000000 --- a/ExceptionAll/Details/BaseDetails.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ExceptionAll.Details; - -public class BaseDetails : ProblemDetails -{ - public BaseDetails(ActionContext context, string title, string instance, int? status, string details, List errors) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Title = title; - Instance = instance; - Status = status; - Detail = details; - this.AddDefaultExtensionsFromContext(context, errors); - } - - public BaseDetails(ExceptionContext context, string title, string instance, int? status, string details, List errors) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Title = title; - Instance = instance; - Status = status; - Detail = details; - this.AddDefaultExtensionsFromContext(context, errors); - } -} diff --git a/ExceptionAll/Details/ForbiddenDetails.cs b/ExceptionAll/Details/ForbiddenDetails.cs index 61fc443..c8c545a 100644 --- a/ExceptionAll/Details/ForbiddenDetails.cs +++ b/ExceptionAll/Details/ForbiddenDetails.cs @@ -1,25 +1,13 @@ namespace ExceptionAll.Details; -public class ForbiddenDetails : BaseDetails +public class ForbiddenDetails : IExceptionAllDetails { - public ForbiddenDetails(ActionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Forbidden" : title, - context.HttpContext.Request.Path, - StatusCodes.Status403Forbidden, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) + public string Title => GetDetails().Title; + public int StatusCode => GetDetails().StatusCode; + public string Message { get; init; } = string.Empty; + public IReadOnlyDictionary? ContextDetails { get; init; } + public (int StatusCode, string Title) GetDetails() { + return (403, "Forbidden"); } - public ForbiddenDetails(ExceptionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Forbidden" : title, - context.HttpContext.Request.Path, - StatusCodes.Status403Forbidden, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) - { - } -} +} \ No newline at end of file diff --git a/ExceptionAll/Details/InternalServerErrorDetails.cs b/ExceptionAll/Details/InternalServerErrorDetails.cs index 19e1944..f35820c 100644 --- a/ExceptionAll/Details/InternalServerErrorDetails.cs +++ b/ExceptionAll/Details/InternalServerErrorDetails.cs @@ -1,25 +1,13 @@ namespace ExceptionAll.Details; -public class InternalServerErrorDetails : BaseDetails +public class InternalServerErrorDetails : IExceptionAllDetails { - public InternalServerErrorDetails(ActionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Internal Server Error" : title, - context.HttpContext.Request.Path, - StatusCodes.Status500InternalServerError, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) + public string Title => GetDetails().Title; + public int StatusCode => GetDetails().StatusCode; + public string Message { get; init; } = string.Empty; + public IReadOnlyDictionary? ContextDetails { get; init; } + public (int StatusCode, string Title) GetDetails() { + return (500, "Internal Server Error"); } - public InternalServerErrorDetails(ExceptionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Internal Server Error" : title, - context.HttpContext.Request.Path, - StatusCodes.Status500InternalServerError, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) - { - } -} +} \ No newline at end of file diff --git a/ExceptionAll/Details/NotFoundDetails.cs b/ExceptionAll/Details/NotFoundDetails.cs index 1886fbb..6431464 100644 --- a/ExceptionAll/Details/NotFoundDetails.cs +++ b/ExceptionAll/Details/NotFoundDetails.cs @@ -1,25 +1,13 @@ namespace ExceptionAll.Details; -public class NotFoundDetails : BaseDetails +public class NotFoundDetails : IExceptionAllDetails { - public NotFoundDetails(ActionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Not Found" : title, - context.HttpContext.Request.Path, - StatusCodes.Status404NotFound, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) + public string Title => GetDetails().Title; + public int StatusCode => GetDetails().StatusCode; + public string Message { get; init; } = string.Empty; + public IReadOnlyDictionary? ContextDetails { get; init; } + public (int StatusCode, string Title) GetDetails() { + return (404, "Not Found"); } - public NotFoundDetails(ExceptionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Not Found" : title, - context.HttpContext.Request.Path, - StatusCodes.Status404NotFound, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) - { - } -} +} \ No newline at end of file diff --git a/ExceptionAll/Details/TooManyRequestsDetails.cs b/ExceptionAll/Details/TooManyRequestsDetails.cs new file mode 100644 index 0000000..e43b1ea --- /dev/null +++ b/ExceptionAll/Details/TooManyRequestsDetails.cs @@ -0,0 +1,13 @@ +namespace ExceptionAll.Details; + +public class TooManyRequestsDetails : IExceptionAllDetails +{ + public string Title => GetDetails().Title; + public int StatusCode => GetDetails().StatusCode; + public string Message { get; init; } = string.Empty; + public IReadOnlyDictionary? ContextDetails { get; init; } + public (int StatusCode, string Title) GetDetails() + { + return (429, "Too Many Requests"); + } +} \ No newline at end of file diff --git a/ExceptionAll/Details/UnauthorizedDetails.cs b/ExceptionAll/Details/UnauthorizedDetails.cs index 0aae2d4..c01e2a1 100644 --- a/ExceptionAll/Details/UnauthorizedDetails.cs +++ b/ExceptionAll/Details/UnauthorizedDetails.cs @@ -1,25 +1,13 @@ namespace ExceptionAll.Details; -public class UnauthorizedDetails : BaseDetails +public class UnauthorizedDetails : IExceptionAllDetails { - public UnauthorizedDetails(ActionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Unauthorized" : title, - context.HttpContext.Request.Path, - StatusCodes.Status401Unauthorized, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) + public string Title => GetDetails().Title; + public int StatusCode => GetDetails().StatusCode; + public string Message { get; init; } = string.Empty; + public IReadOnlyDictionary? ContextDetails { get; init; } + public (int StatusCode, string Title) GetDetails() { + return (401, "Unauthorized"); } - public UnauthorizedDetails(ExceptionContext context, string title = null, string message = null, List errors = null) : - base( - context ?? throw new ArgumentNullException(nameof(context)), - string.IsNullOrEmpty(title) ? "Unauthorized" : title, - context.HttpContext.Request.Path, - StatusCodes.Status401Unauthorized, - string.IsNullOrEmpty(message) ? "See errors or logs for more details" : message, - errors) - { - } -} +} \ No newline at end of file diff --git a/ExceptionAll/Dtos/ErrorDetail.cs b/ExceptionAll/Dtos/ErrorDetail.cs deleted file mode 100644 index f365ab5..0000000 --- a/ExceptionAll/Dtos/ErrorDetail.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ExceptionAll.Dtos -{ - public class ErrorDetail - { - public string Title { get; } - public string Message { get; } - - public ErrorDetail(string title, string message) - { - Title = title; - Message = message; - } - } -} \ No newline at end of file diff --git a/ExceptionAll/Dtos/ErrorResponse.cs b/ExceptionAll/Dtos/ErrorResponse.cs deleted file mode 100644 index 71bf77f..0000000 --- a/ExceptionAll/Dtos/ErrorResponse.cs +++ /dev/null @@ -1,53 +0,0 @@ -using ExceptionAll.Details; -using ExceptionAll.Interfaces; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using System; - -namespace ExceptionAll.Dtos -{ - public class ErrorResponse : IResponseTitle, - IExceptionSelection, - IDetailsType, - ILogAction - { - public Type DetailsType { get; private set; } = typeof(InternalServerErrorDetails); - public string ErrorTitle { get; private set; } = "Error"; - public Type ExceptionType { get; private set; } = typeof(Exception); - public Action, Exception> LogAction { get; private set; } = - (x, e) => x.LogDebug(e, "ExceptionAll has caught an error"); - - private ErrorResponse() - { - } - - public static ErrorResponse CreateErrorResponse() - { - return new ErrorResponse(); - } - - public IDetailsType ForException() where T : Exception - { - ExceptionType = typeof(T); - return this; - } - - ErrorResponse ILogAction.WithLogAction(Action, Exception> action) - { - LogAction = action; - return this; - } - - public ILogAction WithReturnType() where T : ProblemDetails - { - DetailsType = typeof(T); - return this; - } - - public IExceptionSelection WithTitle(string title) - { - ErrorTitle = title; - return this; - } - } -} \ No newline at end of file diff --git a/ExceptionAll/Examples/BadGatewayDetailsExample.cs b/ExceptionAll/Examples/BadGatewayDetailsExample.cs new file mode 100644 index 0000000..7284cd5 --- /dev/null +++ b/ExceptionAll/Examples/BadGatewayDetailsExample.cs @@ -0,0 +1,27 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Examples; + +public class BadGatewayDetailsExample : IExamplesProvider +{ + private readonly IContextConfigurationService _contextConfigurationService; + + public BadGatewayDetailsExample(IContextConfigurationService contextConfigurationService) + { + _contextConfigurationService = contextConfigurationService ?? throw new ArgumentNullException(nameof(contextConfigurationService)); + } + + public BadGatewayDetails GetExamples() + { + return new BadGatewayDetails() + { + Message = "Oops, there was an error", + ContextDetails = _contextConfigurationService.GetContextDetails( + new DefaultHttpContext(), + new List + { + new("Error!", "Something broke") + }) + }; + } +} \ No newline at end of file diff --git a/ExceptionAll/Examples/BadRequestDetailsExample.cs b/ExceptionAll/Examples/BadRequestDetailsExample.cs new file mode 100644 index 0000000..34108b4 --- /dev/null +++ b/ExceptionAll/Examples/BadRequestDetailsExample.cs @@ -0,0 +1,27 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Examples; + +public class BadRequestDetailsExample : IExamplesProvider +{ + private readonly IContextConfigurationService _contextConfigurationService; + + public BadRequestDetailsExample(IContextConfigurationService contextConfigurationService) + { + _contextConfigurationService = contextConfigurationService ?? throw new ArgumentNullException(nameof(contextConfigurationService)); + } + + public BadRequestDetails GetExamples() + { + return new BadRequestDetails + { + Message = "Oops, there was an error", + ContextDetails = _contextConfigurationService.GetContextDetails( + new DefaultHttpContext(), + new List + { + new("Error!", "Something broke") + }) + }; + } +} \ No newline at end of file diff --git a/ExceptionAll/Examples/ForbiddenDetailsExample.cs b/ExceptionAll/Examples/ForbiddenDetailsExample.cs new file mode 100644 index 0000000..77284fa --- /dev/null +++ b/ExceptionAll/Examples/ForbiddenDetailsExample.cs @@ -0,0 +1,26 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Examples; + +public class ForbiddenDetailsExample : IExamplesProvider +{ + private readonly IContextConfigurationService _contextConfigurationService; + + public ForbiddenDetailsExample(IContextConfigurationService contextConfigurationService) + { + _contextConfigurationService = contextConfigurationService ?? throw new ArgumentNullException(nameof(contextConfigurationService)); + } + public ForbiddenDetails GetExamples() + { + return new ForbiddenDetails() + { + Message = "Oops, there was an error", + ContextDetails = _contextConfigurationService.GetContextDetails( + new DefaultHttpContext(), + new List + { + new("Error!", "Something broke") + }) + }; + } +} \ No newline at end of file diff --git a/ExceptionAll/Examples/InternalServerErrorDetailsExample.cs b/ExceptionAll/Examples/InternalServerErrorDetailsExample.cs new file mode 100644 index 0000000..6b6295d --- /dev/null +++ b/ExceptionAll/Examples/InternalServerErrorDetailsExample.cs @@ -0,0 +1,27 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Examples; + +public class InternalServerErrorDetailsExample : IExamplesProvider +{ + private readonly IContextConfigurationService _contextConfigurationService; + + public InternalServerErrorDetailsExample(IContextConfigurationService contextConfigurationService) + { + _contextConfigurationService = contextConfigurationService ?? throw new ArgumentNullException(nameof(contextConfigurationService)); + } + + public InternalServerErrorDetails GetExamples() + { + return new InternalServerErrorDetails() + { + Message = "Oops, there was an error", + ContextDetails = _contextConfigurationService.GetContextDetails( + new DefaultHttpContext(), + new List + { + new("Error!", "Something broke") + }) + }; + } +} \ No newline at end of file diff --git a/ExceptionAll/Examples/NotFoundDetailsExample.cs b/ExceptionAll/Examples/NotFoundDetailsExample.cs new file mode 100644 index 0000000..bf8143f --- /dev/null +++ b/ExceptionAll/Examples/NotFoundDetailsExample.cs @@ -0,0 +1,27 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Examples; + +public class NotFoundDetailsExample : IExamplesProvider +{ + private readonly IContextConfigurationService _contextConfigurationService; + + public NotFoundDetailsExample(IContextConfigurationService contextConfigurationService) + { + _contextConfigurationService = contextConfigurationService ?? throw new ArgumentNullException(nameof(contextConfigurationService)); + } + + public NotFoundDetails GetExamples() + { + return new NotFoundDetails() + { + Message = "Oops, there was an error", + ContextDetails = _contextConfigurationService.GetContextDetails( + new DefaultHttpContext(), + new List + { + new("Error!", "Something broke") + }) + }; + } +} \ No newline at end of file diff --git a/ExceptionAll/Examples/TooManyRequestsDetailsExample.cs b/ExceptionAll/Examples/TooManyRequestsDetailsExample.cs new file mode 100644 index 0000000..e71123a --- /dev/null +++ b/ExceptionAll/Examples/TooManyRequestsDetailsExample.cs @@ -0,0 +1,27 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Examples; + +public class TooManyRequestsDetailsExample : IExamplesProvider +{ + private readonly IContextConfigurationService _contextConfigurationService; + + public TooManyRequestsDetailsExample(IContextConfigurationService contextConfigurationService) + { + _contextConfigurationService = contextConfigurationService ?? throw new ArgumentNullException(nameof(contextConfigurationService)); + } + + public TooManyRequestsDetails GetExamples() + { + return new TooManyRequestsDetails() + { + Message = "Oops, there was an error", + ContextDetails = _contextConfigurationService.GetContextDetails( + new DefaultHttpContext(), + new List + { + new("Error!", "Something broke") + }) + }; + } +} \ No newline at end of file diff --git a/ExceptionAll/Examples/UnauthorizedDetailsExample.cs b/ExceptionAll/Examples/UnauthorizedDetailsExample.cs new file mode 100644 index 0000000..53fdc7e --- /dev/null +++ b/ExceptionAll/Examples/UnauthorizedDetailsExample.cs @@ -0,0 +1,27 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Examples; + +public class UnauthorizedDetailsExample : IExamplesProvider +{ + private readonly IContextConfigurationService _contextConfigurationService; + + public UnauthorizedDetailsExample(IContextConfigurationService contextConfigurationService) + { + _contextConfigurationService = contextConfigurationService ?? throw new ArgumentNullException(nameof(contextConfigurationService)); + } + + public UnauthorizedDetails GetExamples() + { + return new UnauthorizedDetails() + { + Message = "Oops, there was an error", + ContextDetails = _contextConfigurationService.GetContextDetails( + new DefaultHttpContext(), + new List + { + new("Error!", "Something broke") + }) + }; + } +} \ No newline at end of file diff --git a/ExceptionAll/ExceptionAll.csproj b/ExceptionAll/ExceptionAll.csproj index 8b05a87..1137f4e 100644 --- a/ExceptionAll/ExceptionAll.csproj +++ b/ExceptionAll/ExceptionAll.csproj @@ -14,13 +14,16 @@ 1.1.0.0 1.1.0.0 3.0.0 + enable + False + /ExceptionAll-swagger.xml - + - - + + diff --git a/ExceptionAll/Filters/ExceptionFilter.cs b/ExceptionAll/Filters/ExceptionFilter.cs index 539cc73..6d1c9cf 100644 --- a/ExceptionAll/Filters/ExceptionFilter.cs +++ b/ExceptionAll/Filters/ExceptionFilter.cs @@ -15,5 +15,21 @@ public ExceptionFilter(IActionResultService actionResultService) public override void OnException(ExceptionContext context) { context.Result = _actionResultService.GetErrorResponse(context); + return; + /*if (context.ModelState.IsValid) + { + + } + + var errors = new List(); + foreach (var (key, value) in context.ModelState) + { + errors.AddRange(value.Errors.Select(error => new ErrorDetail(key, error.ErrorMessage))); + } + + context.Result = _actionResultService.GetResponse( + context, + "Invalid request model", + errors.Any() ? errors : null);*/ } } diff --git a/ExceptionAll/GlobalUsings.cs b/ExceptionAll/GlobalUsings.cs index 790b7f8..07a5594 100644 --- a/ExceptionAll/GlobalUsings.cs +++ b/ExceptionAll/GlobalUsings.cs @@ -1,11 +1,14 @@ -global using ExceptionAll.Dtos; -global using ExceptionAll.Helpers; -global using Microsoft.AspNetCore.Mvc; -global using Microsoft.AspNetCore.Mvc.Filters; -global using Microsoft.AspNetCore.Http; -global using ExceptionAll.Details; +global using ExceptionAll.Details; +global using ExceptionAll.Examples; +global using ExceptionAll.Filters; global using ExceptionAll.Interfaces; -global using FluentValidation; +global using ExceptionAll.Models; +global using ExceptionAll.Services; global using ExceptionAll.Validation; +global using FluentValidation; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Mvc.Filters; +global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; -global using System.Reflection; \ No newline at end of file +global using Swashbuckle.AspNetCore.Filters; diff --git a/ExceptionAll/Helpers/MiddlewareExtensionHelper.cs b/ExceptionAll/Helpers/MiddlewareExtensionHelper.cs deleted file mode 100644 index 222128f..0000000 --- a/ExceptionAll/Helpers/MiddlewareExtensionHelper.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ExceptionAll.Middleware; -using Microsoft.AspNetCore.Builder; - -namespace ExceptionAll.Helpers -{ - public static class MiddlewareExtensionHelper - { - /// - /// Add 'x-correlation-id' header to all incoming http requests - /// - /// - /// - public static IApplicationBuilder UseCorrelationIdMiddleware( - this IApplicationBuilder builder) - { - return builder.UseMiddleware(); - } - } -} \ No newline at end of file diff --git a/ExceptionAll/Helpers/ProblemDetailsHelper.cs b/ExceptionAll/Helpers/ProblemDetailsHelper.cs deleted file mode 100644 index dae5620..0000000 --- a/ExceptionAll/Helpers/ProblemDetailsHelper.cs +++ /dev/null @@ -1,75 +0,0 @@ -namespace ExceptionAll.Helpers; - -public static class ProblemDetailsHelper -{ - public static void AddDefaultExtensionsFromContext(this BaseDetails details, - ActionContext context, - List errors = null) - { - foreach (var (key, value) in GetExtensionsFromContext(context, errors)) - { - details.Extensions.Add(key, value); - } - } - - public static void AddDefaultExtensionsFromContext(this BaseDetails details, - ExceptionContext context, - List errors = null) - { - foreach (var (key, value) in GetExtensionsFromContext(context, errors)) - { - details.Extensions.Add(key, value); - } - } - - private static IDictionary GetExtensionsFromContext(ActionContext context, - List errors = null) - { - var dictionary = new Dictionary - { - {"Method", context.HttpContext.Request.Method }, - {"QueryString", context.HttpContext.Request.QueryString.Value }, - {"CorrelationId", context.HttpContext.Request.Headers["x-correlation-id"].ToString() }, - {"TraceId", context.HttpContext.TraceIdentifier } - }; - - if (errors is null || !errors.Any()) return dictionary; - dictionary.Add("Errors", errors); - return dictionary; - } - - private static IDictionary GetExtensionsFromContext(ExceptionContext context, - List errors = null) - { - var dictionary = new Dictionary - { - {"Method", context.HttpContext.Request.Method }, - {"QueryString", context.HttpContext.Request.QueryString.Value }, - {"CorrelationId", context.HttpContext.Request.Headers["x-correlation-id"].ToString() }, - {"TraceId", context.HttpContext.TraceIdentifier } - }; - - if (errors is null || !errors.Any()) return dictionary; - dictionary.Add("Errors", errors); - return dictionary; - } - - public static ConstructorInfo GetActionContextConstructor() - where T : BaseDetails - { - try - { - return typeof(T).GetConstructor(new[] - { - typeof(ActionContext), - typeof(string), - typeof(string), - typeof(List) - }); - } - catch (Exception e) - { - throw new Exception($"Error creating constructor for type: {typeof(T)}", e); - } - } -} diff --git a/ExceptionAll/Helpers/ServiceCollectionHelper.cs b/ExceptionAll/Helpers/ServiceCollectionHelper.cs index 33e4874..eb6ab32 100644 --- a/ExceptionAll/Helpers/ServiceCollectionHelper.cs +++ b/ExceptionAll/Helpers/ServiceCollectionHelper.cs @@ -1,8 +1,4 @@ -using ExceptionAll.Filters; -using ExceptionAll.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace ExceptionAll.Helpers; +namespace ExceptionAll.Helpers; public static class ServiceCollectionHelper { @@ -11,15 +7,96 @@ public static class ServiceCollectionHelper /// /// /// - public static IMvcBuilder AddExceptionAll(this IServiceCollection services) + public static IServiceCollection AddExceptionAll(this IServiceCollection services) where T : class, IExceptionAllConfiguration { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); - return services.AddMvc(options => - { - options.Filters.Add(); - }); + services.Scan( + x => x.FromAssemblyOf() + .AddClasses(c => c.AssignableTo()) + .AsImplementedInterfaces() + .WithSingletonLifetime()); + + var serviceProvider = services.BuildServiceProvider(); + var actionResultService = serviceProvider.GetRequiredService(); + var contextConfiguration = serviceProvider.GetRequiredService(); + var configuration = serviceProvider.GetRequiredService(); + //var errorResponseService = serviceProvider.GetRequiredService(); + + //errorResponseService?.AddErrorResponses(configuration.ErrorResponses.ToList()); + + if (configuration.ContextConfiguration is not null) + contextConfiguration.Configure(configuration.ContextConfiguration); + + // Adds an exception filter to + services.AddMvc(options => { options.Filters.Add(); }); + + // Removes the default response from being returned on validation error + services.Configure( + options => + { + options.InvalidModelStateResponseFactory = context => + { + var errors = new List(); + foreach (var (key, value) in context.ModelState) + { + errors.AddRange(value.Errors.Select(error => new ErrorDetail(key, error.ErrorMessage))); + } + + return actionResultService.GetResponse( + context, + "Invalid request model", + errors.Any() ? errors : null); + }; + }); + + return services; + } + + /// + /// Assembly scans for the class implementation of IExceptionAllConfiguration + /// + /// + /// + /// + public static IServiceCollection WithConfigurationInAssemblyOf(this IServiceCollection services) where T : class + { + return services.Scan( + x => x.FromAssemblyOf() + .AddClasses(c => c.AssignableTo()) + .AsImplementedInterfaces() + .WithSingletonLifetime()); + } + + /// + /// Adds ExceptionAll's out of the box, default Swagger example providers + /// + /// + /// + public static IServiceCollection WithExceptionAllSwaggerExamples(this IServiceCollection services) + { + return services.AddSwaggerExamplesFromAssemblyOf(); + } + + /// + /// Applies the configuration provided by the user to the error response container + /// + /// + /// + public static void AddExceptionAll(this IServiceProvider services) + { + var contextConfiguration = services.GetService(); + var errorResponseService = services.GetService(); + var configuration = services.GetService(); + + if (configuration is null || contextConfiguration is null) return; + + errorResponseService?.AddErrorResponses(configuration.ErrorResponses.ToList()); + + if (configuration.ContextConfiguration is not null) + contextConfiguration.Configure(configuration.ContextConfiguration); } /// @@ -27,7 +104,8 @@ public static IMvcBuilder AddExceptionAll(this IServiceCollection services) /// /// /// - public static void AddErrorResponses(this IErrorResponseService service, + private static void AddErrorResponses( + this IErrorResponseService service, List errorResponses) { if (errorResponses == null || !errorResponses.Any()) @@ -38,4 +116,4 @@ public static void AddErrorResponses(this IErrorResponseService service, service.AddErrorResponse(errorResponse); } } -} +} \ No newline at end of file diff --git a/ExceptionAll/Interfaces/IActionResultService.cs b/ExceptionAll/Interfaces/IActionResultService.cs index 0006c82..409e2ca 100644 --- a/ExceptionAll/Interfaces/IActionResultService.cs +++ b/ExceptionAll/Interfaces/IActionResultService.cs @@ -1,4 +1,6 @@ -namespace ExceptionAll.Interfaces; +using ExceptionAll.Models; + +namespace ExceptionAll.Interfaces; /// /// Service for creating and returning standard IActionResult error objects @@ -6,18 +8,20 @@ public interface IActionResultService { /// - /// Create an error response for a filter-caught exception + /// Create an error response for a filter-caught exceptions /// /// Exception context /// IActionResult GetErrorResponse(ExceptionContext context); /// - /// Manually create an error response + /// Manually create an error response for developer caught errors /// - /// - /// ControllerContext - /// Optional developer message + /// + /// + /// /// - IActionResult GetResponse(ActionContext context, string message = null) where T : BaseDetails; -} + IActionResult GetResponse( + ActionContext context, string? message = null, List? errors = null) + where T : IDetailBuilder, new(); +} \ No newline at end of file diff --git a/ExceptionAll/Interfaces/IApiErrorDetails.cs b/ExceptionAll/Interfaces/IApiErrorDetails.cs new file mode 100644 index 0000000..cc68b46 --- /dev/null +++ b/ExceptionAll/Interfaces/IApiErrorDetails.cs @@ -0,0 +1,9 @@ +namespace ExceptionAll.Interfaces; + +public interface IApiErrorDetails +{ + string Title { get; } + int StatusCode { get; } + string Message { get; init; } + public IReadOnlyDictionary? ContextDetails { get; init; } +} \ No newline at end of file diff --git a/ExceptionAll/Interfaces/IContextConfigurationService.cs b/ExceptionAll/Interfaces/IContextConfigurationService.cs new file mode 100644 index 0000000..0fe8d1d --- /dev/null +++ b/ExceptionAll/Interfaces/IContextConfigurationService.cs @@ -0,0 +1,18 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Interfaces; + +public interface IContextConfigurationService +{ + /// + /// Allows the user to configure additional properties shown in the response which can be derived from the HttpContext + /// + /// + void Configure(Dictionary> configuration); + + /// + /// Reads data from the HttpContext and maps it according to the configuration provided + /// + /// + IReadOnlyDictionary? GetContextDetails(HttpContext context, List? errors = null); +} \ No newline at end of file diff --git a/ExceptionAll/Interfaces/IDetailBuilder.cs b/ExceptionAll/Interfaces/IDetailBuilder.cs new file mode 100644 index 0000000..096be47 --- /dev/null +++ b/ExceptionAll/Interfaces/IDetailBuilder.cs @@ -0,0 +1,10 @@ +namespace ExceptionAll.Interfaces; + +public interface IDetailBuilder +{ + /// + /// Returns the status code and title associated with the API response + /// + /// + (int StatusCode, string Title) GetDetails(); +} \ No newline at end of file diff --git a/ExceptionAll/Interfaces/IErrorResponse.cs b/ExceptionAll/Interfaces/IErrorResponse.cs index 4b1dd8c..9944b07 100644 --- a/ExceptionAll/Interfaces/IErrorResponse.cs +++ b/ExceptionAll/Interfaces/IErrorResponse.cs @@ -1,55 +1,55 @@ -using Microsoft.Extensions.Logging; -using System; -using ExceptionAll.Dtos; -using Microsoft.AspNetCore.Mvc; +using ExceptionAll.Models; -namespace ExceptionAll.Interfaces +namespace ExceptionAll.Interfaces; + +/// +/// Constructed response for a given exception type +/// +public interface IErrorResponse { - public interface IErrorResponse - { - Type DetailsType { get; } - string ErrorTitle { get; } - Type ExceptionType { get; } - Action, Exception> LogAction { get; } - } + int StatusCode { get; } + string ErrorTitle { get; } + string Message { get; } + Type ExceptionType { get; } + Action, Exception>? LogAction { get; } +} - public interface IDetailsType : IErrorResponse - { - /// - /// Object returned from handling our error - /// - /// - /// - public ILogAction WithReturnType() where T : ProblemDetails; - } +public interface IResponseTitle : IErrorResponse +{ + /// + /// Creates title for the returned error response object + /// + /// + /// + public IStatusIdentifier WithTitle(string title); +} - public interface IExceptionSelection : IErrorResponse - { - /// - /// Type of error that will trigger our filter - /// - /// Exception type - /// - public IDetailsType ForException() where T : Exception; - } +public interface IStatusIdentifier : IErrorResponse +{ + public IMessageCreation WithStatusCode(int statusCode); +} - public interface ILogAction : IErrorResponse - { - /// - /// Logging action that will occur if an exception is caught - /// - /// - /// - public ErrorResponse WithLogAction(Action, Exception> action); - } +public interface IMessageCreation : IErrorResponse +{ + public IExceptionSelection WithMessage(string message); +} - public interface IResponseTitle : IErrorResponse - { - /// - /// Creates title for the returned error response object - /// - /// - /// - public IExceptionSelection WithTitle(string title); - } +public interface IExceptionSelection : IErrorResponse +{ + /// + /// Type of error that will trigger our filter + /// + /// Exception type + /// + public ILogAction ForException() where T : Exception; } + +public interface ILogAction : IErrorResponse +{ + /// + /// Logging action that will occur if an exception is caught + /// + /// + /// + public ErrorResponse WithLogAction(Action, Exception> action); +} \ No newline at end of file diff --git a/ExceptionAll/Interfaces/IErrorResponseService.cs b/ExceptionAll/Interfaces/IErrorResponseService.cs index 418b6ca..ca81c17 100644 --- a/ExceptionAll/Interfaces/IErrorResponseService.cs +++ b/ExceptionAll/Interfaces/IErrorResponseService.cs @@ -1,5 +1,4 @@ -using ExceptionAll.Dtos; -using System; +using System; using System.Collections.Generic; namespace ExceptionAll.Interfaces diff --git a/ExceptionAll/Interfaces/IExceptionAllConfiguration.cs b/ExceptionAll/Interfaces/IExceptionAllConfiguration.cs new file mode 100644 index 0000000..3c5ea11 --- /dev/null +++ b/ExceptionAll/Interfaces/IExceptionAllConfiguration.cs @@ -0,0 +1,14 @@ +namespace ExceptionAll.Interfaces; + +public interface IExceptionAllConfiguration +{ + /// + /// Collection of error responses + /// + List ErrorResponses { get; } + + /// + /// Maps user defined property names to accessible members of the HttpContext + /// + Dictionary>? ContextConfiguration { get; } +} \ No newline at end of file diff --git a/ExceptionAll/Interfaces/IExceptionAllDetails.cs b/ExceptionAll/Interfaces/IExceptionAllDetails.cs new file mode 100644 index 0000000..6b2feae --- /dev/null +++ b/ExceptionAll/Interfaces/IExceptionAllDetails.cs @@ -0,0 +1,6 @@ +namespace ExceptionAll.Interfaces; + +public interface IExceptionAllDetails : IApiErrorDetails, IDetailBuilder +{ + +} \ No newline at end of file diff --git a/ExceptionAll/Middleware/CorrelationIdMiddleware.cs b/ExceptionAll/Middleware/CorrelationIdMiddleware.cs deleted file mode 100644 index 8764b20..0000000 --- a/ExceptionAll/Middleware/CorrelationIdMiddleware.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.AspNetCore.Http; -using System; -using System.Threading.Tasks; - -namespace ExceptionAll.Middleware -{ - public class CorrelationIdMiddleware - { - private readonly RequestDelegate _next; - - public CorrelationIdMiddleware(RequestDelegate next) - { - _next = next ?? throw new ArgumentNullException(nameof(next)); - } - - public async Task Invoke(HttpContext context) - { - if (!context.Request.Headers.ContainsKey("x-correlation-id")) - { - context.Request.Headers.Add("x-correlation-id", Guid.NewGuid().ToString("N")); - } - - await _next.Invoke(context); - } - } -} \ No newline at end of file diff --git a/ExceptionAll/Models/ApiErrorDetails.cs b/ExceptionAll/Models/ApiErrorDetails.cs new file mode 100644 index 0000000..dc77892 --- /dev/null +++ b/ExceptionAll/Models/ApiErrorDetails.cs @@ -0,0 +1,9 @@ +namespace ExceptionAll.Models; + +public class ApiErrorDetails : IApiErrorDetails +{ + public string Title { get; init; } = "ExceptionAll default error"; + public int StatusCode { get; init; } = 500; + public string Message { get; init; } = "There was an error encountered"; + public IReadOnlyDictionary? ContextDetails { get; init; } +} diff --git a/ExceptionAll/Models/ErrorDetail.cs b/ExceptionAll/Models/ErrorDetail.cs new file mode 100644 index 0000000..a93b015 --- /dev/null +++ b/ExceptionAll/Models/ErrorDetail.cs @@ -0,0 +1,8 @@ +namespace ExceptionAll.Models; + +/// +/// Simple record for communicating errors +/// +/// +/// +public record ErrorDetail(string Title, string Message); \ No newline at end of file diff --git a/ExceptionAll/Models/ErrorResponse.cs b/ExceptionAll/Models/ErrorResponse.cs new file mode 100644 index 0000000..40a006e --- /dev/null +++ b/ExceptionAll/Models/ErrorResponse.cs @@ -0,0 +1,53 @@ +namespace ExceptionAll.Models; + +public class ErrorResponse : IResponseTitle, + IMessageCreation, + IStatusIdentifier, + IExceptionSelection, + ILogAction +{ + public int StatusCode { get; private set; } = 500; + public string ErrorTitle { get; private set; } = "Error"; + public string Message { get; private set; } = "There was an error encountered. If no errors are explicitly shown, please see logs for more details."; + public Type ExceptionType { get; private set; } = typeof(Exception); + public Action, Exception>? LogAction { get; private set; } + + private ErrorResponse() + { + } + + public static ErrorResponse CreateErrorResponse() + { + return new ErrorResponse(); + } + + public IStatusIdentifier WithTitle(string title) + { + ErrorTitle = title; + return this; + } + + public IMessageCreation WithStatusCode(int statusCode) + { + StatusCode = statusCode; + return this; + } + + public IExceptionSelection WithMessage(string message) + { + Message = message; + return this; + } + + public ILogAction ForException() where T : Exception + { + ExceptionType = typeof(T); + return this; + } + + ErrorResponse ILogAction.WithLogAction(Action, Exception> action) + { + LogAction = action; + return this; + } +} \ No newline at end of file diff --git a/ExceptionAll/Services/ActionResultService.cs b/ExceptionAll/Services/ActionResultService.cs index 44db214..04995b5 100644 --- a/ExceptionAll/Services/ActionResultService.cs +++ b/ExceptionAll/Services/ActionResultService.cs @@ -3,97 +3,72 @@ public class ActionResultService : IActionResultService { private readonly IErrorResponseService _errorResponseService; + private readonly IContextConfigurationService _configurationService; private ILogger Logger { get; } - public ActionResultService(ILogger logger, - IErrorResponseService errorResponseService) + public ActionResultService( + ILogger logger, + IErrorResponseService errorResponseService, + IContextConfigurationService configurationService) { - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); + Logger = logger ?? throw new ArgumentNullException(nameof(logger)); _errorResponseService = errorResponseService ?? throw new ArgumentNullException(nameof(errorResponseService)); + _configurationService = configurationService ?? throw new ArgumentNullException(nameof(configurationService)); } public IActionResult GetErrorResponse(ExceptionContext context) { - BaseDetails details; if (_errorResponseService.GetErrorResponses() - .TryGetValue(context.Exception.GetType(), - out var response)) + .TryGetValue( + context.Exception.GetType(), + out var errorResponse)) { - new ErrorResponseValidator().ValidateAndThrow(response); - var constructorInfo = GetExceptionContextConstructor(response.DetailsType); - - details = (BaseDetails)constructorInfo.Invoke(new object[] - { - context, response.ErrorTitle, null, null - }); - - if (details.Status != null) - { - context.HttpContext.Response.StatusCode = (int)details.Status; - } - - response.LogAction?.Invoke(Logger, context.Exception); + new ErrorResponseValidator().ValidateAndThrow(errorResponse); + errorResponse.LogAction?.Invoke(Logger, context.Exception); } else { - context.HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; - details = new InternalServerErrorDetails(context, "Internal Server Error"); - Logger.LogError(context.Exception, "Error encountered when accessing resource"); + Logger.LogInformation( + context.Exception, + "Exception type not found when accessing error response container. Please verify you have added this given exception type to your configuration: {type}", + context.Exception.GetType() + .FullName); } - return new ObjectResult(details) + var apiResponse = new ApiErrorDetails { - StatusCode = context.HttpContext.Response.StatusCode + Title = errorResponse?.ErrorTitle ?? "ExceptionAll Error", + StatusCode = errorResponse?.StatusCode ?? 500, + Message = errorResponse?.Message ?? "There was an error encountered. If no errors are explicitly shown, please see logs for more details.", + ContextDetails = _configurationService.GetContextDetails(context.HttpContext) }; - } - - public IActionResult GetResponse(ActionContext context, string message = null) where T : BaseDetails - { - T details; - if (!typeof(T).IsSubclassOf(typeof(BaseDetails)) && - typeof(T) == typeof(BaseDetails)) - { - var e = new Exception("ProblemDetails is not an acceptable type"); - Logger.LogError(e, "ProblemDetails is not a valid type for this class. Please refer to documentation for assistance"); - throw e; - } - - try - { - var constructorInfo = ProblemDetailsHelper.GetActionContextConstructor(); - details = (T)constructorInfo.Invoke(new object[] { context, "Caught Exception", message, null }); - } - catch (Exception e) - { - Logger.LogError(e, e.Message); - throw new Exception("Error when trying to invoke object constructor", e); - } - new ProblemDetailsValidator().ValidateAndThrow(details); - if (details.Status != null) context.HttpContext.Response.StatusCode = (int)details.Status; + context.HttpContext.Response.StatusCode = apiResponse.StatusCode; - Logger.LogTrace(message ?? nameof(T).Replace("Details", "").Trim()); - return new ObjectResult(details) + return new ObjectResult(apiResponse) { - StatusCode = details.Status + StatusCode = apiResponse.StatusCode }; } - private static ConstructorInfo GetExceptionContextConstructor(Type type) + public IActionResult GetResponse(ActionContext context, string? message = null, List? errors = null) + where T : IDetailBuilder, new() { - try + var (statusCode, title) = new T().GetDetails(); + + var apiResponse = new ApiErrorDetails { - return type.GetConstructor(new[] - { - typeof(ExceptionContext), - typeof(string), - typeof(string), - typeof(List) - }); - } - catch (Exception e) + Title = title, + StatusCode = statusCode, + Message = message ?? "There was an error encountered", + ContextDetails = _configurationService.GetContextDetails(context.HttpContext, errors) + }; + + context.HttpContext.Response.StatusCode = apiResponse.StatusCode; + + return new ObjectResult(apiResponse) { - throw new Exception($"Error creating constructor for type: {type}", e); - } + StatusCode = apiResponse.StatusCode + }; } -} +} \ No newline at end of file diff --git a/ExceptionAll/Services/ContextConfigurationService.cs b/ExceptionAll/Services/ContextConfigurationService.cs new file mode 100644 index 0000000..cb74067 --- /dev/null +++ b/ExceptionAll/Services/ContextConfigurationService.cs @@ -0,0 +1,25 @@ +using ExceptionAll.Models; + +namespace ExceptionAll.Services; + +public class ContextConfigurationService : IContextConfigurationService +{ + private Dictionary>? _contextMappings; + + public void Configure(Dictionary> configuration) + { + _contextMappings = new Dictionary>(configuration); + } + + public IReadOnlyDictionary? GetContextDetails(HttpContext context, List? errors = null) + { + var dictionary = _contextMappings?.ToDictionary(x => x.Key, x => x.Value(context)); + + if (errors is null || !errors.Any()) return dictionary; + + if (dictionary != null) dictionary.Add("Errors", errors); + else dictionary = new Dictionary { { "Errors", errors } }; + + return dictionary; + } +} \ No newline at end of file diff --git a/ExceptionAll/Services/ErrorResponseService.cs b/ExceptionAll/Services/ErrorResponseService.cs index 60b3b31..9759672 100644 --- a/ExceptionAll/Services/ErrorResponseService.cs +++ b/ExceptionAll/Services/ErrorResponseService.cs @@ -13,12 +13,15 @@ public ErrorResponseService(ILogger logger) public void AddErrorResponse(IErrorResponse response) { new ErrorResponseValidator().ValidateAndThrow(response); + if (ErrorResponses.ContainsKey(response.ExceptionType)) { - _logger.LogError("Cannot add response to service because an " + - $"error response already exists for the exception type: {response.ExceptionType}"); - throw new ArgumentException($"Exception type, {response.ExceptionType}, " + - "already exists in service collection"); + _logger.LogError( + "Cannot add response to service because an error response already exists for the exception type: {0}", + response.ExceptionType); + + throw new ArgumentException( + $"Exception type, {response.ExceptionType}, already exists in service collection"); } ErrorResponses.Add(response.ExceptionType, response); @@ -28,4 +31,4 @@ public Dictionary GetErrorResponses() { return ErrorResponses; } -} +} \ No newline at end of file diff --git a/ExceptionAll/Validation/ErrorResponseValidator.cs b/ExceptionAll/Validation/ErrorResponseValidator.cs index e83492d..bbd07aa 100644 --- a/ExceptionAll/Validation/ErrorResponseValidator.cs +++ b/ExceptionAll/Validation/ErrorResponseValidator.cs @@ -11,12 +11,6 @@ public ErrorResponseValidator() RuleFor(x => x.ExceptionType) .NotNull(); - RuleFor(x => x.DetailsType) - .Must(x => x.IsSubclassOf(typeof(BaseDetails))); - - RuleFor(x => x.DetailsType) - .NotNull(); - RuleFor(x => x.ErrorTitle) .NotEmpty(); } diff --git a/ExceptionAll/Validation/ProblemDetailsValidator.cs b/ExceptionAll/Validation/ProblemDetailsValidator.cs deleted file mode 100644 index e88ab00..0000000 --- a/ExceptionAll/Validation/ProblemDetailsValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace ExceptionAll.Validation; - -public class ProblemDetailsValidator : AbstractValidator where T : BaseDetails -{ - public ProblemDetailsValidator() - { - RuleFor(x => x.Status).NotNull(); - RuleFor(x => x.Status).GreaterThanOrEqualTo(100); - RuleFor(x => x.Status).LessThan(600); - RuleFor(x => x.Title).NotNull(); - RuleFor(x => x.Title).NotEmpty(); - } -} diff --git a/README.md b/README.md index e445e15..206793a 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,150 @@ # ExceptionAll -Lightweight extension for adding structured, global error handling to Web API solutions using .NET Core +ExceptionAll is a library for adding structured, global error handling to Web API solutions using .NET Core. It helps reduce code noise by removing the need for 'try-catch' code blocks as well as a singluar source to configure how all exception types are handled, logged, and returned to an API consumer. The package comes with out of the box Swagger example responses to let the developer focus on the code rather than documentation. The configuration of the error responses is also done in a fluent manner, making the code more readable for developers. -# Setup -In Startup.cs add the following namespaces: -``` -using ExceptionAll.Helpers; -using ExceptionAll.Interfaces; -``` +## Table of Contents +- [ExceptionAll](#exceptionall) + - [Table of Contents](#table-of-contents) + - [Setup](#setup) + - [Example Code](#example-code) + - [Extending ExceptionAll](#extending-exceptionall) -In Startup.cs under 'ConfigureServices': +## Setup +1. Create an ExceptionAll configuration class + 1. ErrorResponses + - A list of the types of error responses for specific error types encountered. + 2. ContextConfiguration + - Allows the developer to extend the standard response object by adding details from the HttpContext. Below are some examples various examples. -```csharp -services.AddExceptionAll() + ```csharp + public class ExceptionAllConfiguration : IExceptionAllConfiguration + { + public List ErrorResponses => new() + { + ErrorResponse + .CreateErrorResponse() + .WithTitle("Argument Null Exception") + .WithStatusCode(500) + .WithMessage("The developer goofed") + .ForException() + .WithLogAction((x, e) => x.LogDebug(e, "Oops I did it again")) + }; -// This section is optional. I choose to use this option to keep my objects from returning nulls -.AddJsonOptions(options => -{ - options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; -}); -``` + public Dictionary>? ContextConfiguration => new() + { + { "Path", x => x?.Request.Path.Value ?? string.Empty }, + { "Query", x => x.Request.QueryString.Value ?? string.Empty }, + { "TraceIdentifier", x => x?.TraceIdentifier ?? string.Empty }, + { "LocalIpAddress", x => x?.Connection.LocalIpAddress?.ToString() ?? string.Empty }, + { "CorrelationId", x => x.Request.Headers["x-correlation-id"].ToString() } + }; + } + ``` -In Startup.cs under 'Configure' +2. In Program.cs add the following namespaces: + + ```csharp + using ExceptionAll.Helpers; + ``` -```csharp -// Inject the ErrorResponse and ActionResult service interfaces - public void Configure(IApplicationBuilder app, - IErrorResponseService errorResponseService) - { - // Adds a global response for a unique Error type - // You can call 'AddErrorResponse' for every exception you would like - // to globally handle - errorResponseService.AddErrorResponse( - ErrorResponse - - // Inject our action result service, mainly for passing - // our optional logging action - .CreateErrorResponse(actionResultService) - - // Error title returned - .WithTitle("Bad Request - Fluent Validation") - - // Type of error this response handles - .ForException() - - // Returned object. Must inherit from Microsoft.AspNetCore.Mvc.ProblemDetails - // Different 'Detail' objects are used to allow for support in Swagger documentation - .WithReturnType() - - // Allows developer to choose what level of logging happens for the - // specific exception, if desired. If no desire to log, you don't have - // to declare an Action - .WithLogAction((x, e) => x.LogError("Something bad happened", e)) - ); - - // ExceptionAll also comes with an extension method which allows - // adding a list of IErrorResponses. This can be used if the developer - // desires to migrate code to an outside static class - errorResponseService.AddErrorResponses(ExceptionAllConfiguration.GetErrorResponses()); -``` +3. In Program.cs: -# Custom Detail Responses -ExceptionAll provides some standard detail objects out of the box. If you as a developer need to extend the library -and create additional detail types, follow the below example as a template: + ```csharp + builder.Services + .AddExceptionAll() + .WithExceptionAllSwaggerExamples(); // optional, adds the Swagger response examples -```csharp -using ExceptionAll.Dtos; -using ExceptionAll.Helpers; + // more dependency injection here + // ... + + // the standard .NET 6 WebApplicationBuilder + var app = builder.Build(); + + app.Services.AddExceptionAll(); + ``` -public class BadRequestDetails : ProblemDetails +## Example Code +1. The standard API response + 1. API Controller code + + ```csharp + [HttpGet] + public async Task GetAll() + { + await Task.Delay(0); + var rng = new Random(); + var result = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = DateTime.Now.AddDays(index), + TemperatureC = rng.Next(-20, 55), + Summary = Summaries[rng.Next(Summaries.Length)] + }).ToArray(); + + throw new Exception("This is simulating an uncaught exception"); + } + ``` + 2. Api Response + + ![alt text](ReadMeImages\v4\ApiControllerStandardResponse.PNG) + +2. Catching an exception configured in our error response container + 1. API Controller code + + ```csharp + [HttpGet] + [Route("api/GetNullRefError")] + public async Task GetNullRefError(string param, string otherParam) + { + param = null; + await Task.Delay(0); + throw new ArgumentNullException(nameof(param)); + } + ``` + 2. API Response. Matches what we see in our setup seen further up on the page. + + ![alt text](ReadMeImages\v4\ArgumentNullRefResponse.PNG) + +3. Getting around ExceptionAll, since you might have special cases you don't want to see its responses + 1. API Controller code + ```csharp + [HttpGet] + [Route("api/GetWithoutExceptionAllError")] + public async Task GetWithoutExceptionAllError() { - // Action context is utitilized during developer caught exceptions - public BadRequestDetails(ActionContext context, string title = null, string message = null, List errors = null) + await Task.Delay(0); + try { - if (context is null) throw new ArgumentNullException(nameof(context)); - Title = string.IsNullOrEmpty(title) == false ? title : "Bad Request"; - Instance = context.HttpContext.Request.Path; - Status = StatusCodes.Status400BadRequest; - Detail = string.IsNullOrEmpty(message) == false ? message : "See errors or logs for more details"; - this.AddDefaultExtensionsFromContext(context, errors); + throw new Exception("Some exception"); } - - // Exception context is utilized during global, filter-caught exceptions - public BadRequestDetails(ExceptionContext context, string title = null, string message = null, List errors = null) + catch (Exception e) { - if (context is null) throw new ArgumentNullException(nameof(context)); - Title = string.IsNullOrEmpty(title) == false ? title : "Bad Request"; - Instance = context.HttpContext.Request.Path; - Status = StatusCodes.Status400BadRequest; - Detail = string.IsNullOrEmpty(message) == false ? message : "See errors or logs for more details"; - this.AddDefaultExtensionsFromContext(context, errors); + Console.WriteLine(e); + return BadRequest(e.Message); } } -``` + ``` + 2. API Response + + ![alt text](ReadMeImages\v4\NonExceptionAllResponse.PNG) + +4. Manual response generation, for times developers want to return caught exceptions + 1. API Controller code -You can also add additional properties in the 'Extension' property inherited from ProblemDetails if you don't feel the given properties are enough. +## Extending ExceptionAll +ExceptionAll provides some standard detail objects out of the box, one of which is shown below. If you as a developer need to extend the library +and create additional detail types, follow the below example as a template and implement the IExceptionAllDetails interface on your custom object. The main reason for needing to create your own details is for swagger response object documentation. + +```csharp + +public class BadGatewayDetails : IExceptionAllDetails +{ + public string Title => GetDetails().Title; + public int StatusCode => GetDetails().StatusCode; + public string Message { get; init; } = string.Empty; + public IReadOnlyDictionary? ContextDetails { get; init; } + public (int StatusCode, string Title) GetDetails() + { + return (502, "Bad Gateway"); + } +} +``` diff --git a/ReadMeImages/v4/ApiControllerStandardResponse.PNG b/ReadMeImages/v4/ApiControllerStandardResponse.PNG new file mode 100644 index 0000000..e5fbe33 Binary files /dev/null and b/ReadMeImages/v4/ApiControllerStandardResponse.PNG differ diff --git a/ReadMeImages/v4/ArgumentNullRefResponse.PNG b/ReadMeImages/v4/ArgumentNullRefResponse.PNG new file mode 100644 index 0000000..15f9b72 Binary files /dev/null and b/ReadMeImages/v4/ArgumentNullRefResponse.PNG differ diff --git a/ReadMeImages/v4/NonExceptionAllResponse.PNG b/ReadMeImages/v4/NonExceptionAllResponse.PNG new file mode 100644 index 0000000..4559e36 Binary files /dev/null and b/ReadMeImages/v4/NonExceptionAllResponse.PNG differ diff --git a/nuget.config.bak b/nuget.config.bak new file mode 100644 index 0000000..613b592 --- /dev/null +++ b/nuget.config.bak @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file