Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve global error handling #16

Merged
merged 13 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@
<ItemGroup Label="Code Analyzers">
<PackageReference Include="AsyncFixer" Version="1.6.0" PrivateAssets="All" />
<PackageReference Include="Asyncify" Version="0.9.7" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.149" PrivateAssets="All" />
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.507" PrivateAssets="All" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.23.1.88495" PrivateAssets="All" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429" PrivateAssets="All" />
</ItemGroup>

</Project>
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,36 @@ An example of how to configure the middleware.

```csharp
var app = builder.Build();

app.UseMiddleware<GlobalErrorHandlingMiddleware>();
```

An example of how to configure the middleware with options.

```csharp
var app = builder.Build();

var options = new GlobalErrorHandlingMiddlewareOptions
{
IncludeException = true,
UseProblemDetailsAsResponseBody = false,
};

app.UseMiddleware<GlobalErrorHandlingMiddleware>(options);
```

An example of how to configure the middleware with options through a extension method `UseGlobalErrorHandler`.

```csharp
var app = builder.Build();

app.UseGlobalErrorHandler(options =>
{
options.IncludeException = true;
options.UseProblemDetailsAsResponseBody = false;
});
```

# Sample Project

The sample project `Demo.Api` located in the [sample](/sample/) folder within the repository illustrates a practical implementation of the Atc.Rest.MinimalApi package, showcasing all the features and best practices detailed in this documentation. It's a comprehensive starting point for those looking to get a hands-on understanding of how to effectively utilize the library in real-world applications.
Expand Down
4 changes: 2 additions & 2 deletions sample/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@
<ItemGroup Label="Code Analyzers">
<PackageReference Include="AsyncFixer" Version="1.6.0" PrivateAssets="All" />
<PackageReference Include="Asyncify" Version="0.9.7" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.146" PrivateAssets="All" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.149" PrivateAssets="All" />
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.507" PrivateAssets="All" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.23.1.88495" PrivateAssets="All" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429" PrivateAssets="All" />
</ItemGroup>

</Project>
6 changes: 3 additions & 3 deletions sample/src/Demo.Api.Contracts/Demo.Api.Contracts.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@

<ItemGroup>
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageReference Include="Atc" Version="2.0.465" />
<PackageReference Include="Atc.Rest.MinimalApi" Version="1.0.60" />
<PackageReference Include="Atc" Version="2.0.472" />
<PackageReference Include="Atc.Rest.MinimalApi" Version="1.0.65" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4" />
<PackageReference Include="Microsoft.NETCore.Platforms" Version="7.0.4" />
</ItemGroup>

Expand Down
2 changes: 1 addition & 1 deletion sample/src/Demo.Api/Demo.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Atc" Version="2.0.465" />
<PackageReference Include="Atc" Version="2.0.472" />
<PackageReference Include="Microsoft.NETCore.Platforms" Version="7.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
Expand Down
8 changes: 4 additions & 4 deletions sample/src/Demo.Domain/Demo.Domain.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="AsyncEnumerator" Version="4.0.2" />
<PackageReference Include="Atc" Version="2.0.465" />
<PackageReference Include="Atc.Azure.Options" Version="3.0.28" />
<PackageReference Include="Atc" Version="2.0.472" />
<PackageReference Include="Atc.Azure.Options" Version="3.0.31" />
<PackageReference Include="Atc.Cosmos" Version="1.1.40" />
<PackageReference Include="Atc.Rest.MinimalApi" Version="1.0.60" />
<PackageReference Include="Atc.Rest.MinimalApi" Version="1.0.65" />
<PackageReference Include="Bogus" Version="35.5.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Mapster" Version="7.4.0" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PackageReference Include="xunit" Version="2.7.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PackageReference Include="xunit" Version="2.7.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
4 changes: 2 additions & 2 deletions sample/test/Demo.Domain.Tests/Demo.Domain.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PackageReference Include="xunit" Version="2.7.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
4 changes: 2 additions & 2 deletions src/Atc.Rest.MinimalApi/Atc.Rest.MinimalApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@

<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="Atc" Version="2.0.465" />
<PackageReference Include="Atc" Version="2.0.472" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="MiniValidation" Version="0.9.0" />
<PackageReference Include="MiniValidation" Version="0.9.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.5.0" />
</ItemGroup>

Expand Down
23 changes: 23 additions & 0 deletions src/Atc.Rest.MinimalApi/Extensions/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Atc.Rest.MinimalApi.Extensions;

/// <summary>
/// Provides extension methods for the <see cref="WebApplication"/> class to enhance its functionality.
/// </summary>
public static class WebApplicationExtensions
{
/// <summary>
/// Adds a global error handler middleware to the specified web application.
/// This middleware is designed to catch and process unhandled exceptions globally.
/// </summary>
/// <param name="app">The <see cref="WebApplication"/> to which the error handling middleware is added.</param>
/// <param name="configureOptions">An optional action to configure the <see cref="GlobalErrorHandlingOptions"/>.</param>
/// <returns>The <see cref="IApplicationBuilder"/> with the error handling middleware configured.</returns>
public static IApplicationBuilder UseGlobalErrorHandler(
this WebApplication app,
Action<GlobalErrorHandlingOptions>? configureOptions = null)
{
var options = new GlobalErrorHandlingOptions();
configureOptions?.Invoke(options);
return app.UseMiddleware<GlobalErrorHandlingMiddleware>(options);
}
}
2 changes: 2 additions & 0 deletions src/Atc.Rest.MinimalApi/GlobalUsings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
global using Atc.Rest.MinimalApi.Abstractions;
global using Atc.Rest.MinimalApi.Extensions;
global using Atc.Rest.MinimalApi.Extensions.Internal;
global using Atc.Rest.MinimalApi.Middleware;
global using Atc.Rest.MinimalApi.Options;
global using FluentValidation;

global using Microsoft.AspNetCore.Builder;
Expand Down
105 changes: 94 additions & 11 deletions src/Atc.Rest.MinimalApi/Middleware/GlobalErrorHandlingMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ namespace Atc.Rest.MinimalApi.Middleware;
/// </summary>
public sealed partial class GlobalErrorHandlingMiddleware
{
private readonly GlobalErrorHandlingOptions options;
private readonly RequestDelegate next;

/// <summary>
/// Initializes a new instance of the <see cref="GlobalErrorHandlingMiddleware"/> class.
/// </summary>
/// <param name="next">The delegate representing the remaining middleware in the request pipeline.</param>
/// <param name="options">The options for this middleware.</param>
public GlobalErrorHandlingMiddleware(
RequestDelegate next)
RequestDelegate next,
GlobalErrorHandlingOptions? options = null)
{
this.options = options ?? new GlobalErrorHandlingOptions();

this.next = next;
}

Expand Down Expand Up @@ -42,14 +47,17 @@ public async Task Invoke(
/// <param name="context">The <see cref="HttpContext"/> for the current request.</param>
/// <param name="exception">The exception to handle.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
private static Task HandleExceptionAsync(
private Task HandleExceptionAsync(
HttpContext context,
Exception exception)
{
var statusCode = GetHttpStatusCodeByExceptionType(exception);
context.Response.ContentType = MediaTypeNames.Application.Json;
context.Response.StatusCode = (int)statusCode;
var exceptionResult = JsonSerializer.Serialize(CreateProblemDetails(context, exception, statusCode));

var exceptionResult = options.UseProblemDetailsAsResponseBody
? JsonSerializer.Serialize(CreateProblemDetails(context, exception, statusCode))
: CreateMessage(context, exception, statusCode);

return context.Response.WriteAsync(exceptionResult, context.RequestAborted);
}
Expand All @@ -66,7 +74,8 @@ private static HttpStatusCode GetHttpStatusCodeByExceptionType(

var exceptionType = exception.GetType();
if (exceptionType == typeof(FluentValidation.ValidationException) ||
exceptionType == typeof(System.ComponentModel.DataAnnotations.ValidationException))
exceptionType == typeof(System.ComponentModel.DataAnnotations.ValidationException) ||
exceptionType == typeof(BadHttpRequestException))
{
statusCode = HttpStatusCode.BadRequest;
}
Expand All @@ -93,23 +102,83 @@ private static HttpStatusCode GetHttpStatusCodeByExceptionType(
/// <param name="exception">The exception to include in the problem details.</param>
/// <param name="statusCode">The HTTP status code for the response.</param>
/// <returns>A <see cref="ProblemDetails"/> object representing the error details.</returns>
private static ProblemDetails CreateProblemDetails(
private ProblemDetails CreateProblemDetails(
HttpContext context,
Exception exception,
HttpStatusCode statusCode)
{
var result = new ProblemDetails
{
Status = (int)statusCode,
Title = EnsurePascalCaseAndSpacesBetweenWordsRegex().Replace(statusCode.ToString(), " $0"),
Detail = exception.GetMessage(includeInnerMessage: true, includeExceptionName: true),
Title = statusCode.ToNormalizedString(),
};

if (exception is not null)
{
result.Detail = UseSimpleMessage(exception)
? exception.GetMessage()
: exception.GetMessage(includeInnerMessage: true, includeExceptionName: true);
}

SetExtensionFields(result, context);

return result;
}

/// <summary>
/// Creates a message like a problem details object to include in the error response.
/// </summary>
/// <param name="context">The <see cref="HttpContext"/> for the current request.</param>
/// <param name="exception">The exception to include in the problem details.</param>
/// <param name="statusCode">The HTTP status code for the response.</param>
/// <returns>A <see cref="ProblemDetails"/> object representing the error details.</returns>
private string CreateMessage(
HttpContext context,
Exception exception,
HttpStatusCode statusCode)
{
var sb = new StringBuilder();

sb.AppendLine("{");
sb.Append(2, "status: ");
sb.AppendLine(((int)statusCode).ToString(GlobalizationConstants.EnglishCultureInfo));
sb.Append(2, "title: ");
sb.AppendLine(statusCode.ToNormalizedString());

if (exception is not null)
{
sb.Append(2, "detail: ");
sb.AppendLine(UseSimpleMessage(exception)
? exception.GetMessage()
: exception.GetMessage(includeInnerMessage: true, includeExceptionName: true));
}

var correlationId = context.GetCorrelationId();
if (!string.IsNullOrEmpty(correlationId))
{
sb.Append(2, "correlationId: ");
sb.AppendLine(correlationId);
}

var requestId = context.GetRequestId();
if (!string.IsNullOrEmpty(requestId))
{
sb.Append(2, "requestId: ");
sb.AppendLine(requestId);
}

var traceId = context.TraceIdentifier;
if (!string.IsNullOrEmpty(traceId))
{
sb.Append(2, "traceId: ");
sb.AppendLine(traceId);
}

sb.Append('}');

return sb.ToString();
}

/// <summary>
/// Sets extension fields in the problem details object.
/// </summary>
Expand Down Expand Up @@ -140,9 +209,23 @@ private static void SetExtensionFields(
}

/// <summary>
/// Generates a regular expression that ensures pascal casing and spaces between words.
/// Determines whether a simple message should be used based on the type of the exception.
/// </summary>
/// <returns>A <see cref="Regex"/> object.</returns>
[GeneratedRegex("(?<=[a-z])([A-Z])", RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 1000)]
private static partial Regex EnsurePascalCaseAndSpacesBetweenWordsRegex();
/// <param name="exception">The exception to evaluate.</param>
/// <returns>
/// <see langword="true"/> if a simple message should be used for the specified exception types; otherwise, <see langword="false"/>.
/// </returns>
private bool UseSimpleMessage(
Exception exception)
{
if (!options.IncludeException)
{
return true;
}

var exceptionType = exception.GetType();
return exceptionType == typeof(BadHttpRequestException) ||
exceptionType == typeof(UnauthorizedAccessException) ||
exceptionType == typeof(NotImplementedException);
}
}
29 changes: 29 additions & 0 deletions src/Atc.Rest.MinimalApi/Options/GlobalErrorHandlingOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace Atc.Rest.MinimalApi.Options;

/// <summary>
/// Provides configuration settings for the global error handling middleware.
/// </summary>
public class GlobalErrorHandlingOptions
{
/// <summary>
/// Gets or sets a value indicating whether the exception details should be included in the error response.
/// Defaults to <see langword="true"/>.
/// </summary>
/// <value>
/// <see langword="true"/> if exception details should be included; otherwise, <see langword="false"/>.
/// </value>
public bool IncludeException { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether to use problem details for the response body when handling errors.
/// Defaults to <see langword="true"/>.
/// </summary>
/// <value>
/// <see langword="true"/> if problem details should be used as the response body; otherwise, <see langword="false"/>.
/// </value>
public bool UseProblemDetailsAsResponseBody { get; set; } = true;

/// <inheritdoc />
public override string ToString()
=> $"{nameof(IncludeException)}: {IncludeException}, {nameof(UseProblemDetailsAsResponseBody)}: {UseProblemDetailsAsResponseBody}";
}
Loading