Skip to content

Commit

Permalink
Add support for custom error objects (#25)
Browse files Browse the repository at this point in the history
Add tests
Update readme
Bump version
  • Loading branch information
Frogvall authored Feb 27, 2023
1 parent b0968d2 commit b54c02c
Show file tree
Hide file tree
Showing 10 changed files with 205 additions and 7 deletions.
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ There are also support packages included in this repo that extends the exception
- [Installing the package](#installing-the-package)
- [Extension packages](#extension-packages)
- [Using the exception handler](#using-the-exception-handler)
- [Custom error object](#custom-error-object)
- [Exception listeners](#exception-listeners)
- [Adding the exception mapper](#adding-the-exception-mapper)
- [Mapping profiles](#mapping-profiles)
Expand Down Expand Up @@ -50,7 +51,7 @@ Or add it to your csproj file.
```xml
<ItemGroup>
...
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling" Version="7.0.0" />
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling" Version="7.1.0" />
...
</ItemGroup>
```
Expand Down Expand Up @@ -101,6 +102,21 @@ app.UseApiExceptionHandler();

Since middlewares are dependent on the order they are executed, make sure that any middleware that executes before the exception handler middleware can never throw an exception. If that happens you service will terminate.

### Custom error object

This package comes with a predefined opiniated error object, which might not be what you need in order to fulfil certain demands on your API. In order to change how the resulting error output should look, you can pass a function to the exception handler or exception filter.

```csharp
services.AddControllers(mvcOptions =>
{
mvcOptions.Filters.AddApiExceptionFilter((error, statusCode) => myErrorObject(error, statusCode));
});
```

```csharp
app.UseApiExceptionHandler((error, statusCode) => myErrorObject(error, statusCode));
```

### Exception listeners

Sometimes you want to do some things when the exception handler catches an exception. One such example could be that you would want to add exception metadata to your tracing context, for example Amazon XRay. In order to do so, you can pass one or several actions to the exception handler middleware and filter. The actions will be executed when an exception is caught, before the http response is built.
Expand Down Expand Up @@ -203,7 +219,7 @@ or
```xml
<ItemGroup>
...
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling.ModelValidation" Version="7.0.0" />
<PackageReference Include="Frogvall.AspNetCore.ExceptionHandling.ModelValidation" Version="7.1.0" />
...
</ItemGroup>
```
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Frogvall.AspNetCore.ExceptionHandling.Mapper;
using Microsoft.AspNetCore.Diagnostics;
Expand All @@ -8,6 +7,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using System.Text.Json;
using System.Net;

namespace Frogvall.AspNetCore.ExceptionHandling.ExceptionHandling
{
Expand All @@ -16,15 +16,17 @@ internal class ApiExceptionHandler
private readonly IExceptionMapper _mapper;
private readonly IHostEnvironment _env;
private readonly ILogger<ApiExceptionHandler> _logger;
private readonly CustomApiErrorResolver _resolver;
private readonly Action<Exception>[] _exceptionListeners;
private readonly JsonSerializerOptions _serializerOptions;

internal ApiExceptionHandler(IExceptionMapper mapper, IHostEnvironment env,
ILogger<ApiExceptionHandler> logger, Action<Exception>[] exceptionListeners)
ILogger<ApiExceptionHandler> logger, CustomApiErrorResolver resolver, Action<Exception>[] exceptionListeners)
{
_mapper = mapper;
_env = env;
_logger = logger;
_resolver = resolver;
_exceptionListeners = exceptionListeners;
_serializerOptions = new JsonSerializerOptions
{
Expand All @@ -39,7 +41,16 @@ internal async Task ExceptionHandler(HttpContext context)
if (ex == null) return;

var error = ApiErrorFactory.Build(context, ex, _mapper, _logger, _env.IsDevelopment(), _exceptionListeners);
await JsonSerializer.SerializeAsync(context.Response.Body, error, _serializerOptions);

if (_resolver == null)
{
await JsonSerializer.SerializeAsync(context.Response.Body, error, _serializerOptions);
}
else
{
var customErrorObject = _resolver.Resolve(error, (HttpStatusCode)context.Response.StatusCode);
await JsonSerializer.SerializeAsync(context.Response.Body, customErrorObject, _serializerOptions);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Net;

namespace Frogvall.AspNetCore.ExceptionHandling.ExceptionHandling
{
internal sealed record CustomApiErrorResolver
{
private readonly Func<ApiError, HttpStatusCode, object> _customApiErrorFunction;

internal CustomApiErrorResolver(Func<ApiError, HttpStatusCode, object> customApiErrorFunction)
{
_customApiErrorFunction = customApiErrorFunction;
}

internal object Resolve(ApiError error, HttpStatusCode statusCode)
{
return _customApiErrorFunction(error, statusCode);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using System.Net;

namespace Microsoft.AspNetCore.Builder
{
Expand All @@ -17,6 +18,21 @@ public static IApplicationBuilder UseApiExceptionHandler(this IApplicationBuilde
builder.ApplicationServices.GetRequiredService<IExceptionMapper>(),
builder.ApplicationServices.GetRequiredService<IHostEnvironment>(),
builder.ApplicationServices.GetRequiredService<ILogger<ApiExceptionHandler>>(),
null,
exceptionListeners)
.ExceptionHandler
});
}

public static IApplicationBuilder UseApiExceptionHandler(this IApplicationBuilder builder, Func<ApiError, HttpStatusCode, Object> customErrorObjectFunction, params Action<Exception>[] exceptionListeners)
{
return builder.UseExceptionHandler(new ExceptionHandlerOptions
{
ExceptionHandler = new ApiExceptionHandler(
builder.ApplicationServices.GetRequiredService<IExceptionMapper>(),
builder.ApplicationServices.GetRequiredService<IHostEnvironment>(),
builder.ApplicationServices.GetRequiredService<ILogger<ApiExceptionHandler>>(),
new CustomApiErrorResolver(customErrorObjectFunction),
exceptionListeners)
.ExceptionHandler
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Net;
using Frogvall.AspNetCore.ExceptionHandling.ExceptionHandling;
using Frogvall.AspNetCore.ExceptionHandling.Filters;

namespace Microsoft.AspNetCore.Mvc.Filters
Expand All @@ -9,5 +11,10 @@ public static void AddApiExceptionFilter(this FilterCollection filters, params A
{
filters.Add(new ApiExceptionFilter(exceptionListeners));
}

public static void AddApiExceptionFilter(this FilterCollection filters, Func<ApiError, HttpStatusCode, Object> customErrorObjectFunction, params Action<Exception>[] exceptionListeners)
{
filters.Add(new ApiExceptionFilter(customErrorObjectFunction, exceptionListeners));
}
}
}
43 changes: 43 additions & 0 deletions src/aspnetcore-exceptionhandler/Filters/ApiExceptionFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Net;

namespace Frogvall.AspNetCore.ExceptionHandling.Filters
{
Expand All @@ -17,6 +18,12 @@ public ApiExceptionFilter(params Action<Exception>[] exceptionListeners) : base(
// ReSharper disable once CoVariantArrayConversion
Arguments = new object[] { exceptionListeners };
}

public ApiExceptionFilter(Func<ApiError, HttpStatusCode, Object> customErrorObjectFunction, params Action<Exception>[] exceptionListeners) : base(typeof(ApiExceptionFilterWithResolverImpl))
{
// ReSharper disable once CoVariantArrayConversion
Arguments = new object[] { new CustomApiErrorResolver(customErrorObjectFunction), exceptionListeners };
}

private class ApiExceptionFilterImpl : ExceptionFilterAttribute
{
Expand Down Expand Up @@ -47,7 +54,43 @@ public override async Task OnExceptionAsync(ExceptionContext context)

var error = ApiErrorFactory.Build(context.HttpContext, ex, _mapper, _logger, _env.IsDevelopment(), _exceptionListeners);
await JsonSerializer.SerializeAsync(context.HttpContext.Response.Body, error, _serializerOptions);
context.ExceptionHandled = true;
}
}

private class ApiExceptionFilterWithResolverImpl : ExceptionFilterAttribute
{
private readonly IExceptionMapper _mapper;
private readonly IHostEnvironment _env;
private readonly ILogger<ApiExceptionFilter> _logger;
private readonly CustomApiErrorResolver _resolver;
private readonly Action<Exception>[] _exceptionListeners;
private readonly JsonSerializerOptions _serializerOptions;

public ApiExceptionFilterWithResolverImpl(IExceptionMapper mapper, IHostEnvironment env,
ILogger<ApiExceptionFilter> logger, CustomApiErrorResolver resolver,
Action<Exception>[] exceptionListeners)
{
_mapper = mapper;
_env = env;
_logger = logger;
_resolver = resolver;
_exceptionListeners = exceptionListeners;
_serializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
}

public override async Task OnExceptionAsync(ExceptionContext context)
{
var ex = context.Exception;
if (ex == null) return;

var error = ApiErrorFactory.Build(context.HttpContext, ex, _mapper, _logger, _env.IsDevelopment(), _exceptionListeners);
var customErrorObject = _resolver.Resolve(error, (HttpStatusCode)context.HttpContext.Response.StatusCode);
await JsonSerializer.SerializeAsync(context.HttpContext.Response.Body, customErrorObject, _serializerOptions);
context.ExceptionHandled = true;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<VersionPrefix>7.0.1</VersionPrefix>
<VersionPrefix>7.1.0</VersionPrefix>
<TargetFramework>net6.0</TargetFramework>
<RootNamespace>Frogvall.AspNetCore.ExceptionHandling</RootNamespace>
<AssemblyName>ExceptionHandling</AssemblyName>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ namespace Frogvall.AspNetCore.ExceptionHandling.Test.Helpers
public enum ServerType
{
Mvc,
Controllers
Controllers,
ControllersWithCustomErrorObject
}
}
55 changes: 55 additions & 0 deletions test/aspnetcore-exceptionhandler-test/TestExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ private HttpClient SetupServer(ServerType serverType, bool useExceptionHandlerFi
return SetupServerWithMvc(useExceptionHandlerFilter, addExceptionListener, testServiceName);
case ServerType.Controllers:
return SetupServerWithControllers(useExceptionHandlerFilter, addExceptionListener, testServiceName);
case ServerType.ControllersWithCustomErrorObject:
return SetupServerWithCustomErrorObject(useExceptionHandlerFilter, addExceptionListener, testServiceName);
default:
throw new NotImplementedException();;
}
Expand Down Expand Up @@ -105,6 +107,36 @@ private HttpClient SetupServerWithControllers(bool useExceptionHandlerFilter, bo
options);
}

private HttpClient SetupServerWithCustomErrorObject(bool useExceptionHandlerFilter, bool addExceptionListener, string testServiceName)
{
var options = new ExceptionMapperOptions
{
RespondWithDeveloperContext = true
};
if (testServiceName != null) options.ServiceName = testServiceName;
return ServerHelper.SetupServerWithControllers(
options =>
{
options.EnableEndpointRouting = false;
options.Filters.AddValidateModelFilter(1337);
if (useExceptionHandlerFilter && addExceptionListener) options.Filters.AddApiExceptionFilter((error, statusCode) => new TestCustomErrorObject(error, statusCode), ex => _exceptionSetByExceptionListener = ex, ex => throw new Exception("Should not crash the application."));
else if (useExceptionHandlerFilter) options.Filters.AddApiExceptionFilter((error, statusCode) => new TestCustomErrorObject(error, statusCode));
},
app =>
{
if (!useExceptionHandlerFilter && addExceptionListener) app.UseApiExceptionHandler((error, statusCode) => new TestCustomErrorObject(error, statusCode), ex => _exceptionSetByExceptionListener = ex);
else if (!useExceptionHandlerFilter) app.UseApiExceptionHandler((error, statusCode) => new TestCustomErrorObject(error, statusCode));
app.UseMiddleware<TestAddCustomHeaderMiddleware>();
app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
},
_output,
options);
}

[Theory]
[InlineData(ServerType.Mvc, true, true, null)]
[InlineData(ServerType.Controllers, true, true, null)]
Expand Down Expand Up @@ -318,5 +350,28 @@ public async Task GetCancellationTest_Always_ReturnsFault(ServerType serverType,
error.Service.Should().Be(expectedServiceName);
error.DeveloperContext.Should().BeNull();
}

[Theory]
[InlineData(ServerType.ControllersWithCustomErrorObject, true, true, null)]
[InlineData(ServerType.ControllersWithCustomErrorObject, true, false, null)]
[InlineData(ServerType.ControllersWithCustomErrorObject, false, false, null)]
public async Task PostTest_CustomErrorObjectFunction_ReturnsCustomErrorObject(ServerType serverType, bool useExceptionHandlerFilter, bool addExceptionListener, string testServiceName)
{
//Arrange
var client = SetupServer(serverType, useExceptionHandlerFilter, addExceptionListener, testServiceName);
var content = new StringContent($@"{{""NullableObject"": ""string"", ""NonNullableObject"": 5}}", Encoding.UTF8, "text/json");

// Act
var response = await client.PostAsync("/api/Test", content);
var response1 = await response.Content.ReadAsStringAsync();
var error = JsonSerializer.Deserialize<TestCustomErrorObject>(await response.Content.ReadAsStringAsync(), _serializerOptions);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
error.Status.Should().Be("400");
error.Type.Should().Be("Frogvall.AspNetCore.ExceptionHandling.Test.TestResources.TestEnum.MyThirdValue");
error.Detail.Should().Be("Object > 4");
error.Title.Should().Be("Bad request");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Net;
using Frogvall.AspNetCore.ExceptionHandling.ExceptionHandling;

namespace Frogvall.AspNetCore.ExceptionHandling.Test.TestResources
{
public sealed record TestCustomErrorObject
{
public TestCustomErrorObject(){}

public TestCustomErrorObject(ApiError error, HttpStatusCode statusCode)
{
Detail = error.DetailedMessage;
Status = $"{(int)statusCode}";
Title = error.Message;
TraceId = error.CorrelationId;
Type = error.Error;
}

public string Detail { get; set; }

public string Status { get; set; }

public string Title { get; set; }

public string TraceId { get; set; }

public string Type { get; set; }
}
}

0 comments on commit b54c02c

Please sign in to comment.