Skip to content

Commit ff70523

Browse files
Global exception handling in ASP.NET Core Web API: new net10.0 sample (#2245)
Ports the article's students example to .NET 10: try-catch controller, built-in UseExceptionHandler with AddProblemDetails, custom exception middleware writing through IProblemDetailsService, and an IExceptionHandler for AccessViolationException. Three integration tests.
1 parent 372f080 commit ff70523

15 files changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using Microsoft.AspNetCore.Hosting;
4+
using Microsoft.AspNetCore.Mvc.Testing;
5+
6+
namespace GlobalErrorHandling.Tests;
7+
8+
public sealed class ExceptionHandlingTests(WebApplicationFactory<Program> factory)
9+
: IClassFixture<WebApplicationFactory<Program>>
10+
{
11+
[Theory]
12+
[InlineData("Development")]
13+
[InlineData("Production")]
14+
public async Task AccessViolation_ReturnsProblemDetailsFromTheHandler(string environment)
15+
{
16+
var client = factory
17+
.WithWebHostBuilder(builder => builder.UseEnvironment(environment))
18+
.CreateClient();
19+
20+
var response = await client.GetAsync("/api/values", TestContext.Current.CancellationToken);
21+
22+
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
23+
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
24+
25+
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
26+
using var problem = JsonDocument.Parse(body);
27+
Assert.Equal(500, problem.RootElement.GetProperty("status").GetInt32());
28+
Assert.Equal("Access violation error from the exception handler",
29+
problem.RootElement.GetProperty("detail").GetString());
30+
Assert.True(problem.RootElement.TryGetProperty("traceId", out _));
31+
}
32+
33+
[Fact]
34+
public async Task ErrorResponse_DoesNotLeakTheExceptionMessage()
35+
{
36+
var client = factory.CreateClient();
37+
38+
var response = await client.GetAsync("/api/values", TestContext.Current.CancellationToken);
39+
40+
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
41+
Assert.DoesNotContain("Violation Exception while accessing the resource.", body);
42+
}
43+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
<IsTestProject>true</IsTestProject>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<Using Include="Xunit" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
17+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
18+
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
19+
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
20+
</ItemGroup>
21+
22+
<ItemGroup>
23+
<ProjectReference Include="..\GlobalErrorHandling\GlobalErrorHandling.csproj" />
24+
</ItemGroup>
25+
26+
</Project>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
2+
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.0.31903.59
5+
MinimumVisualStudioVersion = 10.0.40219.1
6+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GlobalErrorHandling", "GlobalErrorHandling\GlobalErrorHandling.csproj", "{34712A87-8967-4A68-93C1-92567975EBEE}"
7+
EndProject
8+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GlobalErrorHandling.Tests", "GlobalErrorHandling.Tests\GlobalErrorHandling.Tests.csproj", "{D4A7D072-B60F-4DE0-B1AC-CC686349700C}"
9+
EndProject
10+
Global
11+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
12+
Debug|Any CPU = Debug|Any CPU
13+
Debug|x64 = Debug|x64
14+
Debug|x86 = Debug|x86
15+
Release|Any CPU = Release|Any CPU
16+
Release|x64 = Release|x64
17+
Release|x86 = Release|x86
18+
EndGlobalSection
19+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
20+
{34712A87-8967-4A68-93C1-92567975EBEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
21+
{34712A87-8967-4A68-93C1-92567975EBEE}.Debug|Any CPU.Build.0 = Debug|Any CPU
22+
{34712A87-8967-4A68-93C1-92567975EBEE}.Debug|x64.ActiveCfg = Debug|Any CPU
23+
{34712A87-8967-4A68-93C1-92567975EBEE}.Debug|x64.Build.0 = Debug|Any CPU
24+
{34712A87-8967-4A68-93C1-92567975EBEE}.Debug|x86.ActiveCfg = Debug|Any CPU
25+
{34712A87-8967-4A68-93C1-92567975EBEE}.Debug|x86.Build.0 = Debug|Any CPU
26+
{34712A87-8967-4A68-93C1-92567975EBEE}.Release|Any CPU.ActiveCfg = Release|Any CPU
27+
{34712A87-8967-4A68-93C1-92567975EBEE}.Release|Any CPU.Build.0 = Release|Any CPU
28+
{34712A87-8967-4A68-93C1-92567975EBEE}.Release|x64.ActiveCfg = Release|Any CPU
29+
{34712A87-8967-4A68-93C1-92567975EBEE}.Release|x64.Build.0 = Release|Any CPU
30+
{34712A87-8967-4A68-93C1-92567975EBEE}.Release|x86.ActiveCfg = Release|Any CPU
31+
{34712A87-8967-4A68-93C1-92567975EBEE}.Release|x86.Build.0 = Release|Any CPU
32+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
33+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Debug|Any CPU.Build.0 = Debug|Any CPU
34+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Debug|x64.ActiveCfg = Debug|Any CPU
35+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Debug|x64.Build.0 = Debug|Any CPU
36+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Debug|x86.ActiveCfg = Debug|Any CPU
37+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Debug|x86.Build.0 = Debug|Any CPU
38+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Release|Any CPU.ActiveCfg = Release|Any CPU
39+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Release|Any CPU.Build.0 = Release|Any CPU
40+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Release|x64.ActiveCfg = Release|Any CPU
41+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Release|x64.Build.0 = Release|Any CPU
42+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Release|x86.ActiveCfg = Release|Any CPU
43+
{D4A7D072-B60F-4DE0-B1AC-CC686349700C}.Release|x86.Build.0 = Release|Any CPU
44+
EndGlobalSection
45+
GlobalSection(SolutionProperties) = preSolution
46+
HideSolutionNode = FALSE
47+
EndGlobalSection
48+
EndGlobal
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using GlobalErrorHandling.Models;
2+
using Microsoft.AspNetCore.Mvc;
3+
4+
namespace GlobalErrorHandling.Controllers;
5+
6+
[Route("api/[controller]")]
7+
[ApiController]
8+
public class ValuesController(ILogger<ValuesController> logger) : ControllerBase
9+
{
10+
[HttpGet]
11+
public IActionResult Get()
12+
{
13+
logger.LogInformation("Fetching all the Students from the storage");
14+
15+
var students = DataManager.GetAllStudents(); //simulation for the data base access
16+
17+
throw new AccessViolationException("Violation Exception while accessing the resource.");
18+
19+
logger.LogInformation("Returning {Count} students.", students.Count);
20+
21+
return Ok(students);
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
namespace GlobalErrorHandling.CustomExceptionMiddleware;
2+
3+
public class ExceptionMiddleware(
4+
RequestDelegate next,
5+
ILogger<ExceptionMiddleware> logger,
6+
IProblemDetailsService problemDetailsService)
7+
{
8+
public async Task InvokeAsync(HttpContext httpContext)
9+
{
10+
try
11+
{
12+
await next(httpContext);
13+
}
14+
catch (AccessViolationException avEx)
15+
{
16+
logger.LogError(avEx, "A new violation exception has been thrown");
17+
await HandleExceptionAsync(httpContext, avEx);
18+
}
19+
catch (Exception ex)
20+
{
21+
logger.LogError(ex, "Something went wrong");
22+
await HandleExceptionAsync(httpContext, ex);
23+
}
24+
}
25+
26+
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
27+
{
28+
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
29+
30+
var message = exception switch
31+
{
32+
AccessViolationException => "Access violation error from the custom middleware",
33+
_ => "Internal Server Error from the custom middleware."
34+
};
35+
36+
await problemDetailsService.WriteAsync(new ProblemDetailsContext
37+
{
38+
HttpContext = context,
39+
Exception = exception,
40+
ProblemDetails = { Detail = message }
41+
});
42+
}
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Microsoft.AspNetCore.Diagnostics;
2+
3+
namespace GlobalErrorHandling.ExceptionHandlers;
4+
5+
public class AccessViolationExceptionHandler(
6+
IProblemDetailsService problemDetailsService,
7+
ILogger<AccessViolationExceptionHandler> logger) : IExceptionHandler
8+
{
9+
public async ValueTask<bool> TryHandleAsync(
10+
HttpContext httpContext,
11+
Exception exception,
12+
CancellationToken cancellationToken)
13+
{
14+
if (exception is not AccessViolationException)
15+
return false;
16+
17+
logger.LogError(exception, "A new violation exception has been thrown");
18+
19+
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
20+
21+
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
22+
{
23+
HttpContext = httpContext,
24+
Exception = exception,
25+
ProblemDetails = { Detail = "Access violation error from the exception handler" }
26+
});
27+
}
28+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using GlobalErrorHandling.CustomExceptionMiddleware;
2+
3+
namespace GlobalErrorHandling.Extensions;
4+
5+
public static class ExceptionMiddlewareExtensions
6+
{
7+
public static void ConfigureCustomExceptionMiddleware(this IApplicationBuilder app)
8+
{
9+
app.UseMiddleware<ExceptionMiddleware>();
10+
}
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
</PropertyGroup>
8+
9+
</Project>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@GlobalErrorHandling_HostAddress = http://localhost:5180
2+
3+
GET {{GlobalErrorHandling_HostAddress}}/api/values/
4+
Accept: application/json
5+
6+
###
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace GlobalErrorHandling.Models;
2+
3+
public static class DataManager
4+
{
5+
public static List<Student> GetAllStudents() =>
6+
[
7+
new(1, "John Doe"),
8+
new(2, "Jane Smith"),
9+
new(3, "Mike Johnson")
10+
];
11+
}

0 commit comments

Comments
 (0)