Skip to content

Commit 48ddb14

Browse files
authored
Merge pull request #321 from nanotaboada/feature/global-exception-handling
feat: add global exception handling middleware with RFC 7807 support
2 parents 6477165 + 7980654 commit 48ddb14

File tree

6 files changed

+171
-31
lines changed

6 files changed

+171
-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\/.*

src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ [FromBody] PlayerRequestModel player
189189
return TypedResults.NotFound();
190190
}
191191
await playerService.UpdateAsync(player);
192+
// codeql[cs/log-forging] Serilog structured logging with @ destructuring automatically escapes control characters
192193
logger.LogInformation("PUT /players/{SquadNumber} updated: {@Player}", squadNumber, player);
193194
return TypedResults.NoContent();
194195
}
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 used to configure the HTTP pipeline, and routes.</param>
15+
/// <returns>The WebApplication object for method chaining.</returns>
16+
public static WebApplication UseExceptionHandling(this WebApplication app)
17+
{
18+
app.UseMiddleware<ExceptionMiddleware>();
19+
return app;
20+
}
21+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 (status, title) = MapExceptionToStatusCode(exception);
39+
40+
var problemDetails = new ProblemDetails
41+
{
42+
Type = $"https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/{status}",
43+
Title = title,
44+
Status = status,
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+
// codeql[cs/log-forging] Serilog structured logging automatically escapes control characters
53+
logger.LogError(
54+
exception,
55+
"Unhandled exception occurred. TraceId: {TraceId}, Path: {Path}, StatusCode: {StatusCode}",
56+
context.TraceIdentifier,
57+
context.Request.Path,
58+
status
59+
);
60+
61+
// Only modify response if headers haven't been sent yet
62+
if (!context.Response.HasStarted)
63+
{
64+
context.Response.StatusCode = status;
65+
context.Response.ContentType = ProblemDetailsContentType;
66+
67+
await context.Response.WriteAsync(
68+
JsonSerializer.Serialize(problemDetails, JsonOptions)
69+
);
70+
}
71+
else
72+
{
73+
logger.LogWarning(
74+
"Unable to write error response for TraceId: {TraceId}. Response has already started.",
75+
context.TraceIdentifier
76+
);
77+
}
78+
}
79+
80+
/// <summary>
81+
/// Maps exception types to appropriate HTTP status codes and titles.
82+
/// </summary>
83+
private static (int StatusCode, string Title) MapExceptionToStatusCode(Exception exception)
84+
{
85+
return exception switch
86+
{
87+
ValidationException => (StatusCodes.Status400BadRequest, "Validation Error"),
88+
ArgumentException
89+
or ArgumentNullException
90+
=> (StatusCodes.Status400BadRequest, "Bad Request"),
91+
InvalidOperationException => (StatusCodes.Status400BadRequest, "Invalid Operation"),
92+
DbUpdateConcurrencyException => (StatusCodes.Status409Conflict, "Concurrency Conflict"),
93+
OperationCanceledException => (StatusCodes.Status408RequestTimeout, "Request Timeout"),
94+
_ => (StatusCodes.Status500InternalServerError, "Internal Server Error")
95+
};
96+
}
97+
98+
/// <summary>
99+
/// Gets the exception detail based on the environment.
100+
/// In Development: includes full exception details and stack trace.
101+
/// In Production: returns a generic message without sensitive information.
102+
/// </summary>
103+
private string GetExceptionDetail(Exception exception)
104+
{
105+
if (environment.IsDevelopment())
106+
{
107+
return $"{exception.Message}\n\nStack Trace:\n{exception.StackTrace}";
108+
}
109+
110+
return exception switch
111+
{
112+
ValidationException => exception.Message,
113+
ArgumentException => exception.Message,
114+
_ => "An unexpected error occurred while processing your request."
115+
};
116+
}
117+
}

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)