diff --git a/CR.Exceptions.AspNet.UnitTests/CR.Exceptions.AspNet.UnitTests.csproj b/CR.Exceptions.AspNet.UnitTests/CR.Exceptions.AspNet.Tests.csproj similarity index 100% rename from CR.Exceptions.AspNet.UnitTests/CR.Exceptions.AspNet.UnitTests.csproj rename to CR.Exceptions.AspNet.UnitTests/CR.Exceptions.AspNet.Tests.csproj diff --git a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs b/CR.Exceptions.AspNet.UnitTests/Component/CrExceptionHandlerTests.cs similarity index 77% rename from CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs rename to CR.Exceptions.AspNet.UnitTests/Component/CrExceptionHandlerTests.cs index 338705d..1b0be3f 100644 --- a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/Component/CrExceptionHandlerTests.cs @@ -5,7 +5,7 @@ using System.Diagnostics; using System.Text.Json; -namespace CR.Exceptions.AspNet.UnitTests; +namespace CR.Exceptions.AspNet.Tests.Component; public sealed class CrExceptionHandlerTests { @@ -31,7 +31,7 @@ public Task Should_Return_404_For_NotFoundException() public Task Should_Return_500_For_UnhandledException() { return AssertHandlerResult( - new Exception("Unknown exception"), + new InvalidOperationException(), StatusCodes.Status500InternalServerError, canCreateActivity: true); } @@ -40,7 +40,7 @@ public Task Should_Return_500_For_UnhandledException() public Task Should_Return_500_For_UnhandledException_When_Activity_Is_Missing() { return AssertHandlerResult( - new Exception("Unknown exception"), + new InvalidOperationException(), StatusCodes.Status500InternalServerError, canCreateActivity: false); } @@ -57,43 +57,29 @@ private async Task AssertHandlerResult(Exception exception, int expectedStatusCo using var responseStream = new MemoryStream(); var context = CreateContext(responseStream); - var isHandled = await handler.TryHandleAsync(context, exception, CancellationToken.None); - - Assert.True(isHandled); - Assert.Equal(expectedStatusCode, context.Response.StatusCode); - Assert.Contains("application/problem+json", context.Response.ContentType); + Assert.True(await handler.TryHandleAsync(context, exception, CancellationToken.None)); + AssertHttpContext(context, expectedStatusCode); responseStream.Position = 0; - var problem = await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions.Web); + var problem = await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions.Web); var expectedTraceId = activity?.TraceId.ToHexString() ?? context.TraceIdentifier; AssertProblemDetails(problem, context, expectedStatusCode, expectedTraceId); - } - private static ServiceProvider CreateServiceProvider() - { - return new ServiceCollection() - .AddLogging() - .AddCrExceptionHandler() - .BuildServiceProvider(); + _output.WriteLine(JsonSerializer.Serialize(problem, options: _prettyJsonOptions)); } - private static DefaultHttpContext CreateContext(MemoryStream responseStream) + private static void AssertHttpContext(HttpContext context, int expectedStatusCode) { - return new DefaultHttpContext - { - Request = { Path = "/api/test" }, - Response = { Body = responseStream } - }; + Assert.Equal(expectedStatusCode, context.Response.StatusCode); + Assert.Contains("application/problem+json", context.Response.ContentType); } - private void AssertProblemDetails(CustomProblemDetails? problem, HttpContext context, int expectedStatusCode, string? expectedTraceId) + private static void AssertProblemDetails(TestProblemDetails? problem, HttpContext context, int expectedStatusCode, string? expectedTraceId) { Assert.NotNull(problem); - _output.WriteLine(JsonSerializer.Serialize(problem, options: _prettyJsonOptions)); - Assert.False(string.IsNullOrEmpty(problem.Type)); Assert.False(string.IsNullOrEmpty(problem.Title)); Assert.False(string.IsNullOrEmpty(problem.Detail)); @@ -101,17 +87,31 @@ private void AssertProblemDetails(CustomProblemDetails? problem, HttpContext con Assert.Equal(expectedStatusCode, problem.Status); Assert.Equal(context.Request.Path, problem.Instance); - Assert.True(problem.Extensions.TryGetValue( - ProblemDetailsExtensionNames.TraceId, - out var traceId)); - - Assert.Equal(expectedTraceId, traceId?.ToString()); + Assert.True(problem.Extensions.TryGetValue(ProblemDetailsExtensionNames.TraceId, out var traceId)); + Assert.Equal(expectedTraceId, traceId!.ToString()); Assert.NotNull(problem.Errors); Assert.NotEmpty(problem.Errors); } - private sealed class CustomProblemDetails : ProblemDetails + private static ServiceProvider CreateServiceProvider() + { + return new ServiceCollection() + .AddLogging() + .AddCrExceptions() + .BuildServiceProvider(); + } + + private static DefaultHttpContext CreateContext(MemoryStream responseStream) + { + return new DefaultHttpContext + { + Request = { Path = "/api/test" }, + Response = { Body = responseStream } + }; + } + + private sealed class TestProblemDetails : ProblemDetails { public CrError[]? Errors { get; set; } } diff --git a/CR.Exceptions.AspNet.UnitTests/Component/LogLevelMapTests.cs b/CR.Exceptions.AspNet.UnitTests/Component/LogLevelMapTests.cs new file mode 100644 index 0000000..262a621 --- /dev/null +++ b/CR.Exceptions.AspNet.UnitTests/Component/LogLevelMapTests.cs @@ -0,0 +1,35 @@ +using CR.Exceptions.AspNet.Mapping; +using Microsoft.Extensions.Logging; + +namespace CR.Exceptions.AspNet.Tests.Component; + +public sealed class LogLevelMapTests +{ + [Fact] + public void TryFind_ShouldReturn_Level_For_NotFoundException() + { + var level = LogLevel.Warning; + var map = CreateMap(builder => builder.Map(level)); + + var result = map.TryFind(new TestNotFoundException(), out var actualLevel); + + Assert.True(result); + Assert.Equal(level, actualLevel); + } + + [Fact] + public void TryFind_ShouldReturn_False_For_UnregisteredException() + { + var map = CreateMap(); + + Assert.False(map.TryFind(new TestUnregisteredException(), out var _)); + } + + private static LogLevelMap CreateMap(Action? configurator = null) + { + var builder = new LogLevelMapBuilder(); + configurator?.Invoke(builder); + + return builder.Build(); + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet.UnitTests/Component/StatusCodeMapTests.cs b/CR.Exceptions.AspNet.UnitTests/Component/StatusCodeMapTests.cs new file mode 100644 index 0000000..3a426d4 --- /dev/null +++ b/CR.Exceptions.AspNet.UnitTests/Component/StatusCodeMapTests.cs @@ -0,0 +1,35 @@ +using CR.Exceptions.AspNet.Mapping; +using Microsoft.AspNetCore.Http; + +namespace CR.Exceptions.AspNet.Tests.Component; + +public sealed class StatusCodeMapTests +{ + [Fact] + public void TryFind_ShouldReturn_404_For_NotFoundException() + { + var code = StatusCodes.Status404NotFound; + var map = CreateMap(builder => builder.Map(code)); + + var result = map.TryFind(new TestNotFoundException(), out var actualCode); + + Assert.True(result); + Assert.Equal(code, actualCode); + } + + [Fact] + public void TryFind_ShouldReturn_False_For_UnregisteredException() + { + var map = CreateMap(); + + Assert.False(map.TryFind(new TestUnregisteredException(), out var _)); + } + + private static StatusCodeMap CreateMap(Action? configurator = null) + { + var builder = new StatusCodeMapBuilder(); + configurator?.Invoke(builder); + + return builder.Build(); + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs deleted file mode 100644 index 1ad37bb..0000000 --- a/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -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 e2ab35c..caee3f5 100644 --- a/CR.Exceptions.AspNet.UnitTests/TestNotFoundException.cs +++ b/CR.Exceptions.AspNet.UnitTests/TestNotFoundException.cs @@ -1,8 +1,8 @@ -namespace CR.Exceptions.AspNet.UnitTests; +namespace CR.Exceptions.AspNet.Tests; internal sealed class TestNotFoundException : NotFoundException { - public TestNotFoundException() : base([new("TestNotFound", "Test Entity not found")]) + public TestNotFoundException() : base([new("TestNotFound", "Test entity not found error message")]) { } } \ No newline at end of file diff --git a/CR.Exceptions.AspNet.UnitTests/TestUnregisteredException.cs b/CR.Exceptions.AspNet.UnitTests/TestUnregisteredException.cs new file mode 100644 index 0000000..4d9a96b --- /dev/null +++ b/CR.Exceptions.AspNet.UnitTests/TestUnregisteredException.cs @@ -0,0 +1,8 @@ +namespace CR.Exceptions.AspNet.Tests; + +internal sealed class TestUnregisteredException : CrException +{ + public TestUnregisteredException() : base([new("TestUnregistered", "Test error message")], "Unregistered detail") + { + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet.UnitTests/Unit/LogLevelMapBuilderTests.cs b/CR.Exceptions.AspNet.UnitTests/Unit/LogLevelMapBuilderTests.cs new file mode 100644 index 0000000..54e5334 --- /dev/null +++ b/CR.Exceptions.AspNet.UnitTests/Unit/LogLevelMapBuilderTests.cs @@ -0,0 +1,43 @@ +using CR.Exceptions.AspNet.Mapping; +using Microsoft.Extensions.Logging; + +namespace CR.Exceptions.AspNet.Tests.Unit; + +public sealed class LogLevelMapBuilderTests +{ + [Fact] + public void Map_ShouldThrow_When_DuplicateLevelRegistered() + { + var builder = CreateBuilder() + .Map(LogLevel.Error); + + Assert.ThrowsAny(() => builder.Map(LogLevel.Warning)); + } + + [Fact] + public void Map_ShouldThrow_When_InvalidLevelRegistered() + { + var builder = CreateBuilder(); + + Assert.ThrowsAny(() => builder.Map((LogLevel)4000)); + } + + [Fact] + public void Map_ShouldThrow_When_NoneLevelRegistered() + { + var builder = CreateBuilder(); + + Assert.ThrowsAny(() => builder.Map(LogLevel.None)); + } + + [Fact] + public void Build_ShouldReturn_Map_WithDefaultMappings() + { + var builder = CreateBuilder() + .AddDefaultMappings(); + + Assert.NotNull(() => builder.Build()); + } + + private static LogLevelMapBuilder CreateBuilder() => new(); +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet.UnitTests/Unit/StatusCodeMapBuilderTests.cs b/CR.Exceptions.AspNet.UnitTests/Unit/StatusCodeMapBuilderTests.cs new file mode 100644 index 0000000..1ad0d71 --- /dev/null +++ b/CR.Exceptions.AspNet.UnitTests/Unit/StatusCodeMapBuilderTests.cs @@ -0,0 +1,35 @@ +using CR.Exceptions.AspNet.Mapping; +using Microsoft.AspNetCore.Http; + +namespace CR.Exceptions.AspNet.Tests.Unit; + +public sealed class StatusCodeMapBuilderTests +{ + [Fact] + public void Map_ShouldThrow_When_DuplicateCodeRegistered() + { + var builder = CreateBuilder() + .Map(StatusCodes.Status400BadRequest); + + Assert.ThrowsAny(() => builder.Map(StatusCodes.Status404NotFound)); + } + + [Fact] + public void Map_ShouldThrow_When_InvalidCodeRegistered() + { + var builder = CreateBuilder(); + + Assert.ThrowsAny(() => builder.Map(4000)); + } + + [Fact] + public void Build_ShouldReturn_Map_WithDefaultMappings() + { + var builder = CreateBuilder() + .AddDefaultMappings(); + + Assert.NotNull(() => builder.Build()); + } + + private static StatusCodeMapBuilder CreateBuilder() => new(); +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/CR.Exceptions.AspNet.csproj b/CR.Exceptions.AspNet/CR.Exceptions.AspNet.csproj index 0ed816f..fb57f64 100644 --- a/CR.Exceptions.AspNet/CR.Exceptions.AspNet.csproj +++ b/CR.Exceptions.AspNet/CR.Exceptions.AspNet.csproj @@ -8,7 +8,11 @@ ASP.NET Core integration for CR.Exceptions. + $(NuGetPackagePrefix).Exceptions.AspNet + exceptions;aspnetcore;webapi;problem-details;rfc7807;middleware;exception-handler + + README.md @@ -19,4 +23,8 @@ - + + + + + \ No newline at end of file diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.Logger.cs b/CR.Exceptions.AspNet/CrExceptionHandler.Logger.cs deleted file mode 100644 index 82031b8..0000000 --- a/CR.Exceptions.AspNet/CrExceptionHandler.Logger.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace CR.Exceptions.AspNet; - -public sealed partial class CrExceptionHandler -{ - [LoggerMessage( - Level = LogLevel.Error, - Message = "Cannot write exception response because the response has already started.")] - private static partial void LogResponseAlreadyStarted(ILogger logger, Exception exception); - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "No HTTP status code mapping found for exception type '{ExceptionType}'. Using 500 Internal Server Error.")] - private static partial void LogMissingHttpStatusMapping(ILogger logger, Exception exception, string exceptionType); - - [LoggerMessage( - Level = LogLevel.Debug, - Message = "Application exception of type '{ExceptionType}' occurred.")] - private static partial void LogApplicationException(ILogger logger, Exception exception, string exceptionType); - - [LoggerMessage( - Level = LogLevel.Error, - Message = "An unexpected exception of type '{ExceptionType}' occurred.")] - private static partial void LogUnhandledException(ILogger logger, Exception exception, string exceptionType); - - [LoggerMessage( - Level = LogLevel.Warning, - Message = "The ProblemDetails extension '{Key}' was overwritten while building the error response.")] - private static partial void LogProblemDetailsExtensionOverwritten(ILogger logger, string key); - - [LoggerMessage( - Level = LogLevel.Error, - Message = "Failed to write ProblemDetails response.")] - private static partial void LogFailedToWriteProblemDetails(ILogger logger, Exception exception); -} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.cs b/CR.Exceptions.AspNet/CrExceptionHandler.cs index da07cef..7cc15a8 100644 --- a/CR.Exceptions.AspNet/CrExceptionHandler.cs +++ b/CR.Exceptions.AspNet/CrExceptionHandler.cs @@ -1,40 +1,45 @@ -using Microsoft.AspNetCore.Diagnostics; +using CR.Exceptions.AspNet.Mapping; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using System.Collections.Immutable; namespace CR.Exceptions.AspNet; -public sealed partial class CrExceptionHandler : IExceptionHandler +public sealed class CrExceptionHandler : IExceptionHandler { private static readonly ImmutableArray DefaultInternalErrors = [new("InternalError", "An unexpected internal error occurred.")]; private readonly IProblemDetailsService _problemDetailsService; - private readonly CrExceptionOptions _options; private readonly ILogger _logger; + private readonly StatusCodeMap _statusCodeMap; + private readonly LogLevelMap _logLevelMap; + public CrExceptionHandler( IProblemDetailsService problemDetailsService, - IOptions options, - ILogger logger) + ILogger logger, + StatusCodeMap statusCodeMap, + LogLevelMap logLevelMap) { _problemDetailsService = problemDetailsService; - _options = options.Value; _logger = logger; + + _statusCodeMap = statusCodeMap; + _logLevelMap = logLevelMap; } public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { if (httpContext.Response.HasStarted) { - LogResponseAlreadyStarted(_logger, exception); + _logger.LogResponseAlreadyStarted(exception); return false; } - var httpStatusCode = StatusCodes.Status500InternalServerError; + var statusCode = StatusCodes.Status500InternalServerError; var exceptionType = exception.GetType(); var exceptionTypeName = exceptionType.FullName ?? exceptionType.Name; @@ -46,25 +51,24 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e detail = crException.Message; errors = crException.Errors; - var statusCode = _options.StatusCodes.FindHttpStatusCode(crException); - - if (statusCode is null) + if (_statusCodeMap.TryFind(crException, out var code)) { - LogMissingHttpStatusMapping(_logger, exception, exceptionTypeName); + statusCode = code; } else { - httpStatusCode = statusCode.Value; + _logger.LogMissingHttpStatusMapping(exception, exceptionTypeName); } - LogApplicationException(_logger, exception, exceptionTypeName); + var logLevel = _logLevelMap.TryFind(crException, out var level) ? level : LogLevel.Debug; + _logger.LogApplicationException(logLevel, exception, exceptionTypeName); } else { - LogUnhandledException(_logger, exception, exceptionTypeName); + _logger.LogUnhandledException(exception, exceptionTypeName); } - httpContext.Response.StatusCode = httpStatusCode; + httpContext.Response.StatusCode = statusCode; var problemDetailsContext = new ProblemDetailsContext { @@ -72,21 +76,14 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Exception = exception, ProblemDetails = { - Status = httpStatusCode, + Status = statusCode, Detail = detail, Instance = httpContext.Request.Path }, }; AddProblemDetailsExtension(problemDetailsContext.ProblemDetails, ProblemDetailsExtensionNames.Errors, errors); - var isWritten = await _problemDetailsService.TryWriteAsync(problemDetailsContext); - - if (!isWritten) - { - LogFailedToWriteProblemDetails(_logger, exception); - } - - return isWritten; + return await TryWriteResponseAsync(exception, problemDetailsContext); } private void AddProblemDetailsExtension(ProblemDetails problemDetails, string key, object? value) @@ -94,7 +91,19 @@ private void AddProblemDetailsExtension(ProblemDetails problemDetails, string ke if (!problemDetails.Extensions.TryAdd(key, value)) { problemDetails.Extensions[key] = value; - LogProblemDetailsExtensionOverwritten(_logger, key); + _logger.LogProblemDetailsExtensionOverwritten(key); } } + + private async Task TryWriteResponseAsync(Exception exception, ProblemDetailsContext problemDetailsContext) + { + var isWritten = await _problemDetailsService.TryWriteAsync(problemDetailsContext); + + if (!isWritten) + { + _logger.LogFailedToWriteProblemDetails(exception); + } + + return isWritten; + } } \ No newline at end of file diff --git a/CR.Exceptions.AspNet/CrExceptionHandlerLogExtensions.cs b/CR.Exceptions.AspNet/CrExceptionHandlerLogExtensions.cs new file mode 100644 index 0000000..a4f4e6f --- /dev/null +++ b/CR.Exceptions.AspNet/CrExceptionHandlerLogExtensions.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Logging; + +namespace CR.Exceptions.AspNet; + +public static partial class CrExceptionHandlerLogExtensions +{ + private static class LogIds + { + private const int BaseId = 33_000; + + public const int ResponseAlreadyStarted = BaseId + 1; + public const int MissingHttpStatusMapping = BaseId + 2; + public const int UnhandledException = BaseId + 3; + public const int ProblemDetailsExtensionOverwritten = BaseId + 4; + public const int FailedToWriteProblemDetails = BaseId + 5; + public const int ApplicationException = BaseId + 6; + } + + [LoggerMessage( + EventId = LogIds.ResponseAlreadyStarted, + Level = LogLevel.Error, + Message = "Cannot write exception response because the response has already started.")] + public static partial void LogResponseAlreadyStarted(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = LogIds.MissingHttpStatusMapping, + Level = LogLevel.Warning, + Message = "No HTTP status code mapping found for exception type '{ExceptionType}'. Using 500 Internal Server Error.")] + public static partial void LogMissingHttpStatusMapping(this ILogger logger, Exception exception, string? exceptionType); + + [LoggerMessage( + EventId = LogIds.UnhandledException, + Level = LogLevel.Error, + Message = "An unexpected exception of type '{ExceptionType}' occurred.")] + public static partial void LogUnhandledException(this ILogger logger, Exception exception, string? exceptionType); + + [LoggerMessage( + EventId = LogIds.ProblemDetailsExtensionOverwritten, + Level = LogLevel.Warning, + Message = "The ProblemDetails extension '{Key}' was overwritten while building the error response.")] + public static partial void LogProblemDetailsExtensionOverwritten(this ILogger logger, string key); + + [LoggerMessage( + EventId = LogIds.FailedToWriteProblemDetails, + Level = LogLevel.Error, + Message = "Failed to write ProblemDetails response.")] + public static partial void LogFailedToWriteProblemDetails(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = LogIds.ApplicationException, + Message = "Application exception of type '{ExceptionType}' occurred.")] + public static partial void LogApplicationException(this ILogger logger, LogLevel level, Exception exception, string? exceptionType); +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/CrExceptionOptions.cs b/CR.Exceptions.AspNet/CrExceptionOptions.cs deleted file mode 100644 index 0f83c58..0000000 --- a/CR.Exceptions.AspNet/CrExceptionOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -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/ExceptionStatusCodeOptions.cs b/CR.Exceptions.AspNet/ExceptionStatusCodeOptions.cs deleted file mode 100644 index 15a1f29..0000000 --- a/CR.Exceptions.AspNet/ExceptionStatusCodeOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CR.Exceptions.AspNet; - -public sealed class ExceptionStatusCodeOptions -{ - private readonly Dictionary _map = []; - - public ExceptionStatusCodeOptions Map(int httpStatusCode) where TException : CrException - { - if (!_map.TryAdd(typeof(TException), httpStatusCode)) - { - throw new InvalidOperationException( - $"The exception '{typeof(TException).FullName}' has already been mapped."); - } - - return this; - } - - public int? FindHttpStatusCode(CrException exception) - { - ArgumentNullException.ThrowIfNull(exception); - - for (var type = exception.GetType(); type is not null; type = type.BaseType) - { - if (_map.TryGetValue(type, out var httpStatusCode)) - { - return httpStatusCode; - } - } - - return null; - } -} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Mapping/LogLevelMap.cs b/CR.Exceptions.AspNet/Mapping/LogLevelMap.cs new file mode 100644 index 0000000..a04555e --- /dev/null +++ b/CR.Exceptions.AspNet/Mapping/LogLevelMap.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Logging; +using System.Collections.Frozen; + +namespace CR.Exceptions.AspNet.Mapping; + +public sealed class LogLevelMap : TypeMap +{ + internal LogLevelMap(FrozenDictionary dictionary) : base(dictionary) + { + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Mapping/LogLevelMapBuilder.cs b/CR.Exceptions.AspNet/Mapping/LogLevelMapBuilder.cs new file mode 100644 index 0000000..f2319f4 --- /dev/null +++ b/CR.Exceptions.AspNet/Mapping/LogLevelMapBuilder.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; + +namespace CR.Exceptions.AspNet.Mapping; + +public sealed class LogLevelMapBuilder : TypeMapBuilder +{ + public LogLevelMap Build() + { + return new(BuildFrozenDictionary()); + } + + protected override void ThrowIfInvalidValue(LogLevel value) + { + if (!Enum.IsDefined(value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, $"The value '{value}' is not a valid {nameof(LogLevel)}."); + } + + if (value == LogLevel.None) + { + throw new ArgumentException( + $"{nameof(LogLevel)}.{nameof(LogLevel.None)} cannot be used for exception mapping.", nameof(value)); + } + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Mapping/LogLevelMapBuilderExtensions.cs b/CR.Exceptions.AspNet/Mapping/LogLevelMapBuilderExtensions.cs new file mode 100644 index 0000000..e40716d --- /dev/null +++ b/CR.Exceptions.AspNet/Mapping/LogLevelMapBuilderExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Logging; + +namespace CR.Exceptions.AspNet.Mapping; + +public static class LogLevelMapBuilderExtensions +{ + extension(LogLevelMapBuilder builder) + { + public LogLevelMapBuilder AddDefaultMappings() + { + builder + .Map(LogLevel.Error); + + return builder; + } + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Mapping/StatusCodeMap.cs b/CR.Exceptions.AspNet/Mapping/StatusCodeMap.cs new file mode 100644 index 0000000..3fe076a --- /dev/null +++ b/CR.Exceptions.AspNet/Mapping/StatusCodeMap.cs @@ -0,0 +1,10 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.AspNet.Mapping; + +public sealed class StatusCodeMap : TypeMap +{ + internal StatusCodeMap(FrozenDictionary dictionary) : base(dictionary) + { + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Mapping/StatusCodeMapBuilder.cs b/CR.Exceptions.AspNet/Mapping/StatusCodeMapBuilder.cs new file mode 100644 index 0000000..4ab0eca --- /dev/null +++ b/CR.Exceptions.AspNet/Mapping/StatusCodeMapBuilder.cs @@ -0,0 +1,20 @@ +using System.Net; + +namespace CR.Exceptions.AspNet.Mapping; + +public sealed class StatusCodeMapBuilder : TypeMapBuilder +{ + public StatusCodeMap Build() + { + return new(BuildFrozenDictionary()); + } + + protected override void ThrowIfInvalidValue(int value) + { + if (!Enum.IsDefined(typeof(HttpStatusCode), value)) + { + throw new ArgumentOutOfRangeException( + nameof(value), $"'{value}' is not a standard HTTP status code."); + } + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/ExceptionStatusCodeOptionsExtensions.cs b/CR.Exceptions.AspNet/Mapping/StatusCodeMapBuilderExtensions.cs similarity index 59% rename from CR.Exceptions.AspNet/ExceptionStatusCodeOptionsExtensions.cs rename to CR.Exceptions.AspNet/Mapping/StatusCodeMapBuilderExtensions.cs index ad63c08..ecd4606 100644 --- a/CR.Exceptions.AspNet/ExceptionStatusCodeOptionsExtensions.cs +++ b/CR.Exceptions.AspNet/Mapping/StatusCodeMapBuilderExtensions.cs @@ -1,20 +1,23 @@ using Microsoft.AspNetCore.Http; -namespace CR.Exceptions.AspNet; +namespace CR.Exceptions.AspNet.Mapping; -public static class ExceptionStatusCodeOptionsExtensions +public static class StatusCodeMapBuilderExtensions { - extension(ExceptionStatusCodeOptions options) + extension(StatusCodeMapBuilder builder) { - public ExceptionStatusCodeOptions AddDefaultMappings() + public StatusCodeMapBuilder AddDefaultMappings() { - return options + builder .Map(StatusCodes.Status400BadRequest) .Map(StatusCodes.Status401Unauthorized) .Map(StatusCodes.Status403Forbidden) .Map(StatusCodes.Status404NotFound) .Map(StatusCodes.Status409Conflict) - .Map(StatusCodes.Status422UnprocessableEntity); + .Map(StatusCodes.Status422UnprocessableEntity) + .Map(StatusCodes.Status500InternalServerError); + + return builder; } } } \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Mapping/TypeMap.cs b/CR.Exceptions.AspNet/Mapping/TypeMap.cs new file mode 100644 index 0000000..1aada42 --- /dev/null +++ b/CR.Exceptions.AspNet/Mapping/TypeMap.cs @@ -0,0 +1,26 @@ +using CR.Exceptions.Mapping; +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; + +namespace CR.Exceptions.AspNet.Mapping; + +public abstract class TypeMap : Map +{ + protected TypeMap(FrozenDictionary dictionary) : base(dictionary) { } + + public bool TryFind(CrException exception, [MaybeNullWhen(false)] out TValue value) + { + ArgumentNullException.ThrowIfNull(exception); + + for (var type = exception.GetType(); type is not null; type = type.BaseType) + { + if (TryGetValue(type, out value)) + { + return true; + } + } + + value = default; + return false; + } +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/Mapping/TypeMapBuilder.cs b/CR.Exceptions.AspNet/Mapping/TypeMapBuilder.cs new file mode 100644 index 0000000..6f866d6 --- /dev/null +++ b/CR.Exceptions.AspNet/Mapping/TypeMapBuilder.cs @@ -0,0 +1,16 @@ +using CR.Exceptions.Mapping; + +namespace CR.Exceptions.AspNet.Mapping; + +public abstract class TypeMapBuilder : MapBuilder +{ + public TypeMapBuilder Map(TValue value) where TException : CrException + { + ThrowIfInvalidValue(value); + Add(typeof(TException), value); + + return this; + } + + protected abstract void ThrowIfInvalidValue(TValue value); +} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/README.md b/CR.Exceptions.AspNet/README.md new file mode 100644 index 0000000..54153bc --- /dev/null +++ b/CR.Exceptions.AspNet/README.md @@ -0,0 +1,126 @@ +# Intro + +ASP.NET Core integration for **CrCore.Exceptions**. + +This package provides automatic handling of `CrException` instances, converts them into RFC 7807 `ProblemDetails` responses, and supports configurable exception-to-HTTP status code and log level mappings. + +## Features + +- ASP.NET Core exception handler +- RFC 7807 `ProblemDetails` responses +- Configurable exception → HTTP status code mapping +- Configurable exception → log level mapping +- Generic handling of unexpected exceptions + +--- + +# Installation + +```bash +dotnet add package CrCore.Exceptions.AspNet +``` + +Register the default exception handling during application startup. + +```csharp +builder.Services.AddCrExceptions(); + +app.UseExceptionHandler(); +``` + +--- + +# Default HTTP Status Code Mapping + +| Exception | HTTP Status | +|-----------|------------:| +| `ValidationException` | 400 | +| `UnauthorizedException` | 401 | +| `ForbiddenException` | 403 | +| `NotFoundException` | 404 | +| `ConflictException` | 409 | +| `UnprocessableException` | 422 | +| `InternalException` | 500 | + +# Custom HTTP Status Code Mapping + +```csharp +builder.Services.AddCrStatusCodeMapping(builder => +{ + builder.AddDefaultMappings(); + + builder.Map(StatusCodes.Status499ClientClosedRequest); +}); +``` + +--- + +# Default Log Level Mapping + +| Exception | Log Level | +|-----------|------------:| +| `InternalException` | Error | +| `OtherUnregistered` | Debug | + +# Custom Log Level Mapping + +```csharp +builder.Services.AddCrLogLevelMapping(builder => +{ + builder.AddDefaultMappings(); + + builder.Map(LogLevel.Warning); +}); +``` + +--- + +# ProblemDetails Response + +`CrException` instances are automatically converted into RFC 7807 `ProblemDetails`. + +Example: + +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", + "title": "Not Found", + "status": 404, + "detail": "The requested resource was not found.", + "instance": "/api/users/1", + "errors": [ + { + "code": "Identity.UserNotFound", + "message": "User was not found." + } + ], + "traceId": "..." +} +``` + +Clients should use `errors[].code` as the stable application error identifier. + +--- + +# Unexpected Exceptions + +Exceptions that do not inherit from `CrException` are converted into a generic internal server error response. + +Example: + +```json +{ + "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": "/api/users/1", + "errors": [ + { + "code": "InternalError", + "message": "An unexpected internal error occurred." + } + ], + "traceId": "..." +} +``` \ No newline at end of file diff --git a/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs b/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs index 9e71888..532296f 100644 --- a/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs +++ b/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using CR.Exceptions.AspNet.Mapping; +using Microsoft.Extensions.DependencyInjection; namespace CR.Exceptions.AspNet; @@ -6,30 +7,52 @@ public static class ServiceCollectionExtensions { extension(IServiceCollection services) { + public IServiceCollection AddCrExceptions() + { + return services + .AddCrExceptionHandler() + .AddCrStatusCodeMapping() + .AddCrLogLevelMapping(); + } + public IServiceCollection AddCrExceptionHandler() { - return services.AddCrExceptionHandler(options => - { - options.StatusCodes.AddDefaultMappings(); - }); + return services + .AddCustomProblemDetails() + .AddExceptionHandler(); + } + + public IServiceCollection AddCrStatusCodeMapping() + => AddCrStatusCodeMapping(services, static builder => builder.AddDefaultMappings()); + + public IServiceCollection AddCrStatusCodeMapping(Action configurator) + { + ArgumentNullException.ThrowIfNull(configurator); + + var builder = new StatusCodeMapBuilder(); + configurator(builder); + + return services.AddSingleton(builder.Build()); } - public IServiceCollection AddCrExceptionHandler(Action setupAction) + public IServiceCollection AddCrLogLevelMapping() + => AddCrLogLevelMapping(services, static builder => builder.AddDefaultMappings()); + + public IServiceCollection AddCrLogLevelMapping(Action configurator) { - ArgumentNullException.ThrowIfNull(setupAction); + ArgumentNullException.ThrowIfNull(configurator); - services.Configure(setupAction); - services.AddCustomProblemDetails(); - services.AddExceptionHandler(); + var builder = new LogLevelMapBuilder(); + configurator(builder); - return services; + return services.AddSingleton(builder.Build()); } private IServiceCollection AddCustomProblemDetails() { - return services.AddProblemDetails(options => + return services.AddProblemDetails(static options => { - options.CustomizeProblemDetails = context => + options.CustomizeProblemDetails = static context => { var currentActivity = System.Diagnostics.Activity.Current; diff --git a/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj b/CR.Exceptions.UnitTests/CR.Exceptions.Tests.csproj similarity index 100% rename from CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj rename to CR.Exceptions.UnitTests/CR.Exceptions.Tests.csproj diff --git a/CR.Exceptions.UnitTests/Component/ErrorMapTests.cs b/CR.Exceptions.UnitTests/Component/ErrorMapTests.cs new file mode 100644 index 0000000..ebe48be --- /dev/null +++ b/CR.Exceptions.UnitTests/Component/ErrorMapTests.cs @@ -0,0 +1,36 @@ +using CR.Exceptions.Mapping; + +namespace CR.Exceptions.Tests.Component; + +public sealed class ErrorMapTests +{ + [Fact] + public void TryGet_ShouldReturn_Errors_WhenCodeExists() + { + const string errorCode = "InvalidGrant"; + const string registrationCode = "invalid_grant"; + + var map = new ErrorMapBuilder() + .Add(new(registrationCode, [new(errorCode, "Invalid username or password.")])) + .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_ShouldReturn_False_WhenCodeNotExist() + { + var map = new ErrorMapBuilder() + .Add(new("?", [new("?", "?")])) + .Build(); + + var result = map.TryGet("non_existent_code", out var errors); + + Assert.False(result); + Assert.True(errors.IsDefaultOrEmpty); + } +} \ No newline at end of file diff --git a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs b/CR.Exceptions.UnitTests/Component/ExceptionFactoryTests.cs similarity index 50% rename from CR.Exceptions.UnitTests/ExceptionFactoryTests.cs rename to CR.Exceptions.UnitTests/Component/ExceptionFactoryTests.cs index f183ad9..4a697c8 100644 --- a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs +++ b/CR.Exceptions.UnitTests/Component/ExceptionFactoryTests.cs @@ -1,20 +1,19 @@ using CR.Exceptions.Mapping; -namespace CR.Exceptions.UnitTests; +namespace CR.Exceptions.Tests.Component; public sealed class ExceptionFactoryTests { [Fact] - public void TryCreate_ShouldReturnException_WhenCodeExists() + public void TryCreate_ShouldReturn_Exception_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) + .Add(new(errorRegistration, errors => new TestException(errors))) .Build(); var result = factory.TryCreate(registrationCode, out var exception); @@ -28,26 +27,15 @@ public void TryCreate_ShouldReturnException_WhenCodeExists() } [Fact] - public void TryCreate_ShouldReturnFalse_WhenCodeNotExist() + public void TryCreate_ShouldReturn_False_WhenCodeNotExist() { - var factory = new ExceptionFactoryBuilder().Build(); + var factory = new ExceptionFactoryBuilder() + .Add(new(new("?", [new("?", "?")]), errors => new TestException(errors))) + .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/ErrorMapTests.cs b/CR.Exceptions.UnitTests/ErrorMapTests.cs deleted file mode 100644 index b1b8c14..0000000 --- a/CR.Exceptions.UnitTests/ErrorMapTests.cs +++ /dev/null @@ -1,48 +0,0 @@ -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/TestException.cs b/CR.Exceptions.UnitTests/TestException.cs index 30d890a..0dbb7ce 100644 --- a/CR.Exceptions.UnitTests/TestException.cs +++ b/CR.Exceptions.UnitTests/TestException.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace CR.Exceptions.UnitTests; +namespace CR.Exceptions.Tests; internal sealed class TestException : CrException { diff --git a/CR.Exceptions.UnitTests/Unit/ErrorMapBuilderTests.cs b/CR.Exceptions.UnitTests/Unit/ErrorMapBuilderTests.cs new file mode 100644 index 0000000..5f0f647 --- /dev/null +++ b/CR.Exceptions.UnitTests/Unit/ErrorMapBuilderTests.cs @@ -0,0 +1,17 @@ +using CR.Exceptions.Mapping; + +namespace CR.Exceptions.Tests.Unit; + +public sealed class ErrorMapBuilderTests +{ + [Fact] + public void Add_ShouldThrow_WhenDuplicateRegistered() + { + var errorRegistration = new ErrorRegistration("duplicate", [new("code", "message")]); + + var builder = new ErrorMapBuilder() + .Add(errorRegistration); + + Assert.ThrowsAny(() => builder.Add(errorRegistration)); + } +} \ No newline at end of file diff --git a/CR.Exceptions.UnitTests/Unit/ExceptionFactoryBuilderTests.cs b/CR.Exceptions.UnitTests/Unit/ExceptionFactoryBuilderTests.cs new file mode 100644 index 0000000..fbc4b1d --- /dev/null +++ b/CR.Exceptions.UnitTests/Unit/ExceptionFactoryBuilderTests.cs @@ -0,0 +1,18 @@ +using CR.Exceptions.Mapping; + +namespace CR.Exceptions.Tests.Unit; + +public sealed class ExceptionFactoryBuilderTests +{ + [Fact] + public void Add_ShouldThrow_WhenDuplicateRegistered() + { + var errorRegistration = new ErrorRegistration("duplicate", [new("code", "message")]); + var exceptionRegistration = new ExceptionRegistration(errorRegistration, errors => new TestException(errors)); + + var builder = new ExceptionFactoryBuilder() + .Add(exceptionRegistration); + + Assert.ThrowsAny(() => builder.Add(exceptionRegistration)); + } +} \ No newline at end of file diff --git a/CR.Exceptions.slnx b/CR.Exceptions.slnx index a955dd2..2d7e57f 100644 --- a/CR.Exceptions.slnx +++ b/CR.Exceptions.slnx @@ -2,8 +2,8 @@ - + - + diff --git a/CR.Exceptions/CR.Exceptions.csproj b/CR.Exceptions/CR.Exceptions.csproj index 7bf1a01..21cbfe4 100644 --- a/CR.Exceptions/CR.Exceptions.csproj +++ b/CR.Exceptions/CR.Exceptions.csproj @@ -7,8 +7,16 @@ - Common exception abstractions and error models for .NET applications. + Core .NET exception framework providing base error models, an exception factory, and error mapping. + $(NuGetPackagePrefix).Exceptions + exceptions;exception-handling;error-handling;error-factory;error-mapping;domain-exceptions + + README.md - + + + + + \ No newline at end of file diff --git a/CR.Exceptions/CrError.cs b/CR.Exceptions/CrError.cs index 7756327..76dfcb3 100644 --- a/CR.Exceptions/CrError.cs +++ b/CR.Exceptions/CrError.cs @@ -1,14 +1,14 @@ namespace CR.Exceptions; -public sealed record class CrError +public 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); + ArgumentException.ThrowIfNullOrEmpty(code); + ArgumentException.ThrowIfNullOrEmpty(message); Code = code; Message = message; diff --git a/CR.Exceptions/CrException.cs b/CR.Exceptions/CrException.cs index d8eb908..83ba31c 100644 --- a/CR.Exceptions/CrException.cs +++ b/CR.Exceptions/CrException.cs @@ -9,7 +9,7 @@ public abstract class CrException : Exception protected CrException(ImmutableArray errors, string message, Exception? innerException = null) : base(message, innerException) { - ArgumentException.ThrowIfNullOrWhiteSpace(message); + ArgumentException.ThrowIfNullOrEmpty(message); errors.ThrowIfEmptyOrContainsNull(); Errors = errors; diff --git a/CR.Exceptions/Extensions/EnumerableExtensions.cs b/CR.Exceptions/Extensions/EnumerableExtensions.cs deleted file mode 100644 index 40b82ec..0000000 --- a/CR.Exceptions/Extensions/EnumerableExtensions.cs +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 95f9275..0000000 --- a/CR.Exceptions/Extensions/FrozenDictionaryExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -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/Extensions/ImmutableArrayExtensions.cs b/CR.Exceptions/Extensions/ImmutableArrayExtensions.cs new file mode 100644 index 0000000..a3e99b7 --- /dev/null +++ b/CR.Exceptions/Extensions/ImmutableArrayExtensions.cs @@ -0,0 +1,25 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions.Extensions; + +public static class ImmutableArrayExtensions +{ + extension(ImmutableArray source) where TSource : class? + { + public void ThrowIfEmptyOrContainsNull() + { + if (source.IsDefaultOrEmpty) + { + throw new ArgumentException("The array cannot be empty.", nameof(source)); + } + + for (var i = 0; i < source.Length; i++) + { + if (source[i] is null) + { + throw new ArgumentNullException(nameof(source), $"The array element[{i}] is null."); + } + } + } + } +} \ No newline at end of file diff --git a/CR.Exceptions/InternalException.cs b/CR.Exceptions/InternalException.cs new file mode 100644 index 0000000..a5f0673 --- /dev/null +++ b/CR.Exceptions/InternalException.cs @@ -0,0 +1,16 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions; + +public abstract class InternalException : CrException +{ + protected InternalException(ImmutableArray errors, Exception? innerException = null) + : base(errors, "An unexpected internal error occurred.", innerException) + { + } + + protected InternalException(ImmutableArray errors, string message, Exception? innerException = null) + : base(errors, message, innerException) + { + } +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ErrorMap.cs b/CR.Exceptions/Mapping/ErrorMap.cs index 553370a..35c65fb 100644 --- a/CR.Exceptions/Mapping/ErrorMap.cs +++ b/CR.Exceptions/Mapping/ErrorMap.cs @@ -3,30 +3,18 @@ namespace CR.Exceptions.Mapping; -public sealed class ErrorMap +public sealed class ErrorMap : Map { - private readonly FrozenDictionary _map; - - internal ErrorMap(FrozenDictionary map) - { - _map = map; - } + internal ErrorMap(FrozenDictionary dictionary) : base(dictionary) { } public ImmutableArray Get(string code) - { - if (TryGet(code, out var errors)) - { - return errors; - } - - throw new KeyNotFoundException($"Collection with code '{code}' is not found."); - } + => GetValue(code).Errors; public bool TryGet(string code, out ImmutableArray errors) { - if (_map.TryGetValue(code, out var descriptor)) + if (TryGetValue(code, out var value)) { - errors = descriptor.Errors; + errors = value.Errors; return true; } diff --git a/CR.Exceptions/Mapping/ErrorMapBuilder.cs b/CR.Exceptions/Mapping/ErrorMapBuilder.cs index c15ee4c..4f9b19f 100644 --- a/CR.Exceptions/Mapping/ErrorMapBuilder.cs +++ b/CR.Exceptions/Mapping/ErrorMapBuilder.cs @@ -1,28 +1,21 @@ -using CR.Exceptions.Extensions; +namespace CR.Exceptions.Mapping; -namespace CR.Exceptions.Mapping; - -public sealed class ErrorMapBuilder +public sealed class ErrorMapBuilder : MapBuilder { - private readonly RegistrationCollection _collection = new(); - public ErrorMapBuilder Add(ErrorRegistration registration) { - _collection.Add(registration); + Add(registration.Code, registration); return this; } public ErrorMapBuilder AddRange(IEnumerable registrations) { - _collection.AddRange(registrations); + foreach (var registration in registrations) Add(registration); return this; } public ErrorMap Build() { - return new(_collection.Items.ToUniqueFrozenDictionary( - keySelector: k => k.Code, - elementSelector: v => v, - comparer: StringComparer.Ordinal)); + return new(BuildFrozenDictionary(comparer: StringComparer.Ordinal)); } } \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ErrorRegistration.cs b/CR.Exceptions/Mapping/ErrorRegistration.cs index 8c30948..bbbacf2 100644 --- a/CR.Exceptions/Mapping/ErrorRegistration.cs +++ b/CR.Exceptions/Mapping/ErrorRegistration.cs @@ -3,14 +3,14 @@ namespace CR.Exceptions.Mapping; -public sealed record class ErrorRegistration +public record class ErrorRegistration { public string Code { get; init; } public ImmutableArray Errors { get; init; } public ErrorRegistration(string code, ImmutableArray errors) { - ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentException.ThrowIfNullOrEmpty(code); errors.ThrowIfEmptyOrContainsNull(); Code = code; diff --git a/CR.Exceptions/Mapping/ExceptionFactory.cs b/CR.Exceptions/Mapping/ExceptionFactory.cs index aec062a..c0e2c54 100644 --- a/CR.Exceptions/Mapping/ExceptionFactory.cs +++ b/CR.Exceptions/Mapping/ExceptionFactory.cs @@ -3,36 +3,19 @@ namespace CR.Exceptions.Mapping; -public sealed class ExceptionFactory +public sealed class ExceptionFactory : Map { - private readonly FrozenDictionary _map; - - internal ExceptionFactory(FrozenDictionary map) - { - _map = map; - } + internal ExceptionFactory(FrozenDictionary dictionary) : base(dictionary) { } public CrException Create(string code) - { - if (TryCreate(code, out var exception)) - { - return exception; - } - - throw new KeyNotFoundException($"Exception factory with code '{code}' is not found."); - } + => TransformValueToResult(GetValue(code)); 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"); + => (exception = TryGetValue(code, out var value) ? TransformValueToResult(value) : null) != null; - return true; - } - - exception = default; - return false; + private static CrException TransformValueToResult(ExceptionRegistration value) + { + return value.Factory(value.Definition.Errors) + ?? throw new NullReferenceException("The registered factory return null exception"); } } \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs index e07da99..511ff32 100644 --- a/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs +++ b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs @@ -1,28 +1,21 @@ -using CR.Exceptions.Extensions; +namespace CR.Exceptions.Mapping; -namespace CR.Exceptions.Mapping; - -public sealed class ExceptionFactoryBuilder +public sealed class ExceptionFactoryBuilder : MapBuilder { - private readonly RegistrationCollection _collection = new(); - public ExceptionFactoryBuilder Add(ExceptionRegistration registration) { - _collection.Add(registration); + Add(registration.Definition.Code, registration); return this; } public ExceptionFactoryBuilder AddRange(IEnumerable registrations) { - _collection.AddRange(registrations); + foreach (var registration in registrations) Add(registration); return this; } public ExceptionFactory Build() { - return new(_collection.Items.ToUniqueFrozenDictionary( - keySelector: k => k.Definition.Code, - elementSelector: v => v, - comparer: StringComparer.Ordinal)); + return new(BuildFrozenDictionary(comparer: StringComparer.Ordinal)); } } \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionRegistration.cs b/CR.Exceptions/Mapping/ExceptionRegistration.cs index cb0d54f..eed2adf 100644 --- a/CR.Exceptions/Mapping/ExceptionRegistration.cs +++ b/CR.Exceptions/Mapping/ExceptionRegistration.cs @@ -2,7 +2,7 @@ namespace CR.Exceptions.Mapping; -public sealed record class ExceptionRegistration +public record class ExceptionRegistration { public ErrorRegistration Definition { get; init; } public Func, CrException> Factory { get; init; } diff --git a/CR.Exceptions/Mapping/Map.cs b/CR.Exceptions/Mapping/Map.cs new file mode 100644 index 0000000..b05d5c3 --- /dev/null +++ b/CR.Exceptions/Mapping/Map.cs @@ -0,0 +1,22 @@ +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; + +namespace CR.Exceptions.Mapping; + +public abstract class Map where TKey : notnull +{ + private readonly FrozenDictionary _dictionary; + public IReadOnlyDictionary Dictionary => _dictionary; + + protected Map(FrozenDictionary dictionary) + { + ArgumentNullException.ThrowIfNull(dictionary); + _dictionary = dictionary; + } + + protected TValue GetValue(TKey key) + => TryGetValue(key, out var value) ? value : throw new KeyNotFoundException($"Key '{key}' in map is not found."); + + protected bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) + => _dictionary.TryGetValue(key, out value); +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/MapBuilder.cs b/CR.Exceptions/Mapping/MapBuilder.cs new file mode 100644 index 0000000..89bcbb1 --- /dev/null +++ b/CR.Exceptions/Mapping/MapBuilder.cs @@ -0,0 +1,23 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.Mapping; + +public abstract class MapBuilder where TKey : notnull +{ + private readonly Dictionary _map = []; + + protected void Add(TKey key, TValue value) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(value); + + if (!_map.TryAdd(key, value)) + { + throw new ArgumentException( + $"The key '{key}' of type '{key.GetType().FullName}' has already been added.", nameof(key)); + } + } + + protected FrozenDictionary BuildFrozenDictionary(IEqualityComparer? comparer = null) + => _map.ToFrozenDictionary(comparer); +} \ No newline at end of file diff --git a/CR.Exceptions/Mapping/RegistrationCollection.cs b/CR.Exceptions/Mapping/RegistrationCollection.cs deleted file mode 100644 index 1d0d01c..0000000 --- a/CR.Exceptions/Mapping/RegistrationCollection.cs +++ /dev/null @@ -1,24 +0,0 @@ -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/README.md b/CR.Exceptions/README.md new file mode 100644 index 0000000..410f78c --- /dev/null +++ b/CR.Exceptions/README.md @@ -0,0 +1,133 @@ +# Intro + +A lightweight library for defining application errors, creating typed exceptions, and mapping external error codes into domain-specific exceptions. + +This package contains only the core exception model and does not depend on ASP.NET Core. + +## Features + +- Typed application exceptions +- Standard exception categories +- Structured application errors (`CrError`) +- External error mapping (`ErrorMap`) +- Exception factory (`ExceptionFactory`) +- No ASP.NET Core dependencies + +--- + +# Installation + +```bash +dotnet add package CrCore.Exceptions +``` + +--- + +# Error Model + +Every application error is represented by `CrError`. + +```csharp +var error = new CrError("IdentityUserNotFound", "User was not found."); +``` + +Each error contains: + +- `Code` — stable identifier for clients. +- `Message` — human-readable description. + +--- + +# Exception Categories + +Applications should inherit from one of the predefined exception categories. + +Available categories: + +| Exception | Purpose | +|-----------|---------| +| `ValidationException` | Validation failures | +| `UnauthorizedException` | Authentication required | +| `ForbiddenException` | Access denied | +| `NotFoundException` | Resource not found | +| `ConflictException` | Resource conflict | +| `UnprocessableException` | Business rule violation | +| `InternalException` | Internal server error | + +Example: + +```csharp +public sealed class UserNotFoundException : NotFoundException +{ + public UserNotFoundException(Guid userId) : base( + [new CrError("IdentityUserNotFound", $"User '{userId}' was not found.")], + "User was not found.") + { + } +} +``` + +Usage: + +```csharp +throw new UserNotFoundException(userId); +``` + +--- + +# ErrorMap + +External systems usually expose their own error codes. + +For example: + +```text +user_not_found +``` + +Those codes can be mapped into application errors. + +```csharp +ErrorMap errorMap = builder + .Add(new ErrorRegistration( + "user_not_found", + [new CrError("IdentityUserNotFound", "User was not found.")])) + .Build(); +``` + +Resolving an external error: + +```csharp +if (errorMap.TryGet("user_not_found", out var errors)) +{ + throw new UserNotFoundException(errors); +} +``` + +This keeps external service contracts isolated from the application domain. + +--- + +# ExceptionFactory + +`ExceptionFactory` creates typed exceptions from registered external error codes. + +Registration: + +```csharp +ExceptionFactory factory = builder + .Add(new ExceptionRegistration( + new ErrorRegistration( + "invalid_grant", + [new CrError("IdentityInvalidCredentials", "Invalid username or password.")]), + errors => new InvalidCredentialsException(errors))) + .Build(); +``` + +Usage: + +```csharp +throw factory.Create("invalid_grant"); +``` + +The factory only creates exceptions for registered error codes. \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index f931d6f..c795950 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,18 +5,11 @@ true git - exceptions;aspnetcore;problem-details true $(NoWarn);1591 - README.md Apache-2.0 - CrCore - - - - \ No newline at end of file diff --git a/README.md b/README.md index b14e3c7..1eb1643 100644 --- a/README.md +++ b/README.md @@ -2,209 +2,22 @@ A lightweight framework for defining application errors, creating typed exceptions, and exposing consistent error responses across application boundaries. -CR.Exceptions separates: +The repository contains the following modules: -- error definition (`CrError`); -- application exceptions (`CrException`); -- external error mapping (`ErrorMap`); -- exception creation (`ExceptionFactory`); -- HTTP response representation (ASP.NET Core integration). +## Modules -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. +### CR.Exceptions ---- - -## Installation - -Register the exception handler during application startup: - -```csharp -builder.Services.AddCrExceptionHandler(); - -app.UseExceptionHandler(); -```` - -Custom mappings can be configured: - -```csharp -builder.Services.AddCrExceptionHandler(options => -{ - options.StatusCodes.AddDefaultMappings(); - options.StatusCodes.Map(499); -}); -``` - ---- - -# Error Model - -Every application error is represented by `CrError`. - -Each error provides: - -* `Code` — stable identifier used by clients. -* `Message` — human-readable error description. - ---- - -# 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: - -```csharp -public sealed class UserNotFoundException : NotFoundException -{ - public UserNotFoundException(Guid userId) : base( - [new CrError("Identity.UserNotFound", $"User '{userId}' was not found.")], - "User was not found.") - { - } -} -``` +Core library for defining application errors, exception categories, error mapping, and exception creation. -Throw exceptions normally: - -```csharp -throw new UserNotFoundException(userId); -``` +Documentation: +[CR.Exceptions README](./CR.Exceptions/README.md) --- -# External Error Mapping - -External systems usually expose their own error codes. - -For example, an external API may return: - -``` -user_not_found -```` - -These codes can be registered in `ErrorMap` and converted into application-level errors: - -```csharp -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); -} -``` - -This allows application services to decide how external errors should be handled. - -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": "https://tools.ietf.org/html/rfc9110#section-15.5.5", - "title": "Not Found", - "status": 404, - "detail": "The requested resource was not found.", - "instance": "/api/test", - "errors": [ - { - "code": "TestNotFound", - "message": "Test Entity not found" - } - ], - "traceId": "5a1192a06ca5cd006057ca7e6b84e231" -} -``` - -Clients should use the `errors[].code` value as the stable identifier. - ---- - -# Internal Errors - -Unexpected exceptions are converted into a generic internal error response. +### CR.Exceptions.AspNet -Example: +ASP.NET Core integration for handling application exceptions and converting them into RFC 7807 ProblemDetails responses. -```json -{ - "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": "/api/test", - "errors": [ - { - "code": "InternalError", - "message": "An unexpected internal error occurred." - } - ], - "traceId": "3e071f69b5f9e695a32a699369b57651" -} -``` \ No newline at end of file +Documentation: +[CR.Exceptions.AspNet README](./CR.Exceptions.AspNet/README.md) \ No newline at end of file