Skip to content

Commit 7e10631

Browse files
committed
feat: add global exception handling middleware with RFC 7807 support
- Create ExceptionMiddleware with RFC 7807 Problem Details format - Map exception types to appropriate HTTP status codes - Include stack traces in Development environment only - Add structured logging with Serilog integration - Register middleware in pipeline after Serilog request logging - Cache JsonSerializerOptions instance to avoid CA1869 warning - Exclude Middleware folder from Codecov and Codacy coverage - Maintain backward compatibility with existing validation handling
1 parent 6477165 commit 7e10631

File tree

5 files changed

+157
-31
lines changed

5 files changed

+157
-31
lines changed

.codacy.yml

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,34 @@
33
# Uses Java glob syntax: https://docs.oracle.com/javase/tutorial/essential/io/fileOps.html#glob
44

55
exclude_paths:
6-
76
# Ignore all root-level metadata and documentation
8-
- '.gitignore'
9-
- '.runsettings'
10-
- 'LICENSE'
11-
- 'README.md'
7+
- ".gitignore"
8+
- ".runsettings"
9+
- "LICENSE"
10+
- "README.md"
1211

1312
# Ignore all file types that shouldn't be analyzed
14-
- '**.yml'
15-
- '**.json'
16-
- '**.png'
17-
- '**.sln'
18-
- '**.csproj'
13+
- "**.yml"
14+
- "**.json"
15+
- "**.png"
16+
- "**.sln"
17+
- "**.csproj"
1918

2019
# Ignore generated or infrastructure files
21-
- '**/*Program.cs'
20+
- "**/*Program.cs"
2221

2322
# Ignore specific folders across any depth in the project
24-
- '**/Configurations/**'
25-
- '**/Data/**'
26-
- '**/Enums/**'
27-
- '**/Extensions/**'
28-
- '**/Mappings/**'
29-
- '**/Migrations/**'
30-
- '**/Models/**'
31-
- '**/Properties/**'
32-
- '**/Repositories/**'
33-
- '**/Utilities/**'
34-
- '**/Validators/**'
35-
- 'test/**/*'
36-
- 'scripts/**/*'
23+
- "**/Configurations/**"
24+
- "**/Data/**"
25+
- "**/Enums/**"
26+
- "**/Extensions/**"
27+
- "**/Mappings/**"
28+
- "**/Middlewares/**"
29+
- "**/Migrations/**"
30+
- "**/Models/**"
31+
- "**/Properties/**"
32+
- "**/Repositories/**"
33+
- "**/Utilities/**"
34+
- "**/Validators/**"
35+
- "test/**/*"
36+
- "scripts/**/*"

codecov.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
21
# Codecov Repository YAML
32
# https://docs.codecov.com/docs/codecov-yaml
43

54
coverage:
6-
# https://docs.codecov.com/docs/commit-status
5+
# https://docs.codecov.com/docs/commit-status
76
status:
87
project:
98
default:
@@ -29,11 +28,11 @@ component_management:
2928
- component_id: controllers
3029
name: Controllers
3130
paths:
32-
- 'src/Dotnet.Samples.AspNetCore.WebApi/Controllers/'
31+
- "src/Dotnet.Samples.AspNetCore.WebApi/Controllers/"
3332
- component_id: services
3433
name: Services
3534
paths:
36-
- 'src/Dotnet.Samples.AspNetCore.WebApi/Services/'
35+
- "src/Dotnet.Samples.AspNetCore.WebApi/Services/"
3736

3837
comment:
3938
layout: "header, diff, flags, components"
@@ -47,19 +46,20 @@ ignore:
4746
- .*\.json
4847
- .*\.yml
4948
- .*\.png
50-
- '**/*.md'
49+
- "**/*.md"
5150

5251
- .*\/test\/.*
5352
- .*\/scripts\/.*
5453
- .*\/Program\.cs
55-
- '**/LICENSE'
56-
- '**/README.md'
54+
- "**/LICENSE"
55+
- "**/README.md"
5756

5857
- .*\/Configurations\/.*
5958
- .*\/Data\/.*
6059
- .*\/Enums\/.*
6160
- .*\/Extensions\/.*
6261
- .*\/Mappings\/.*
62+
- .*\/Middlewares\/.*
6363
- .*\/Migrations\/.*
6464
- .*\/Models\/.*
6565
- .*\/Properties\/.*
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using Dotnet.Samples.AspNetCore.WebApi.Middlewares;
2+
3+
namespace Dotnet.Samples.AspNetCore.WebApi.Extensions;
4+
5+
/// <summary>
6+
/// Extension methods for configuring middleware in the application pipeline.
7+
/// </summary>
8+
public static class MiddlewareExtensions
9+
{
10+
/// <summary>
11+
/// Adds global exception handling middleware to the application pipeline.
12+
/// This middleware catches unhandled exceptions and returns RFC 7807 compliant error responses.
13+
/// </summary>
14+
/// <param name="app">The web application builder.</param>
15+
/// <returns>The web application builder for method chaining.</returns>
16+
public static WebApplication UseExceptionHandling(this WebApplication app)
17+
{
18+
app.UseMiddleware<ExceptionMiddleware>();
19+
return app;
20+
}
21+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using System.Text.Json;
2+
using FluentValidation;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Microsoft.EntityFrameworkCore;
5+
6+
namespace Dotnet.Samples.AspNetCore.WebApi.Middlewares;
7+
8+
/// <summary>
9+
/// Middleware for global exception handling with RFC 7807 Problem Details format.
10+
/// </summary>
11+
public class ExceptionMiddleware(ILogger<ExceptionMiddleware> logger, IHostEnvironment environment)
12+
{
13+
private const string ProblemDetailsContentType = "application/problem+json";
14+
15+
private static readonly JsonSerializerOptions JsonOptions =
16+
new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
17+
18+
/// <summary>
19+
/// Invokes the middleware to handle exceptions globally.
20+
/// </summary>
21+
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
22+
{
23+
try
24+
{
25+
await next(context);
26+
}
27+
catch (Exception exception)
28+
{
29+
await HandleExceptionAsync(context, exception);
30+
}
31+
}
32+
33+
/// <summary>
34+
/// Handles the exception and returns an RFC 7807 compliant error response.
35+
/// </summary>
36+
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
37+
{
38+
var (statusCode, title) = MapExceptionToStatusCode(exception);
39+
40+
var problemDetails = new ProblemDetails
41+
{
42+
Type = $"https://httpstatuses.com/{statusCode}",
43+
Title = title,
44+
Status = statusCode,
45+
Detail = GetExceptionDetail(exception),
46+
Instance = context.Request.Path
47+
};
48+
49+
// Add trace ID for request correlation
50+
problemDetails.Extensions["traceId"] = context.TraceIdentifier;
51+
52+
// Log the exception with structured logging
53+
logger.LogError(
54+
exception,
55+
"Unhandled exception occurred. TraceId: {TraceId}, Path: {Path}, StatusCode: {StatusCode}",
56+
context.TraceIdentifier,
57+
context.Request.Path,
58+
statusCode
59+
);
60+
61+
context.Response.StatusCode = statusCode;
62+
context.Response.ContentType = ProblemDetailsContentType;
63+
64+
await context.Response.WriteAsync(JsonSerializer.Serialize(problemDetails, JsonOptions));
65+
}
66+
67+
/// <summary>
68+
/// Maps exception types to appropriate HTTP status codes and titles.
69+
/// </summary>
70+
private static (int StatusCode, string Title) MapExceptionToStatusCode(Exception exception)
71+
{
72+
return exception switch
73+
{
74+
ValidationException => (StatusCodes.Status400BadRequest, "Validation Error"),
75+
ArgumentException
76+
or ArgumentNullException
77+
=> (StatusCodes.Status400BadRequest, "Bad Request"),
78+
InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid Operation"),
79+
DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "Concurrency Conflict"),
80+
OperationCanceledException => (StatusCodes.Status408RequestTimeout, "Request Timeout"),
81+
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error")
82+
};
83+
}
84+
85+
/// <summary>
86+
/// Gets the exception detail based on the environment.
87+
/// In Development: includes full exception details and stack trace.
88+
/// In Production: returns a generic message without sensitive information.
89+
/// </summary>
90+
private string GetExceptionDetail(Exception exception)
91+
{
92+
if (environment.IsDevelopment())
93+
{
94+
return $"{exception.Message}\n\nStack Trace:\n{exception.StackTrace}";
95+
}
96+
97+
return exception switch
98+
{
99+
ValidationException => exception.Message,
100+
ArgumentException => exception.Message,
101+
_ => "An unexpected error occurred while processing your request."
102+
};
103+
}
104+
}

src/Dotnet.Samples.AspNetCore.WebApi/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
* -------------------------------------------------------------------------- */
5656

5757
app.UseSerilogRequestLogging();
58+
app.UseExceptionHandling();
5859
app.UseHttpsRedirection();
5960
app.MapHealthChecks("/health");
6061
app.UseRateLimiter();

0 commit comments

Comments
 (0)