diff --git a/CR.Exceptions.AspNet.UnitTests/CR.Exceptions.AspNet.UnitTests.csproj b/CR.Exceptions.AspNet.UnitTests/CR.Exceptions.AspNet.UnitTests.csproj index 9c1d831..9d00fd5 100644 --- a/CR.Exceptions.AspNet.UnitTests/CR.Exceptions.AspNet.UnitTests.csproj +++ b/CR.Exceptions.AspNet.UnitTests/CR.Exceptions.AspNet.UnitTests.csproj @@ -8,11 +8,16 @@ - - - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs index 331cae7..338705d 100644 --- a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs @@ -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(); - 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(responseStream, JsonSerializerOptions.Web); + var expectedTraceId = activity?.TraceId.ToHexString() ?? context.TraceIdentifier; + + AssertProblemDetails(problem, context, expectedStatusCode, expectedTraceId); } private static ServiceProvider CreateServiceProvider() @@ -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 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( - 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; } } } \ No newline at end of file diff --git a/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs b/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs deleted file mode 100644 index e526bb5..0000000 --- a/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using CR.Exceptions.AspNet.Options; -using FluentAssertions; -using Microsoft.AspNetCore.Http; - -namespace CR.Exceptions.AspNet.UnitTests; - -public sealed class ExceptionMappingOptionsTests -{ - [Fact] - public void Should_Return_404_Status_Code_For_NotFoundException() - { - var options = new ExceptionMappingOptions().AddDefaultMappings(); - var exception = new TestNotFoundException(); - - var statusCode = options.FindHttpStatusCode(exception); - - statusCode.Should() - .Be(StatusCodes.Status404NotFound); - } -} \ No newline at end of file diff --git a/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs new file mode 100644 index 0000000..1ad37bb --- /dev/null +++ b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs @@ -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") + { + } + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet.UnitTests/TestNotFoundException.cs b/CR.Exceptions.AspNet.UnitTests/TestNotFoundException.cs index 493a3ee..e2ab35c 100644 --- a/CR.Exceptions.AspNet.UnitTests/TestNotFoundException.cs +++ b/CR.Exceptions.AspNet.UnitTests/TestNotFoundException.cs @@ -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")]) { diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.cs b/CR.Exceptions.AspNet/CrExceptionHandler.cs index f6f9afb..da07cef 100644 --- a/CR.Exceptions.AspNet/CrExceptionHandler.cs +++ b/CR.Exceptions.AspNet/CrExceptionHandler.cs @@ -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 DefaultInternalErrors = + [new("InternalError", "An unexpected internal error occurred.")]; private readonly IProblemDetailsService _problemDetailsService; private readonly CrExceptionOptions _options; @@ -42,15 +38,15 @@ public async ValueTask 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) { @@ -65,33 +61,24 @@ public async ValueTask 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) diff --git a/CR.Exceptions.AspNet/CrExceptionOptions.cs b/CR.Exceptions.AspNet/CrExceptionOptions.cs new file mode 100644 index 0000000..0f83c58 --- /dev/null +++ b/CR.Exceptions.AspNet/CrExceptionOptions.cs @@ -0,0 +1,6 @@ +namespace CR.Exceptions.AspNet; + +public sealed class CrExceptionOptions +{ + public ExceptionStatusCodeOptions StatusCodes { get; init; } = new(); +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/ErrorCodes.cs b/CR.Exceptions.AspNet/ErrorCodes.cs deleted file mode 100644 index 5aa0649..0000000 --- a/CR.Exceptions.AspNet/ErrorCodes.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CR.Exceptions.AspNet; - -internal static class ErrorCodes -{ - public const string InternalError = "InternalError"; -} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Options/ExceptionMappingOptions.cs b/CR.Exceptions.AspNet/ExceptionStatusCodeOptions.cs similarity index 78% rename from CR.Exceptions.AspNet/Options/ExceptionMappingOptions.cs rename to CR.Exceptions.AspNet/ExceptionStatusCodeOptions.cs index f04cd60..15a1f29 100644 --- a/CR.Exceptions.AspNet/Options/ExceptionMappingOptions.cs +++ b/CR.Exceptions.AspNet/ExceptionStatusCodeOptions.cs @@ -1,10 +1,10 @@ -namespace CR.Exceptions.AspNet.Options; +namespace CR.Exceptions.AspNet; -public sealed class ExceptionMappingOptions +public sealed class ExceptionStatusCodeOptions { private readonly Dictionary _map = []; - public ExceptionMappingOptions Map(int httpStatusCode) where TException : CrException + public ExceptionStatusCodeOptions Map(int httpStatusCode) where TException : CrException { if (!_map.TryAdd(typeof(TException), httpStatusCode)) { diff --git a/CR.Exceptions.AspNet/ExceptionMappingOptionsExtensions.cs b/CR.Exceptions.AspNet/ExceptionStatusCodeOptionsExtensions.cs similarity index 70% rename from CR.Exceptions.AspNet/ExceptionMappingOptionsExtensions.cs rename to CR.Exceptions.AspNet/ExceptionStatusCodeOptionsExtensions.cs index 6d1d86c..ad63c08 100644 --- a/CR.Exceptions.AspNet/ExceptionMappingOptionsExtensions.cs +++ b/CR.Exceptions.AspNet/ExceptionStatusCodeOptionsExtensions.cs @@ -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(StatusCodes.Status400BadRequest) diff --git a/CR.Exceptions.AspNet/Options/CrExceptionOptions.cs b/CR.Exceptions.AspNet/Options/CrExceptionOptions.cs deleted file mode 100644 index 1780a73..0000000 --- a/CR.Exceptions.AspNet/Options/CrExceptionOptions.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace CR.Exceptions.AspNet.Options; - -public sealed class CrExceptionOptions -{ - public ExceptionMappingOptions ExceptionMapping { get; init; } = new(); - public ProblemDetailsOptions ProblemDetails { get; init; } = new(); -} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Options/ProblemDetailsOptions.cs b/CR.Exceptions.AspNet/Options/ProblemDetailsOptions.cs deleted file mode 100644 index 3ee4c6f..0000000 --- a/CR.Exceptions.AspNet/Options/ProblemDetailsOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CR.Exceptions.AspNet.Options; - -public sealed class ProblemDetailsOptions -{ - public string Type { get; set; } = "about:blank"; -} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/ProblemDetailsExtensionNames.cs b/CR.Exceptions.AspNet/ProblemDetailsExtensionNames.cs index 9347db4..02b43a2 100644 --- a/CR.Exceptions.AspNet/ProblemDetailsExtensionNames.cs +++ b/CR.Exceptions.AspNet/ProblemDetailsExtensionNames.cs @@ -1,6 +1,6 @@ namespace CR.Exceptions.AspNet; -internal static class ProblemDetailsExtensionNames +public static class ProblemDetailsExtensionNames { public const string Errors = "errors"; public const string TraceId = "traceId"; diff --git a/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs b/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs index e3719c4..9e71888 100644 --- a/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs +++ b/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using CR.Exceptions.AspNet.Options; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; namespace CR.Exceptions.AspNet; @@ -11,7 +10,7 @@ public IServiceCollection AddCrExceptionHandler() { return services.AddCrExceptionHandler(options => { - options.ExceptionMapping.AddDefaultMappings(); + options.StatusCodes.AddDefaultMappings(); }); } @@ -20,11 +19,25 @@ public IServiceCollection AddCrExceptionHandler(Action setup ArgumentNullException.ThrowIfNull(setupAction); services.Configure(setupAction); - - services.AddProblemDetails(); + services.AddCustomProblemDetails(); services.AddExceptionHandler(); return services; } + + private IServiceCollection AddCustomProblemDetails() + { + return services.AddProblemDetails(options => + { + options.CustomizeProblemDetails = context => + { + var currentActivity = System.Diagnostics.Activity.Current; + + context.ProblemDetails.Extensions[ProblemDetailsExtensionNames.TraceId] = currentActivity != null + ? currentActivity.TraceId.ToHexString() + : context.HttpContext.TraceIdentifier; + }; + }); + } } } \ No newline at end of file diff --git a/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj b/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj new file mode 100644 index 0000000..566be10 --- /dev/null +++ b/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/CR.Exceptions.UnitTests/ErrorMapTests.cs b/CR.Exceptions.UnitTests/ErrorMapTests.cs new file mode 100644 index 0000000..b1b8c14 --- /dev/null +++ b/CR.Exceptions.UnitTests/ErrorMapTests.cs @@ -0,0 +1,48 @@ +using CR.Exceptions.Mapping; + +namespace CR.Exceptions.UnitTests; + +public sealed class ErrorMapTests +{ + [Fact] + public void TryGet_ShouldReturnErrors_WhenCodeExists() + { + const string errorCode = "InvalidGrant"; + const string registrationCode = "invalid_grant"; + + var registration = new ErrorRegistration(registrationCode, [new(errorCode, "Invalid username or password.")]); + + var map = new ErrorMapBuilder() + .Add(registration) + .Build(); + + var result = map.TryGet(registrationCode, out var errors); + + Assert.True(result); + var singleError = Assert.Single(errors); + Assert.Equal(errorCode, singleError.Code); + } + + [Fact] + public void TryGet_ShouldReturnFalse_WhenCodeNotExist() + { + var map = new ErrorMapBuilder().Build(); + + var result = map.TryGet("non_existent_code", out var errors); + + Assert.False(result); + Assert.True(errors.IsDefaultOrEmpty); + } + + [Fact] + public void Build_ShouldThrow_WhenDuplicateCodesRegistered() + { + var errorRegistration = new ErrorRegistration("duplicate", [new("code", "message")]); + + var builder = new ErrorMapBuilder() + .Add(errorRegistration) + .Add(errorRegistration); + + Assert.Throws(builder.Build); + } +} \ No newline at end of file diff --git a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs new file mode 100644 index 0000000..f183ad9 --- /dev/null +++ b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs @@ -0,0 +1,53 @@ +using CR.Exceptions.Mapping; + +namespace CR.Exceptions.UnitTests; + +public sealed class ExceptionFactoryTests +{ + [Fact] + public void TryCreate_ShouldReturnException_WhenCodeExists() + { + const string errorCode = "TestError"; + const string registrationCode = "test_error"; + + var errorRegistration = new ErrorRegistration(registrationCode, [new(errorCode, "Something went wrong.")]); + var exceptionRegistration = new ExceptionRegistration(errorRegistration, errors => new TestException(errors)); + + var factory = new ExceptionFactoryBuilder() + .Add(exceptionRegistration) + .Build(); + + var result = factory.TryCreate(registrationCode, out var exception); + + Assert.True(result); + Assert.NotNull(exception); + + var typedException = Assert.IsType(exception); + var singleError = Assert.Single(typedException.Errors); + Assert.Equal(errorCode, singleError.Code); + } + + [Fact] + public void TryCreate_ShouldReturnFalse_WhenCodeNotExist() + { + var factory = new ExceptionFactoryBuilder().Build(); + + var result = factory.TryCreate("non_existent_code", out var exception); + + Assert.False(result); + Assert.Null(exception); + } + + [Fact] + public void Build_ShouldThrow_WhenDuplicateCodesRegistered() + { + var errorRegistration = new ErrorRegistration("duplicate", [new("code", "message")]); + var exceptionRegistration = new ExceptionRegistration(errorRegistration, errors => new TestException(errors)); + + var builder = new ExceptionFactoryBuilder() + .Add(exceptionRegistration) + .Add(exceptionRegistration); + + Assert.Throws(builder.Build); + } +} \ No newline at end of file diff --git a/CR.Exceptions.UnitTests/TestException.cs b/CR.Exceptions.UnitTests/TestException.cs new file mode 100644 index 0000000..30d890a --- /dev/null +++ b/CR.Exceptions.UnitTests/TestException.cs @@ -0,0 +1,10 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions.UnitTests; + +internal sealed class TestException : CrException +{ + public TestException(ImmutableArray errors) : base(errors, "Test exception message") + { + } +} \ No newline at end of file diff --git a/CR.Exceptions.slnx b/CR.Exceptions.slnx index 1b7bf04..a955dd2 100644 --- a/CR.Exceptions.slnx +++ b/CR.Exceptions.slnx @@ -4,5 +4,6 @@ + diff --git a/CR.Exceptions/ConflictException.cs b/CR.Exceptions/ConflictException.cs index ddc1d67..ef55ccf 100644 --- a/CR.Exceptions/ConflictException.cs +++ b/CR.Exceptions/ConflictException.cs @@ -1,13 +1,15 @@ -namespace CR.Exceptions; +using System.Collections.Immutable; + +namespace CR.Exceptions; public abstract class ConflictException : CrException { - protected ConflictException(CrError[] errors, Exception? innerException = null) + protected ConflictException(ImmutableArray errors, Exception? innerException = null) : base(errors, "The requested operation could not be completed due to a conflict.", innerException) { } - protected ConflictException(CrError[] errors, string message, Exception? innerException = null) + protected ConflictException(ImmutableArray errors, string message, Exception? innerException = null) : base(errors, message, innerException) { } diff --git a/CR.Exceptions/CrError.cs b/CR.Exceptions/CrError.cs index e1e2000..7756327 100644 --- a/CR.Exceptions/CrError.cs +++ b/CR.Exceptions/CrError.cs @@ -1,3 +1,16 @@ namespace CR.Exceptions; -public sealed record class CrError(string Code, string Message); \ No newline at end of file +public sealed record class CrError +{ + public string Code { get; init; } + public string Message { get; init; } + + public CrError(string code, string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + Code = code; + Message = message; + } +} \ No newline at end of file diff --git a/CR.Exceptions/CrException.cs b/CR.Exceptions/CrException.cs index 263d093..d8eb908 100644 --- a/CR.Exceptions/CrException.cs +++ b/CR.Exceptions/CrException.cs @@ -1,29 +1,16 @@ -namespace CR.Exceptions; +using CR.Exceptions.Extensions; +using System.Collections.Immutable; + +namespace CR.Exceptions; public abstract class CrException : Exception { - /// - /// Gets the errors associated with this exception. - /// The array is not copied. - /// - public CrError[] Errors { get; } + public ImmutableArray Errors { get; } - protected CrException(CrError[] errors, string message, Exception? innerException = null) : base(message, innerException) + protected CrException(ImmutableArray errors, string message, Exception? innerException = null) : base(message, innerException) { - ArgumentNullException.ThrowIfNull(errors); ArgumentException.ThrowIfNullOrWhiteSpace(message); - - if (errors.Length == 0) - { - throw new ArgumentException("At least one error must be provided.", nameof(errors)); - } - - foreach (var error in errors) - { - ArgumentNullException.ThrowIfNull(error, nameof(errors)); - ArgumentException.ThrowIfNullOrWhiteSpace(error.Code, nameof(errors)); - ArgumentException.ThrowIfNullOrWhiteSpace(error.Message, nameof(errors)); - } + errors.ThrowIfEmptyOrContainsNull(); Errors = errors; } diff --git a/CR.Exceptions/Extensions/EnumerableExtensions.cs b/CR.Exceptions/Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000..40b82ec --- /dev/null +++ b/CR.Exceptions/Extensions/EnumerableExtensions.cs @@ -0,0 +1,32 @@ +namespace CR.Exceptions.Extensions; + +internal static class EnumerableExtensions +{ + extension(IEnumerable? source) where TSource : class? + { + public void ThrowIfEmptyOrContainsNull() + { + ArgumentNullException.ThrowIfNull(source); + + var index = 0; + var isEmpty = true; + + foreach (var item in source) + { + isEmpty = false; + + if (item is null) + { + throw new ArgumentNullException(nameof(source), $"The collection element[{index}] is null."); + } + + index++; + } + + if (isEmpty) + { + throw new ArgumentException("The collection cannot be empty.", nameof(source)); + } + } + } +} \ No newline at end of file diff --git a/CR.Exceptions/Extensions/FrozenDictionaryExtensions.cs b/CR.Exceptions/Extensions/FrozenDictionaryExtensions.cs new file mode 100644 index 0000000..95f9275 --- /dev/null +++ b/CR.Exceptions/Extensions/FrozenDictionaryExtensions.cs @@ -0,0 +1,29 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.Extensions; + +internal static class FrozenDictionaryExtensions +{ + extension(IEnumerable source) + { + public FrozenDictionary ToUniqueFrozenDictionary( + Func keySelector, + Func elementSelector, + IEqualityComparer? comparer = null) where TKey : notnull + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(keySelector); + ArgumentNullException.ThrowIfNull(elementSelector); + + try + { + var validatedDictionary = source.ToDictionary(keySelector, elementSelector, comparer); + return validatedDictionary.ToFrozenDictionary(comparer); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException("Initialization failed: sequence contains duplicate keys.", ex); + } + } + } +} \ No newline at end of file diff --git a/CR.Exceptions/ForbiddenException.cs b/CR.Exceptions/ForbiddenException.cs index b534515..674adb0 100644 --- a/CR.Exceptions/ForbiddenException.cs +++ b/CR.Exceptions/ForbiddenException.cs @@ -1,13 +1,15 @@ -namespace CR.Exceptions; +using System.Collections.Immutable; + +namespace CR.Exceptions; public abstract class ForbiddenException : CrException { - protected ForbiddenException(CrError[] errors, Exception? innerException = null) + protected ForbiddenException(ImmutableArray errors, Exception? innerException = null) : base(errors, "You do not have permission to perform this operation.", innerException) { } - protected ForbiddenException(CrError[] errors, string message, Exception? innerException = null) + protected ForbiddenException(ImmutableArray errors, string message, Exception? innerException = null) : base(errors, message, innerException) { } diff --git a/CR.Exceptions/Mapping/ErrorMap.cs b/CR.Exceptions/Mapping/ErrorMap.cs new file mode 100644 index 0000000..553370a --- /dev/null +++ b/CR.Exceptions/Mapping/ErrorMap.cs @@ -0,0 +1,36 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; + +namespace CR.Exceptions.Mapping; + +public sealed class ErrorMap +{ + private readonly FrozenDictionary _map; + + internal ErrorMap(FrozenDictionary map) + { + _map = map; + } + + public ImmutableArray Get(string code) + { + if (TryGet(code, out var errors)) + { + return errors; + } + + throw new KeyNotFoundException($"Collection with code '{code}' is not found."); + } + + public bool TryGet(string code, out ImmutableArray errors) + { + if (_map.TryGetValue(code, out var descriptor)) + { + errors = descriptor.Errors; + return true; + } + + errors = []; + return false; + } +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ErrorMapBuilder.cs b/CR.Exceptions/Mapping/ErrorMapBuilder.cs new file mode 100644 index 0000000..c15ee4c --- /dev/null +++ b/CR.Exceptions/Mapping/ErrorMapBuilder.cs @@ -0,0 +1,28 @@ +using CR.Exceptions.Extensions; + +namespace CR.Exceptions.Mapping; + +public sealed class ErrorMapBuilder +{ + private readonly RegistrationCollection _collection = new(); + + public ErrorMapBuilder Add(ErrorRegistration registration) + { + _collection.Add(registration); + return this; + } + + public ErrorMapBuilder AddRange(IEnumerable registrations) + { + _collection.AddRange(registrations); + return this; + } + + public ErrorMap Build() + { + return new(_collection.Items.ToUniqueFrozenDictionary( + keySelector: k => k.Code, + elementSelector: v => v, + comparer: StringComparer.Ordinal)); + } +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ErrorRegistration.cs b/CR.Exceptions/Mapping/ErrorRegistration.cs new file mode 100644 index 0000000..8c30948 --- /dev/null +++ b/CR.Exceptions/Mapping/ErrorRegistration.cs @@ -0,0 +1,19 @@ +using CR.Exceptions.Extensions; +using System.Collections.Immutable; + +namespace CR.Exceptions.Mapping; + +public sealed record class ErrorRegistration +{ + public string Code { get; init; } + public ImmutableArray Errors { get; init; } + + public ErrorRegistration(string code, ImmutableArray errors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + errors.ThrowIfEmptyOrContainsNull(); + + Code = code; + Errors = errors; + } +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionFactory.cs b/CR.Exceptions/Mapping/ExceptionFactory.cs new file mode 100644 index 0000000..aec062a --- /dev/null +++ b/CR.Exceptions/Mapping/ExceptionFactory.cs @@ -0,0 +1,38 @@ +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; + +namespace CR.Exceptions.Mapping; + +public sealed class ExceptionFactory +{ + private readonly FrozenDictionary _map; + + internal ExceptionFactory(FrozenDictionary map) + { + _map = map; + } + + public CrException Create(string code) + { + if (TryCreate(code, out var exception)) + { + return exception; + } + + throw new KeyNotFoundException($"Exception factory with code '{code}' is not found."); + } + + public bool TryCreate(string code, [MaybeNullWhen(false)] out CrException exception) + { + if (_map.TryGetValue(code, out var registration)) + { + exception = registration.Factory(registration.Definition.Errors) + ?? throw new NullReferenceException("The registered factory return null exception"); + + return true; + } + + exception = default; + return false; + } +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs new file mode 100644 index 0000000..e07da99 --- /dev/null +++ b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs @@ -0,0 +1,28 @@ +using CR.Exceptions.Extensions; + +namespace CR.Exceptions.Mapping; + +public sealed class ExceptionFactoryBuilder +{ + private readonly RegistrationCollection _collection = new(); + + public ExceptionFactoryBuilder Add(ExceptionRegistration registration) + { + _collection.Add(registration); + return this; + } + + public ExceptionFactoryBuilder AddRange(IEnumerable registrations) + { + _collection.AddRange(registrations); + return this; + } + + public ExceptionFactory Build() + { + return new(_collection.Items.ToUniqueFrozenDictionary( + keySelector: k => k.Definition.Code, + elementSelector: v => v, + comparer: StringComparer.Ordinal)); + } +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionRegistration.cs b/CR.Exceptions/Mapping/ExceptionRegistration.cs new file mode 100644 index 0000000..cb0d54f --- /dev/null +++ b/CR.Exceptions/Mapping/ExceptionRegistration.cs @@ -0,0 +1,18 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions.Mapping; + +public sealed record class ExceptionRegistration +{ + public ErrorRegistration Definition { get; init; } + public Func, CrException> Factory { get; init; } + + public ExceptionRegistration(ErrorRegistration definition, Func, CrException> factory) + { + ArgumentNullException.ThrowIfNull(definition); + ArgumentNullException.ThrowIfNull(factory); + + Definition = definition; + Factory = factory; + } +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/RegistrationCollection.cs b/CR.Exceptions/Mapping/RegistrationCollection.cs new file mode 100644 index 0000000..1d0d01c --- /dev/null +++ b/CR.Exceptions/Mapping/RegistrationCollection.cs @@ -0,0 +1,24 @@ +namespace CR.Exceptions.Mapping; + +internal sealed class RegistrationCollection where T : class +{ + private readonly List _items = []; + public IReadOnlyList Items => _items; + + public void Add(T item) + { + ArgumentNullException.ThrowIfNull(item); + + _items.Add(item); + } + + public void AddRange(IEnumerable items) + { + ArgumentNullException.ThrowIfNull(items); + + foreach (var item in items) + { + Add(item); + } + } +} \ No newline at end of file diff --git a/CR.Exceptions/NotFoundException.cs b/CR.Exceptions/NotFoundException.cs index 0093bcc..2957699 100644 --- a/CR.Exceptions/NotFoundException.cs +++ b/CR.Exceptions/NotFoundException.cs @@ -1,13 +1,15 @@ -namespace CR.Exceptions; +using System.Collections.Immutable; + +namespace CR.Exceptions; public abstract class NotFoundException : CrException { - protected NotFoundException(CrError[] errors, Exception? innerException = null) + protected NotFoundException(ImmutableArray errors, Exception? innerException = null) : base(errors, "The requested resource was not found.", innerException) { } - protected NotFoundException(CrError[] errors, string message, Exception? innerException = null) + protected NotFoundException(ImmutableArray errors, string message, Exception? innerException = null) : base(errors, message, innerException) { } diff --git a/CR.Exceptions/UnauthorizedException.cs b/CR.Exceptions/UnauthorizedException.cs index 618a2ab..f45ddac 100644 --- a/CR.Exceptions/UnauthorizedException.cs +++ b/CR.Exceptions/UnauthorizedException.cs @@ -1,13 +1,15 @@ -namespace CR.Exceptions; +using System.Collections.Immutable; + +namespace CR.Exceptions; public abstract class UnauthorizedException : CrException { - protected UnauthorizedException(CrError[] errors, Exception? innerException = null) + protected UnauthorizedException(ImmutableArray errors, Exception? innerException = null) : base(errors, "Authentication is required to access this resource.", innerException) { } - protected UnauthorizedException(CrError[] errors, string message, Exception? innerException = null) + protected UnauthorizedException(ImmutableArray errors, string message, Exception? innerException = null) : base(errors, message, innerException) { } diff --git a/CR.Exceptions/UnprocessableException.cs b/CR.Exceptions/UnprocessableException.cs index e792199..58bf754 100644 --- a/CR.Exceptions/UnprocessableException.cs +++ b/CR.Exceptions/UnprocessableException.cs @@ -1,13 +1,15 @@ -namespace CR.Exceptions; +using System.Collections.Immutable; + +namespace CR.Exceptions; public abstract class UnprocessableException : CrException { - protected UnprocessableException(CrError[] errors, Exception? innerException = null) + protected UnprocessableException(ImmutableArray errors, Exception? innerException = null) : base(errors, "The request could not be processed.", innerException) { } - protected UnprocessableException(CrError[] errors, string message, Exception? innerException = null) + protected UnprocessableException(ImmutableArray errors, string message, Exception? innerException = null) : base(errors, message, innerException) { } diff --git a/CR.Exceptions/ValidationException.cs b/CR.Exceptions/ValidationException.cs index ec133be..b071177 100644 --- a/CR.Exceptions/ValidationException.cs +++ b/CR.Exceptions/ValidationException.cs @@ -1,13 +1,15 @@ -namespace CR.Exceptions; +using System.Collections.Immutable; + +namespace CR.Exceptions; public abstract class ValidationException : CrException { - protected ValidationException(CrError[] errors, Exception? innerException = null) + protected ValidationException(ImmutableArray errors, Exception? innerException = null) : base(errors, "One or more validation errors occurred.", innerException) { } - protected ValidationException(CrError[] errors, string message, Exception? innerException = null) + protected ValidationException(ImmutableArray errors, string message, Exception? innerException = null) : base(errors, message, innerException) { } diff --git a/README.md b/README.md index cd8e9a6..b14e3c7 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,66 @@ # Intro -A lightweight library for defining application exceptions and automatically converting them into HTTP responses in ASP.NET Core. +A lightweight framework for defining application errors, creating typed exceptions, and exposing consistent error responses across application boundaries. + +CR.Exceptions separates: + +- error definition (`CrError`); +- application exceptions (`CrException`); +- external error mapping (`ErrorMap`); +- exception creation (`ExceptionFactory`); +- HTTP response representation (ASP.NET Core integration). + +The goal is to keep external service errors (for example Keycloak, GitHub, payment providers) isolated from application logic while providing a consistent error contract for clients. + +--- ## Installation -Register the exception handler during application startup. +Register the exception handler during application startup: ```csharp builder.Services.AddCrExceptionHandler(); -``` -Or configure custom exception mappings. +app.UseExceptionHandler(); +```` + +Custom mappings can be configured: ```csharp builder.Services.AddCrExceptionHandler(options => { - options.ExceptionMapping.AddDefaultMappings(); - options.ExceptionMapping.Map(499); + options.StatusCodes.AddDefaultMappings(); + options.StatusCodes.Map(499); }); ``` -Enable exception handling middleware. +--- -```csharp -app.UseExceptionHandler(); -``` +# Error Model + +Every application error is represented by `CrError`. + +Each error provides: + +* `Code` — stable identifier used by clients. +* `Message` — human-readable error description. -## Creating a Custom Exception +--- -Create your own exception by inheriting from one of the provided exception categories. +# Application Exceptions + +Application exceptions are created by inheriting from one of the provided exception categories. + +Available categories: + +| Exception | HTTP Status | +| ------------------------ | ----------: | +| `ValidationException` | 400 | +| `UnauthorizedException` | 401 | +| `ForbiddenException` | 403 | +| `NotFoundException` | 404 | +| `ConflictException` | 409 | +| `UnprocessableException` | 422 | Example: @@ -36,80 +68,143 @@ Example: public sealed class UserNotFoundException : NotFoundException { public UserNotFoundException(Guid userId) : base( - [new CrError("UserNotFound", $"User '{userId}' was not found.")], + [new CrError("Identity.UserNotFound", $"User '{userId}' was not found.")], "User was not found.") { } } ``` +Throw exceptions normally: + ```csharp -public sealed record class CrError(string Code, string Message); +throw new UserNotFoundException(userId); ``` -Each error provides: -- `Code` — unique identifier used by clients. -- `Message` — human-readable error description. +--- + +# External Error Mapping -## Throwing an Exception +External systems usually expose their own error codes. -Throw your custom exception normally. +For example, an external API may return: + +``` +user_not_found +```` + +These codes can be registered in `ErrorMap` and converted into application-level errors: ```csharp -throw new UserNotFoundException(userId); +ErrorMap errorMap = _errorMapBuilder.Add(new ErrorRegistration( + "user_not_found", + [new CrError("IdentityUserNotFound", "User was not found.")])).Build(); +```` + +After registration, the application can resolve errors by external code: + +```csharp +if (errorMap.TryGet("user_not_found", out var errors)) +{ + throw new UserNotFoundException(errors); +} ``` -The exception handler automatically converts it into an RFC 7807 `ProblemDetails` response. +This allows application services to decide how external errors should be handled. -Example: +For example, API clients may map external HTTP responses to application exceptions: + +```csharp +switch (response.StatusCode) +{ + case 404: + throw new ApiNotFoundException(errors); + + case 403: + throw new ApiForbiddenException(errors); + + default: + throw new ApiException(errors); +} +``` + +--- + +# ExceptionFactory + +`ExceptionFactory` creates typed exceptions from registered error codes. + +Example registration: + +```csharp +var registration = new ErrorRegistration( + "invalid_grant", + [new CrError("IdentityInvalidCredentials", "Invalid username or password.")]); + +ExceptionFactory factory = _exceptionFactoryBuilder.Add( + new ExceptionRegistration( + registration, + errors => new InvalidCredentialsException(errors))).Build(); +``` + +After registration: + +```csharp +var exception = factory.Create("invalid_grant"); +throw exception; +``` + +The factory only handles registered error codes. +If a code is not registered, the application should decide how to handle this case. + +--- + +# ASP.NET Core Integration + +CR.Exceptions automatically converts `CrException` instances into RFC 7807 `ProblemDetails` responses. + +Example response: ```json { - "type": "about:blank", + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", "title": "Not Found", "status": 404, - "detail": "User was not found.", - "instance": "/api/users/123", - "traceId": "0HNNAF6ABMHQO", + "detail": "The requested resource was not found.", + "instance": "/api/test", "errors": [ { - "code": "UserNotFound", - "message": "User '123' was not found." + "code": "TestNotFound", + "message": "Test Entity not found" } - ] + ], + "traceId": "5a1192a06ca5cd006057ca7e6b84e231" } ``` -## Default Exception Mappings +Clients should use the `errors[].code` value as the stable identifier. -| Exception Category | HTTP Status | -|--------------------|------------:| -| `ValidationException` | 400 Bad Request | -| `UnauthorizedException` | 401 Unauthorized | -| `ForbiddenException` | 403 Forbidden | -| `NotFoundException` | 404 Not Found | -| `ConflictException` | 409 Conflict | -| `UnprocessableException` | 422 Unprocessable Entity | +--- -## Internal Errors +# Internal Errors -Unexpected exceptions are automatically converted into a generic internal error response. +Unexpected exceptions are converted into a generic internal error response. Example: ```json { - "type": "about:blank", - "title": "Internal Server Error", + "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1", + "title": "An error occurred while processing your request.", "status": 500, "detail": "An unexpected error occurred.", - "instance": "", - "traceId": "0HNNAF6ABMHQO", + "instance": "/api/test", "errors": [ { "code": "InternalError", "message": "An unexpected internal error occurred." } - ] + ], + "traceId": "3e071f69b5f9e695a32a699369b57651" } -``` +``` \ No newline at end of file