Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
02463fb
Add catalog
apptade Jul 29, 2026
f53a07b
Replace CrError[] to ImmutableArray<CrError>
apptade Jul 29, 2026
87b05f6
Update CrExceptionHandler
apptade Jul 29, 2026
c71968e
Update Exception Error Map
apptade Jul 29, 2026
b111e4f
Update CrException.cs
apptade Jul 29, 2026
9faa991
Update CrErrorValidator
apptade Jul 29, 2026
7dacc4f
Update Base Map
apptade Jul 29, 2026
bf1771e
Update CrError.cs
apptade Jul 29, 2026
040413b
Rename folder
apptade Jul 30, 2026
0eee833
Rename
apptade Jul 30, 2026
9655020
Create FrozenDictionaryExtensions.cs
apptade Jul 30, 2026
def8c77
Create EnumerableExtensions.cs
apptade Jul 30, 2026
1d025bf
Update mapping
apptade Jul 30, 2026
8e96789
Rename asp net mapping
apptade Jul 30, 2026
86773a8
Rename ExceptionResolver to ExceptionFactory
apptade Jul 30, 2026
3030a15
Update ErrorMapBuilder.cs
apptade Jul 30, 2026
ed8d5a1
Create RegistrationCollection.cs
apptade Jul 30, 2026
6b657a7
Update CR.Exceptions.AspNet.UnitTests
apptade Jul 30, 2026
cf72969
Create CR.Exceptions.UnitTests.csproj
apptade Jul 30, 2026
77975e2
Update CR.Exceptions.AspNet.UnitTests
apptade Jul 30, 2026
18e86e8
Update CR.Exceptions.UnitTests.csproj
apptade Jul 30, 2026
37ab14f
Update README.md
apptade Jul 30, 2026
df2ea62
Update Tests
apptade Jul 30, 2026
c055f9e
CR.Exceptions.AspNet
apptade Jul 30, 2026
823df1b
Update Tests
apptade Jul 30, 2026
ab7986e
Update CR.Exceptions.AspNet
apptade Jul 30, 2026
d7f4523
Update tests
apptade Jul 30, 2026
aab4148
Update CrExceptionHandler.cs
apptade Jul 30, 2026
21052c4
Update CrExceptionHandlerTests.cs
apptade Jul 30, 2026
ccb02af
Update README.md
apptade Jul 30, 2026
7fa6b57
Update ExceptionFactory.cs
apptade Jul 30, 2026
813ee44
Update ExceptionFactoryTests.cs
apptade Jul 30, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>

<ItemGroup>
Expand Down
114 changes: 57 additions & 57 deletions CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs
Original file line number Diff line number Diff line change
@@ -1,59 +1,74 @@
using FluentAssertions;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using Xunit.Abstractions;

namespace CR.Exceptions.AspNet.UnitTests;

public sealed class CrExceptionHandlerTests
{
private static readonly JsonSerializerOptions _prettyJsonOptions = new(JsonSerializerOptions.Web) { WriteIndented = true };

private readonly ITestOutputHelper _output;
private readonly JsonSerializerOptions _jsonOptions;

public CrExceptionHandlerTests(ITestOutputHelper output)
{
_output = output;
_jsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
}

[Fact]
public Task Should_Return_404_For_NotFoundException()
{
return ShouldReturnStatusCode(new TestNotFoundException(), StatusCodes.Status404NotFound);
return AssertHandlerResult(
new TestNotFoundException(),
StatusCodes.Status404NotFound,
canCreateActivity: true);
}

[Fact]
public Task Should_Return_500_For_UnhandledException()
{
return ShouldReturnStatusCode(new Exception("Something went wrong"), StatusCodes.Status500InternalServerError);
return AssertHandlerResult(
new Exception("Unknown exception"),
StatusCodes.Status500InternalServerError,
canCreateActivity: true);
}

[Fact]
public Task Should_Return_500_For_UnhandledException_When_Activity_Is_Missing()
{
return AssertHandlerResult(
new Exception("Unknown exception"),
StatusCodes.Status500InternalServerError,
canCreateActivity: false);
}

private async Task ShouldReturnStatusCode(Exception exception, int expectedStatusCode)
private async Task AssertHandlerResult(Exception exception, int expectedStatusCode, bool canCreateActivity)
{
using var provider = CreateServiceProvider();
using var activity = canCreateActivity
? new Activity("TestActivity").Start()
: null;

using var provider = CreateServiceProvider();
var handler = provider.GetRequiredService<IExceptionHandler>();
var context = CreateContext();

var result = await handler.TryHandleAsync(
context,
exception,
CancellationToken.None);
using var responseStream = new MemoryStream();
var context = CreateContext(responseStream);

var isHandled = await handler.TryHandleAsync(context, exception, CancellationToken.None);

await LogResponseBody(context);
Assert.True(isHandled);
Assert.Equal(expectedStatusCode, context.Response.StatusCode);
Assert.Contains("application/problem+json", context.Response.ContentType);

result.Should().BeTrue();
context.Response.StatusCode.Should().Be(expectedStatusCode);
responseStream.Position = 0;

var problem = await DeserializeProblemDetails(context);
AssertProblemDetails(problem!, context, expectedStatusCode);
var problem = await JsonSerializer.DeserializeAsync<CustomProblemDetails>(responseStream, JsonSerializerOptions.Web);
var expectedTraceId = activity?.TraceId.ToHexString() ?? context.TraceIdentifier;

AssertProblemDetails(problem, context, expectedStatusCode, expectedTraceId);
}

private static ServiceProvider CreateServiceProvider()
Expand All @@ -64,55 +79,40 @@ private static ServiceProvider CreateServiceProvider()
.BuildServiceProvider();
}

private static DefaultHttpContext CreateContext()
private static DefaultHttpContext CreateContext(MemoryStream responseStream)
{
return new DefaultHttpContext
{
Response =
{
Body = new MemoryStream()
}
Request = { Path = "/api/test" },
Response = { Body = responseStream }
};
}

private async Task<ProblemDetailsResponse> DeserializeProblemDetails(HttpContext context)
private void AssertProblemDetails(CustomProblemDetails? problem, HttpContext context, int expectedStatusCode, string? expectedTraceId)
{
context.Response.Body.Position = 0;
Assert.NotNull(problem);

return (await JsonSerializer.DeserializeAsync<ProblemDetailsResponse>(
context.Response.Body, _jsonOptions))!;
}
_output.WriteLine(JsonSerializer.Serialize(problem, options: _prettyJsonOptions));

private async Task LogResponseBody(DefaultHttpContext context)
{
context.Response.Body.Position = 0;
var jsonNode = await JsonNode.ParseAsync(context.Response.Body);
_output.WriteLine(jsonNode?.ToJsonString(_jsonOptions));
}
Assert.False(string.IsNullOrEmpty(problem.Type));
Assert.False(string.IsNullOrEmpty(problem.Title));
Assert.False(string.IsNullOrEmpty(problem.Detail));

private static void AssertProblemDetails(ProblemDetailsResponse problem, HttpContext context, int expectedStatusCode)
{
problem.Should().NotBeNull();
Assert.Equal(expectedStatusCode, problem.Status);
Assert.Equal(context.Request.Path, problem.Instance);

problem.Type.Should().NotBeNullOrWhiteSpace();
problem.Title.Should().NotBeNullOrWhiteSpace();
problem.Detail.Should().NotBeNullOrWhiteSpace();
Assert.True(problem.Extensions.TryGetValue(
ProblemDetailsExtensionNames.TraceId,
out var traceId));

problem.Status.Should().Be(expectedStatusCode);
problem.Instance.Should().Be(context.Request.Path);
Assert.Equal(expectedTraceId, traceId?.ToString());

problem.TraceId.Should().NotBeNullOrWhiteSpace();
problem.Errors.Should().NotBeNull().And.NotBeEmpty();
Assert.NotNull(problem.Errors);
Assert.NotEmpty(problem.Errors);
}

private sealed class ProblemDetailsResponse
private sealed class CustomProblemDetails : ProblemDetails
{
public string? Type { get; set; }
public string? Title { get; set; }
public int? Status { get; set; }
public string? Detail { get; set; }
public string? Instance { get; set; }
public string? TraceId { get; set; }
public CrError[]? Errors { get; set; }
}
}
20 changes: 0 additions & 20 deletions CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs

This file was deleted.

34 changes: 34 additions & 0 deletions CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Http;

namespace CR.Exceptions.AspNet.UnitTests;

public sealed class ExceptionStatusCodeOptionsTests
{
[Fact]
public void Should_Return_404_For_NotFoundException()
{
var statusCode = GetStatusCodeFor(new TestNotFoundException());
Assert.Equal(StatusCodes.Status404NotFound, statusCode);
}

[Fact]
public void Should_Return_Null_For_UnregisteredException()
{
var statusCode = GetStatusCodeFor(new TestUnregisteredException());
Assert.Null(statusCode);
}

private static int? GetStatusCodeFor(CrException exception)
{
return new ExceptionStatusCodeOptions()
.AddDefaultMappings()
.FindHttpStatusCode(exception);
}

private sealed class TestUnregisteredException : CrException
{
public TestUnregisteredException() : base([new("TestUnregistered", "Test message")], "Unregistered")
{
}
}
}
2 changes: 1 addition & 1 deletion CR.Exceptions.AspNet.UnitTests/TestNotFoundException.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
namespace CR.Exceptions.AspNet.UnitTests;

public sealed class TestNotFoundException : NotFoundException
internal sealed class TestNotFoundException : NotFoundException
{
public TestNotFoundException() : base([new("TestNotFound", "Test Entity not found")])
{
Expand Down
31 changes: 9 additions & 22 deletions CR.Exceptions.AspNet/CrExceptionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
using CR.Exceptions.AspNet.Options;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.Collections.Immutable;

namespace CR.Exceptions.AspNet;

public sealed partial class CrExceptionHandler : IExceptionHandler
{
private static readonly CrError[] DefaultInternalErrors =
[
new(ErrorCodes.InternalError, "An unexpected internal error occurred.")
];
private static readonly ImmutableArray<CrError> DefaultInternalErrors =
[new("InternalError", "An unexpected internal error occurred.")];

private readonly IProblemDetailsService _problemDetailsService;
private readonly CrExceptionOptions _options;
Expand Down Expand Up @@ -42,15 +38,15 @@ public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception e
var exceptionType = exception.GetType();
var exceptionTypeName = exceptionType.FullName ?? exceptionType.Name;

CrError[] errors;
string detail;
var errors = DefaultInternalErrors;
var detail = "An unexpected error occurred.";

if (exception is CrException crException)
{
detail = crException.Message;
errors = crException.Errors;

var statusCode = _options.ExceptionMapping.FindHttpStatusCode(crException);
var statusCode = _options.StatusCodes.FindHttpStatusCode(crException);

if (statusCode is null)
{
Expand All @@ -65,33 +61,24 @@ public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception e
}
else
{
detail = "An unexpected error occurred.";
errors = DefaultInternalErrors;

LogUnhandledException(_logger, exception, exceptionTypeName);
}

var traceId = Activity.Current?.TraceId.ToHexString() ?? httpContext.TraceIdentifier;
var title = ReasonPhrases.GetReasonPhrase(httpStatusCode);
httpContext.Response.StatusCode = httpStatusCode;

var problemDetailsContext = new ProblemDetailsContext
{
HttpContext = httpContext,
Exception = exception,
ProblemDetails =
{
Type = _options.ProblemDetails.Type,
Status = httpStatusCode,
Title = string.IsNullOrWhiteSpace(title) ? "An error occurred" : title,
Detail = detail,
Instance = httpContext.Request.Path
},
};

AddProblemDetailsExtension(problemDetailsContext.ProblemDetails, ProblemDetailsExtensionNames.TraceId, traceId);
AddProblemDetailsExtension(problemDetailsContext.ProblemDetails, ProblemDetailsExtensionNames.Errors, errors);

httpContext.Response.StatusCode = httpStatusCode;

var isWritten = await _problemDetailsService.TryWriteAsync(problemDetailsContext);

if (!isWritten)
Expand Down
6 changes: 6 additions & 0 deletions CR.Exceptions.AspNet/CrExceptionOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace CR.Exceptions.AspNet;

public sealed class CrExceptionOptions
{
public ExceptionStatusCodeOptions StatusCodes { get; init; } = new();
}
6 changes: 0 additions & 6 deletions CR.Exceptions.AspNet/ErrorCodes.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
namespace CR.Exceptions.AspNet.Options;
namespace CR.Exceptions.AspNet;

public sealed class ExceptionMappingOptions
public sealed class ExceptionStatusCodeOptions
{
private readonly Dictionary<Type, int> _map = [];

public ExceptionMappingOptions Map<TException>(int httpStatusCode) where TException : CrException
public ExceptionStatusCodeOptions Map<TException>(int httpStatusCode) where TException : CrException
{
if (!_map.TryAdd(typeof(TException), httpStatusCode))
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
using CR.Exceptions.AspNet.Options;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;

namespace CR.Exceptions.AspNet;

public static class ExceptionMappingOptionsExtensions
public static class ExceptionStatusCodeOptionsExtensions
{
extension(ExceptionMappingOptions options)
extension(ExceptionStatusCodeOptions options)
{
public ExceptionMappingOptions AddDefaultMappings()
public ExceptionStatusCodeOptions AddDefaultMappings()
{
return options
.Map<ValidationException>(StatusCodes.Status400BadRequest)
Expand Down
7 changes: 0 additions & 7 deletions CR.Exceptions.AspNet/Options/CrExceptionOptions.cs

This file was deleted.

Loading
Loading