From 02463fbb9c2b5f1a97ce2df789f540663b2df354 Mon Sep 17 00:00:00 2001 From: apptade Date: Wed, 29 Jul 2026 17:37:21 +0300 Subject: [PATCH 01/32] Add catalog --- .../Catalog/ExceptionErrorCatalog.cs | 44 +++++++++++++++++++ .../Catalog/ExceptionErrorCatalogBuilder.cs | 39 ++++++++++++++++ .../Catalog/ExceptionErrorDefinition.cs | 5 +++ 3 files changed, 88 insertions(+) create mode 100644 CR.Exceptions/Catalog/ExceptionErrorCatalog.cs create mode 100644 CR.Exceptions/Catalog/ExceptionErrorCatalogBuilder.cs create mode 100644 CR.Exceptions/Catalog/ExceptionErrorDefinition.cs diff --git a/CR.Exceptions/Catalog/ExceptionErrorCatalog.cs b/CR.Exceptions/Catalog/ExceptionErrorCatalog.cs new file mode 100644 index 0000000..bb7674b --- /dev/null +++ b/CR.Exceptions/Catalog/ExceptionErrorCatalog.cs @@ -0,0 +1,44 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.Catalog; + +public sealed class ExceptionErrorCatalog +{ + private readonly FrozenDictionary _definitionMap; + + internal ExceptionErrorCatalog(FrozenDictionary definitionMap) + { + _definitionMap = definitionMap; + } + + public IReadOnlyCollection ResolveErrors(string code) + { + return GetDefinition(code).Errors; + } + + public CrException ResolveException(string code) + { + var definition = GetDefinition(code); + + if (definition.ExceptionFactory is null) + { + throw new InvalidOperationException( + $"Error with code '{code}' does not have exception factory."); + } + + return definition.ExceptionFactory(definition.Errors); + } + + private ExceptionErrorDefinition GetDefinition(string code) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + + if (!_definitionMap.TryGetValue(code, out var definition)) + { + throw new InvalidOperationException( + $"Error with code '{code}' is not added."); + } + + return definition; + } +} \ No newline at end of file diff --git a/CR.Exceptions/Catalog/ExceptionErrorCatalogBuilder.cs b/CR.Exceptions/Catalog/ExceptionErrorCatalogBuilder.cs new file mode 100644 index 0000000..04b9fea --- /dev/null +++ b/CR.Exceptions/Catalog/ExceptionErrorCatalogBuilder.cs @@ -0,0 +1,39 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.Catalog; + +public sealed class ExceptionErrorCatalogBuilder +{ + private readonly Dictionary _definitionMap; + + public ExceptionErrorCatalogBuilder() + { + _definitionMap = new(StringComparer.Ordinal); + } + + public ExceptionErrorCatalogBuilder Add(string code, params CrError[] errors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentNullException.ThrowIfNull(errors); + + _definitionMap.Add(code, new ExceptionErrorDefinition(errors)); + + return this; + } + + public ExceptionErrorCatalogBuilder Add(string code, Func factory, params CrError[] errors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(errors); + + _definitionMap.Add(code, new ExceptionErrorDefinition(errors, factory)); + + return this; + } + + internal ExceptionErrorCatalog Build() + { + return new ExceptionErrorCatalog(_definitionMap.ToFrozenDictionary(StringComparer.Ordinal)); + } +} \ No newline at end of file diff --git a/CR.Exceptions/Catalog/ExceptionErrorDefinition.cs b/CR.Exceptions/Catalog/ExceptionErrorDefinition.cs new file mode 100644 index 0000000..7dc1d90 --- /dev/null +++ b/CR.Exceptions/Catalog/ExceptionErrorDefinition.cs @@ -0,0 +1,5 @@ +namespace CR.Exceptions.Catalog; + +internal sealed record class ExceptionErrorDefinition( + CrError[] Errors, + Func? ExceptionFactory = null); \ No newline at end of file From f53a07b7e8c716f7e81090e47d64e198b15417ed Mon Sep 17 00:00:00 2001 From: apptade Date: Wed, 29 Jul 2026 17:52:38 +0300 Subject: [PATCH 02/32] Replace CrError[] to ImmutableArray --- CR.Exceptions.AspNet/CrExceptionHandler.cs | 5 ++-- CR.Exceptions/ConflictException.cs | 8 +++--- CR.Exceptions/CrException.cs | 29 ++++++++++++---------- CR.Exceptions/ForbiddenException.cs | 8 +++--- CR.Exceptions/NotFoundException.cs | 8 +++--- CR.Exceptions/UnauthorizedException.cs | 8 +++--- CR.Exceptions/UnprocessableException.cs | 8 +++--- CR.Exceptions/ValidationException.cs | 8 +++--- 8 files changed, 49 insertions(+), 33 deletions(-) diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.cs b/CR.Exceptions.AspNet/CrExceptionHandler.cs index f6f9afb..6dfbce2 100644 --- a/CR.Exceptions.AspNet/CrExceptionHandler.cs +++ b/CR.Exceptions.AspNet/CrExceptionHandler.cs @@ -5,13 +5,14 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.Collections.Immutable; using System.Diagnostics; namespace CR.Exceptions.AspNet; public sealed partial class CrExceptionHandler : IExceptionHandler { - private static readonly CrError[] DefaultInternalErrors = + private static readonly ImmutableArray DefaultInternalErrors = [ new(ErrorCodes.InternalError, "An unexpected internal error occurred.") ]; @@ -42,7 +43,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e var exceptionType = exception.GetType(); var exceptionTypeName = exceptionType.FullName ?? exceptionType.Name; - CrError[] errors; + ImmutableArray errors; string detail; if (exception is CrException crException) 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/CrException.cs b/CR.Exceptions/CrException.cs index 263d093..038b5fd 100644 --- a/CR.Exceptions/CrException.cs +++ b/CR.Exceptions/CrException.cs @@ -1,28 +1,31 @@ -namespace CR.Exceptions; +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) + if (errors.IsDefaultOrEmpty) { throw new ArgumentException("At least one error must be provided.", nameof(errors)); } - foreach (var error in errors) + for (var i = 0; i < errors.Length; i++) { - ArgumentNullException.ThrowIfNull(error, nameof(errors)); - ArgumentException.ThrowIfNullOrWhiteSpace(error.Code, nameof(errors)); - ArgumentException.ThrowIfNullOrWhiteSpace(error.Message, nameof(errors)); + var error = errors[i]; + + ArgumentNullException.ThrowIfNull(error); + + if (string.IsNullOrWhiteSpace(error.Code)) + throw new ArgumentException($"errors[{i}].Code cannot be null or whitespace.", nameof(errors)); + + if (string.IsNullOrWhiteSpace(error.Message)) + throw new ArgumentException($"errors[{i}].Message cannot be null or whitespace.", nameof(errors)); } Errors = errors; 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/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) { } From 87b05f62533631c13f07c61709b1a5addc81c688 Mon Sep 17 00:00:00 2001 From: apptade Date: Wed, 29 Jul 2026 21:25:20 +0300 Subject: [PATCH 03/32] Update CrExceptionHandler --- CR.Exceptions.AspNet/CrExceptionHandler.cs | 2 +- CR.Exceptions.AspNet/ErrorCodes.cs | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 CR.Exceptions.AspNet/ErrorCodes.cs diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.cs b/CR.Exceptions.AspNet/CrExceptionHandler.cs index 6dfbce2..e41cca1 100644 --- a/CR.Exceptions.AspNet/CrExceptionHandler.cs +++ b/CR.Exceptions.AspNet/CrExceptionHandler.cs @@ -14,7 +14,7 @@ public sealed partial class CrExceptionHandler : IExceptionHandler { private static readonly ImmutableArray DefaultInternalErrors = [ - new(ErrorCodes.InternalError, "An unexpected internal error occurred.") + new("InternalError", "An unexpected internal error occurred.") ]; private readonly IProblemDetailsService _problemDetailsService; 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 From c71968ed7577aa781b3eed2c0b2011e03154ee26 Mon Sep 17 00:00:00 2001 From: apptade Date: Wed, 29 Jul 2026 21:47:19 +0300 Subject: [PATCH 04/32] Update Exception Error Map --- .../Catalog/ExceptionErrorCatalog.cs | 44 ------------ .../Catalog/ExceptionErrorCatalogBuilder.cs | 39 ----------- .../Catalog/ExceptionErrorDefinition.cs | 5 -- CR.Exceptions/CrErrorValidator.cs | 25 +++++++ CR.Exceptions/Map/ExceptionErrorDescriptor.cs | 8 +++ CR.Exceptions/Map/ExceptionErrorMap.cs | 69 +++++++++++++++++++ CR.Exceptions/Map/ExceptionErrorMapBuilder.cs | 34 +++++++++ 7 files changed, 136 insertions(+), 88 deletions(-) delete mode 100644 CR.Exceptions/Catalog/ExceptionErrorCatalog.cs delete mode 100644 CR.Exceptions/Catalog/ExceptionErrorCatalogBuilder.cs delete mode 100644 CR.Exceptions/Catalog/ExceptionErrorDefinition.cs create mode 100644 CR.Exceptions/CrErrorValidator.cs create mode 100644 CR.Exceptions/Map/ExceptionErrorDescriptor.cs create mode 100644 CR.Exceptions/Map/ExceptionErrorMap.cs create mode 100644 CR.Exceptions/Map/ExceptionErrorMapBuilder.cs diff --git a/CR.Exceptions/Catalog/ExceptionErrorCatalog.cs b/CR.Exceptions/Catalog/ExceptionErrorCatalog.cs deleted file mode 100644 index bb7674b..0000000 --- a/CR.Exceptions/Catalog/ExceptionErrorCatalog.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Frozen; - -namespace CR.Exceptions.Catalog; - -public sealed class ExceptionErrorCatalog -{ - private readonly FrozenDictionary _definitionMap; - - internal ExceptionErrorCatalog(FrozenDictionary definitionMap) - { - _definitionMap = definitionMap; - } - - public IReadOnlyCollection ResolveErrors(string code) - { - return GetDefinition(code).Errors; - } - - public CrException ResolveException(string code) - { - var definition = GetDefinition(code); - - if (definition.ExceptionFactory is null) - { - throw new InvalidOperationException( - $"Error with code '{code}' does not have exception factory."); - } - - return definition.ExceptionFactory(definition.Errors); - } - - private ExceptionErrorDefinition GetDefinition(string code) - { - ArgumentException.ThrowIfNullOrWhiteSpace(code); - - if (!_definitionMap.TryGetValue(code, out var definition)) - { - throw new InvalidOperationException( - $"Error with code '{code}' is not added."); - } - - return definition; - } -} \ No newline at end of file diff --git a/CR.Exceptions/Catalog/ExceptionErrorCatalogBuilder.cs b/CR.Exceptions/Catalog/ExceptionErrorCatalogBuilder.cs deleted file mode 100644 index 04b9fea..0000000 --- a/CR.Exceptions/Catalog/ExceptionErrorCatalogBuilder.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Frozen; - -namespace CR.Exceptions.Catalog; - -public sealed class ExceptionErrorCatalogBuilder -{ - private readonly Dictionary _definitionMap; - - public ExceptionErrorCatalogBuilder() - { - _definitionMap = new(StringComparer.Ordinal); - } - - public ExceptionErrorCatalogBuilder Add(string code, params CrError[] errors) - { - ArgumentException.ThrowIfNullOrWhiteSpace(code); - ArgumentNullException.ThrowIfNull(errors); - - _definitionMap.Add(code, new ExceptionErrorDefinition(errors)); - - return this; - } - - public ExceptionErrorCatalogBuilder Add(string code, Func factory, params CrError[] errors) - { - ArgumentException.ThrowIfNullOrWhiteSpace(code); - ArgumentNullException.ThrowIfNull(factory); - ArgumentNullException.ThrowIfNull(errors); - - _definitionMap.Add(code, new ExceptionErrorDefinition(errors, factory)); - - return this; - } - - internal ExceptionErrorCatalog Build() - { - return new ExceptionErrorCatalog(_definitionMap.ToFrozenDictionary(StringComparer.Ordinal)); - } -} \ No newline at end of file diff --git a/CR.Exceptions/Catalog/ExceptionErrorDefinition.cs b/CR.Exceptions/Catalog/ExceptionErrorDefinition.cs deleted file mode 100644 index 7dc1d90..0000000 --- a/CR.Exceptions/Catalog/ExceptionErrorDefinition.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace CR.Exceptions.Catalog; - -internal sealed record class ExceptionErrorDefinition( - CrError[] Errors, - Func? ExceptionFactory = null); \ No newline at end of file diff --git a/CR.Exceptions/CrErrorValidator.cs b/CR.Exceptions/CrErrorValidator.cs new file mode 100644 index 0000000..4cedef5 --- /dev/null +++ b/CR.Exceptions/CrErrorValidator.cs @@ -0,0 +1,25 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions; + +internal static class CrErrorValidator +{ + public static void ThrowExceptionIfInvalid(ImmutableArray errors) + { + if (errors.IsDefaultOrEmpty) + { + throw new ArgumentException("At least one error must be provided.", nameof(errors)); + } + + for (var i = 0; i < errors.Length; i++) + { + var error = errors[i] ?? throw new ArgumentNullException(nameof(errors), $"errors[{i}] is null."); + + if (string.IsNullOrWhiteSpace(error.Code)) + throw new ArgumentException($"errors[{i}].Code cannot be null or whitespace.", nameof(errors)); + + if (string.IsNullOrWhiteSpace(error.Message)) + throw new ArgumentException($"errors[{i}].Message cannot be null or whitespace.", nameof(errors)); + } + } +} \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionErrorDescriptor.cs b/CR.Exceptions/Map/ExceptionErrorDescriptor.cs new file mode 100644 index 0000000..6bf4b99 --- /dev/null +++ b/CR.Exceptions/Map/ExceptionErrorDescriptor.cs @@ -0,0 +1,8 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions.Map; + +public sealed record class ExceptionErrorDescriptor( + string Code, + ImmutableArray Errors, + Func, CrException>? ExceptionFactory = null); \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionErrorMap.cs b/CR.Exceptions/Map/ExceptionErrorMap.cs new file mode 100644 index 0000000..9b64e92 --- /dev/null +++ b/CR.Exceptions/Map/ExceptionErrorMap.cs @@ -0,0 +1,69 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; + +namespace CR.Exceptions.Map; + +public sealed class ExceptionErrorMap +{ + private readonly FrozenDictionary _descriptorMap; + + public ExceptionErrorMap(FrozenDictionary descriptorMap) + { + _descriptorMap = descriptorMap; + } + + public ImmutableArray GetErrors(string code) + { + return GetDescriptor(code).Errors; + } + + public CrException CreateException(string code) + { + var definition = GetDescriptor(code); + + if (definition.ExceptionFactory is null) + { + throw new InvalidOperationException( + $"Error with code '{code}' does not have exception factory."); + } + + return definition.ExceptionFactory(definition.Errors); + } + + public bool TryGetErrors(string code, out ImmutableArray errors) + { + if (_descriptorMap.TryGetValue(code, out var descriptor)) + { + errors = descriptor.Errors; + return true; + } + + errors = default; + return false; + } + + public bool TryCreateException(string code, out CrException? exception) + { + if (_descriptorMap.TryGetValue(code, out var descriptor)) + { + if (descriptor.ExceptionFactory is not null) + { + exception = descriptor.ExceptionFactory(descriptor.Errors); + return true; + } + } + + exception = null; + return false; + } + + private ExceptionErrorDescriptor GetDescriptor(string code) + { + if (_descriptorMap.TryGetValue(code, out var descriptor)) + { + return descriptor; + } + + throw new KeyNotFoundException($"Error with code '{code}' is not added."); + } +} \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionErrorMapBuilder.cs b/CR.Exceptions/Map/ExceptionErrorMapBuilder.cs new file mode 100644 index 0000000..4f39211 --- /dev/null +++ b/CR.Exceptions/Map/ExceptionErrorMapBuilder.cs @@ -0,0 +1,34 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.Map; + +public sealed class ExceptionErrorMapBuilder +{ + private readonly List _descriptors = []; + + public ExceptionErrorMapBuilder Add(ExceptionErrorDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + _descriptors.Add(descriptor); + + return this; + } + + public ExceptionErrorMapBuilder AddParams(params ExceptionErrorDescriptor[] descriptors) + { + return AddRange(descriptors); + } + + public ExceptionErrorMapBuilder AddRange(IEnumerable descriptors) + { + ArgumentNullException.ThrowIfNull(descriptors); + _descriptors.AddRange(descriptors); + + return this; + } + + public ExceptionErrorMap Build() + { + return new ExceptionErrorMap(_descriptors.ToFrozenDictionary(keySelector: k => k.Code, comparer: StringComparer.Ordinal)); + } +} \ No newline at end of file From b111e4f92a9e2ef89b0aab38165bb2650dfed62c Mon Sep 17 00:00:00 2001 From: apptade Date: Wed, 29 Jul 2026 23:04:11 +0300 Subject: [PATCH 05/32] Update CrException.cs --- CR.Exceptions/CrException.cs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/CR.Exceptions/CrException.cs b/CR.Exceptions/CrException.cs index 038b5fd..f6eb2f8 100644 --- a/CR.Exceptions/CrException.cs +++ b/CR.Exceptions/CrException.cs @@ -9,24 +9,7 @@ public abstract class CrException : Exception protected CrException(ImmutableArray errors, string message, Exception? innerException = null) : base(message, innerException) { ArgumentException.ThrowIfNullOrWhiteSpace(message); - - if (errors.IsDefaultOrEmpty) - { - throw new ArgumentException("At least one error must be provided.", nameof(errors)); - } - - for (var i = 0; i < errors.Length; i++) - { - var error = errors[i]; - - ArgumentNullException.ThrowIfNull(error); - - if (string.IsNullOrWhiteSpace(error.Code)) - throw new ArgumentException($"errors[{i}].Code cannot be null or whitespace.", nameof(errors)); - - if (string.IsNullOrWhiteSpace(error.Message)) - throw new ArgumentException($"errors[{i}].Message cannot be null or whitespace.", nameof(errors)); - } + CrErrorValidator.ThrowExceptionIfInvalid(errors); Errors = errors; } From 9faa99142e8aadd4ce110aa812070d1926add874 Mon Sep 17 00:00:00 2001 From: apptade Date: Wed, 29 Jul 2026 23:11:13 +0300 Subject: [PATCH 06/32] Update CrErrorValidator --- CR.Exceptions/CrError.cs | 15 +++++++++++++- CR.Exceptions/CrErrorValidator.cs | 9 ++------- CR.Exceptions/Map/ExceptionErrorDescriptor.cs | 20 +++++++++++++++---- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/CR.Exceptions/CrError.cs b/CR.Exceptions/CrError.cs index e1e2000..bea2938 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, nameof(code)); + ArgumentException.ThrowIfNullOrWhiteSpace(message, nameof(message)); + + Code = code; + Message = message; + } +} \ No newline at end of file diff --git a/CR.Exceptions/CrErrorValidator.cs b/CR.Exceptions/CrErrorValidator.cs index 4cedef5..b0d423d 100644 --- a/CR.Exceptions/CrErrorValidator.cs +++ b/CR.Exceptions/CrErrorValidator.cs @@ -13,13 +13,8 @@ public static void ThrowExceptionIfInvalid(ImmutableArray errors) for (var i = 0; i < errors.Length; i++) { - var error = errors[i] ?? throw new ArgumentNullException(nameof(errors), $"errors[{i}] is null."); - - if (string.IsNullOrWhiteSpace(error.Code)) - throw new ArgumentException($"errors[{i}].Code cannot be null or whitespace.", nameof(errors)); - - if (string.IsNullOrWhiteSpace(error.Message)) - throw new ArgumentException($"errors[{i}].Message cannot be null or whitespace.", nameof(errors)); + if (errors[i] is null) + throw new ArgumentNullException(nameof(errors), $"errors[{i}] is null."); } } } \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionErrorDescriptor.cs b/CR.Exceptions/Map/ExceptionErrorDescriptor.cs index 6bf4b99..f74901c 100644 --- a/CR.Exceptions/Map/ExceptionErrorDescriptor.cs +++ b/CR.Exceptions/Map/ExceptionErrorDescriptor.cs @@ -2,7 +2,19 @@ namespace CR.Exceptions.Map; -public sealed record class ExceptionErrorDescriptor( - string Code, - ImmutableArray Errors, - Func, CrException>? ExceptionFactory = null); \ No newline at end of file +public sealed record class ExceptionErrorDescriptor +{ + public string Code { get; init; } + public ImmutableArray Errors { get; init; } + public Func, CrException>? ExceptionFactory { get; init; } + + public ExceptionErrorDescriptor(string code, ImmutableArray errors, Func, CrException>? exceptionFactory = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code, nameof(code)); + CrErrorValidator.ThrowExceptionIfInvalid(errors); + + Code = code; + Errors = errors; + ExceptionFactory = exceptionFactory; + } +} \ No newline at end of file From 7dacc4fba18e443690b2247e5120375a5ad20041 Mon Sep 17 00:00:00 2001 From: apptade Date: Wed, 29 Jul 2026 23:50:29 +0300 Subject: [PATCH 07/32] Update Base Map --- CR.Exceptions/Map/ErrorMap.cs | 32 +++++++++ CR.Exceptions/Map/ErrorMapBuilder.cs | 31 +++++++++ CR.Exceptions/Map/ErrorRegistration.cs | 18 +++++ CR.Exceptions/Map/ExceptionErrorDescriptor.cs | 20 ------ CR.Exceptions/Map/ExceptionErrorMap.cs | 69 ------------------- CR.Exceptions/Map/ExceptionErrorMapBuilder.cs | 34 --------- CR.Exceptions/Map/ExceptionFactory.cs | 35 ++++++++++ CR.Exceptions/Map/ExceptionFactoryBuilder.cs | 31 +++++++++ CR.Exceptions/Map/ExceptionRegistration.cs | 18 +++++ 9 files changed, 165 insertions(+), 123 deletions(-) create mode 100644 CR.Exceptions/Map/ErrorMap.cs create mode 100644 CR.Exceptions/Map/ErrorMapBuilder.cs create mode 100644 CR.Exceptions/Map/ErrorRegistration.cs delete mode 100644 CR.Exceptions/Map/ExceptionErrorDescriptor.cs delete mode 100644 CR.Exceptions/Map/ExceptionErrorMap.cs delete mode 100644 CR.Exceptions/Map/ExceptionErrorMapBuilder.cs create mode 100644 CR.Exceptions/Map/ExceptionFactory.cs create mode 100644 CR.Exceptions/Map/ExceptionFactoryBuilder.cs create mode 100644 CR.Exceptions/Map/ExceptionRegistration.cs diff --git a/CR.Exceptions/Map/ErrorMap.cs b/CR.Exceptions/Map/ErrorMap.cs new file mode 100644 index 0000000..e3cfdc5 --- /dev/null +++ b/CR.Exceptions/Map/ErrorMap.cs @@ -0,0 +1,32 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; + +namespace CR.Exceptions.Map; + +public sealed class ErrorMap +{ + private readonly FrozenDictionary _map; + + public ErrorMap(FrozenDictionary map) + { + _map = map; + } + + public ImmutableArray GetOrDefault(string code) + { + TryGet(code, out var errors); + return errors; + } + + public bool TryGet(string code, out ImmutableArray errors) + { + if (_map.TryGetValue(code, out var descriptor)) + { + errors = descriptor.Errors; + return true; + } + + errors = default; + return false; + } +} \ No newline at end of file diff --git a/CR.Exceptions/Map/ErrorMapBuilder.cs b/CR.Exceptions/Map/ErrorMapBuilder.cs new file mode 100644 index 0000000..7bba7d8 --- /dev/null +++ b/CR.Exceptions/Map/ErrorMapBuilder.cs @@ -0,0 +1,31 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.Map; + +public sealed class ErrorMapBuilder +{ + private readonly List _registrations = []; + + public ErrorMapBuilder Add(ErrorRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + + _registrations.Add(registration); + + return this; + } + + public ErrorMapBuilder AddRange(IEnumerable registrations) + { + ArgumentNullException.ThrowIfNull(registrations); + + foreach (var registration in registrations) Add(registration); + + return this; + } + + public ErrorMap Build() + { + return new(_registrations.ToFrozenDictionary(keySelector: k => k.Code, comparer: StringComparer.Ordinal)); + } +} \ No newline at end of file diff --git a/CR.Exceptions/Map/ErrorRegistration.cs b/CR.Exceptions/Map/ErrorRegistration.cs new file mode 100644 index 0000000..1de12fd --- /dev/null +++ b/CR.Exceptions/Map/ErrorRegistration.cs @@ -0,0 +1,18 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions.Map; + +public sealed record class ErrorRegistration +{ + public string Code { get; init; } + public ImmutableArray Errors { get; init; } + + public ErrorRegistration(string code, ImmutableArray errors) + { + ArgumentException.ThrowIfNullOrWhiteSpace(code); + CrErrorValidator.ThrowExceptionIfInvalid(errors); + + Code = code; + Errors = errors; + } +} \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionErrorDescriptor.cs b/CR.Exceptions/Map/ExceptionErrorDescriptor.cs deleted file mode 100644 index f74901c..0000000 --- a/CR.Exceptions/Map/ExceptionErrorDescriptor.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Immutable; - -namespace CR.Exceptions.Map; - -public sealed record class ExceptionErrorDescriptor -{ - public string Code { get; init; } - public ImmutableArray Errors { get; init; } - public Func, CrException>? ExceptionFactory { get; init; } - - public ExceptionErrorDescriptor(string code, ImmutableArray errors, Func, CrException>? exceptionFactory = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(code, nameof(code)); - CrErrorValidator.ThrowExceptionIfInvalid(errors); - - Code = code; - Errors = errors; - ExceptionFactory = exceptionFactory; - } -} \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionErrorMap.cs b/CR.Exceptions/Map/ExceptionErrorMap.cs deleted file mode 100644 index 9b64e92..0000000 --- a/CR.Exceptions/Map/ExceptionErrorMap.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Collections.Frozen; -using System.Collections.Immutable; - -namespace CR.Exceptions.Map; - -public sealed class ExceptionErrorMap -{ - private readonly FrozenDictionary _descriptorMap; - - public ExceptionErrorMap(FrozenDictionary descriptorMap) - { - _descriptorMap = descriptorMap; - } - - public ImmutableArray GetErrors(string code) - { - return GetDescriptor(code).Errors; - } - - public CrException CreateException(string code) - { - var definition = GetDescriptor(code); - - if (definition.ExceptionFactory is null) - { - throw new InvalidOperationException( - $"Error with code '{code}' does not have exception factory."); - } - - return definition.ExceptionFactory(definition.Errors); - } - - public bool TryGetErrors(string code, out ImmutableArray errors) - { - if (_descriptorMap.TryGetValue(code, out var descriptor)) - { - errors = descriptor.Errors; - return true; - } - - errors = default; - return false; - } - - public bool TryCreateException(string code, out CrException? exception) - { - if (_descriptorMap.TryGetValue(code, out var descriptor)) - { - if (descriptor.ExceptionFactory is not null) - { - exception = descriptor.ExceptionFactory(descriptor.Errors); - return true; - } - } - - exception = null; - return false; - } - - private ExceptionErrorDescriptor GetDescriptor(string code) - { - if (_descriptorMap.TryGetValue(code, out var descriptor)) - { - return descriptor; - } - - throw new KeyNotFoundException($"Error with code '{code}' is not added."); - } -} \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionErrorMapBuilder.cs b/CR.Exceptions/Map/ExceptionErrorMapBuilder.cs deleted file mode 100644 index 4f39211..0000000 --- a/CR.Exceptions/Map/ExceptionErrorMapBuilder.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Collections.Frozen; - -namespace CR.Exceptions.Map; - -public sealed class ExceptionErrorMapBuilder -{ - private readonly List _descriptors = []; - - public ExceptionErrorMapBuilder Add(ExceptionErrorDescriptor descriptor) - { - ArgumentNullException.ThrowIfNull(descriptor); - _descriptors.Add(descriptor); - - return this; - } - - public ExceptionErrorMapBuilder AddParams(params ExceptionErrorDescriptor[] descriptors) - { - return AddRange(descriptors); - } - - public ExceptionErrorMapBuilder AddRange(IEnumerable descriptors) - { - ArgumentNullException.ThrowIfNull(descriptors); - _descriptors.AddRange(descriptors); - - return this; - } - - public ExceptionErrorMap Build() - { - return new ExceptionErrorMap(_descriptors.ToFrozenDictionary(keySelector: k => k.Code, comparer: StringComparer.Ordinal)); - } -} \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionFactory.cs b/CR.Exceptions/Map/ExceptionFactory.cs new file mode 100644 index 0000000..038eaf0 --- /dev/null +++ b/CR.Exceptions/Map/ExceptionFactory.cs @@ -0,0 +1,35 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.Map; + +public sealed class ExceptionFactory +{ + private readonly FrozenDictionary _map; + + public ExceptionFactory(FrozenDictionary map) + { + _map = map; + } + + public CrException Create(string code) + { + if (_map.TryGetValue(code, out var registration)) + { + return registration.Factory(registration.Error.Errors); + } + + throw new KeyNotFoundException($"Exception with code '{code}' is not added."); + } + + public bool TryCreate(string code, out CrException? exception) + { + if (_map.TryGetValue(code, out var registration)) + { + exception = registration.Factory(registration.Error.Errors); + return true; + } + + exception = null; + return false; + } +} \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionFactoryBuilder.cs b/CR.Exceptions/Map/ExceptionFactoryBuilder.cs new file mode 100644 index 0000000..73db013 --- /dev/null +++ b/CR.Exceptions/Map/ExceptionFactoryBuilder.cs @@ -0,0 +1,31 @@ +using System.Collections.Frozen; + +namespace CR.Exceptions.Map; + +public sealed class ExceptionFactoryBuilder +{ + private readonly List _registrations = []; + + public ExceptionFactoryBuilder Add(ExceptionRegistration registration) + { + ArgumentNullException.ThrowIfNull(registration); + + _registrations.Add(registration); + + return this; + } + + public ExceptionFactoryBuilder AddRange(IEnumerable registrations) + { + ArgumentNullException.ThrowIfNull(registrations); + + foreach (var registration in registrations) Add(registration); + + return this; + } + + public ExceptionFactory Build() + { + return new(_registrations.ToFrozenDictionary(keySelector: k => k.Error.Code, comparer: StringComparer.Ordinal)); + } +} \ No newline at end of file diff --git a/CR.Exceptions/Map/ExceptionRegistration.cs b/CR.Exceptions/Map/ExceptionRegistration.cs new file mode 100644 index 0000000..2ab5353 --- /dev/null +++ b/CR.Exceptions/Map/ExceptionRegistration.cs @@ -0,0 +1,18 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions.Map; + +public sealed record class ExceptionRegistration +{ + public ErrorRegistration Error { get; init; } + public Func, CrException> Factory { get; init; } + + public ExceptionRegistration(ErrorRegistration error, Func, CrException> factory) + { + ArgumentNullException.ThrowIfNull(error); + ArgumentNullException.ThrowIfNull(factory); + + Error = error; + Factory = factory; + } +} \ No newline at end of file From bf1771e350a59ef6dc833b59ee90d0afd07caa3e Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 00:54:35 +0300 Subject: [PATCH 08/32] Update CrError.cs --- CR.Exceptions/CrError.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CR.Exceptions/CrError.cs b/CR.Exceptions/CrError.cs index bea2938..7756327 100644 --- a/CR.Exceptions/CrError.cs +++ b/CR.Exceptions/CrError.cs @@ -7,8 +7,8 @@ public sealed record class CrError public CrError(string code, string message) { - ArgumentException.ThrowIfNullOrWhiteSpace(code, nameof(code)); - ArgumentException.ThrowIfNullOrWhiteSpace(message, nameof(message)); + ArgumentException.ThrowIfNullOrWhiteSpace(code); + ArgumentException.ThrowIfNullOrWhiteSpace(message); Code = code; Message = message; From 040413b8787b54098db052d0fcfa3d243524d0aa Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 14:31:29 +0300 Subject: [PATCH 09/32] Rename folder --- CR.Exceptions/{Map => Mapping}/ErrorMap.cs | 2 +- CR.Exceptions/{Map => Mapping}/ErrorMapBuilder.cs | 2 +- CR.Exceptions/{Map => Mapping}/ErrorRegistration.cs | 2 +- CR.Exceptions/{Map => Mapping}/ExceptionFactory.cs | 2 +- CR.Exceptions/{Map => Mapping}/ExceptionFactoryBuilder.cs | 2 +- CR.Exceptions/{Map => Mapping}/ExceptionRegistration.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) rename CR.Exceptions/{Map => Mapping}/ErrorMap.cs (95%) rename CR.Exceptions/{Map => Mapping}/ErrorMapBuilder.cs (95%) rename CR.Exceptions/{Map => Mapping}/ErrorRegistration.cs (92%) rename CR.Exceptions/{Map => Mapping}/ExceptionFactory.cs (96%) rename CR.Exceptions/{Map => Mapping}/ExceptionFactoryBuilder.cs (96%) rename CR.Exceptions/{Map => Mapping}/ExceptionRegistration.cs (93%) diff --git a/CR.Exceptions/Map/ErrorMap.cs b/CR.Exceptions/Mapping/ErrorMap.cs similarity index 95% rename from CR.Exceptions/Map/ErrorMap.cs rename to CR.Exceptions/Mapping/ErrorMap.cs index e3cfdc5..ec65dbd 100644 --- a/CR.Exceptions/Map/ErrorMap.cs +++ b/CR.Exceptions/Mapping/ErrorMap.cs @@ -1,7 +1,7 @@ using System.Collections.Frozen; using System.Collections.Immutable; -namespace CR.Exceptions.Map; +namespace CR.Exceptions.Mapping; public sealed class ErrorMap { diff --git a/CR.Exceptions/Map/ErrorMapBuilder.cs b/CR.Exceptions/Mapping/ErrorMapBuilder.cs similarity index 95% rename from CR.Exceptions/Map/ErrorMapBuilder.cs rename to CR.Exceptions/Mapping/ErrorMapBuilder.cs index 7bba7d8..8f985a8 100644 --- a/CR.Exceptions/Map/ErrorMapBuilder.cs +++ b/CR.Exceptions/Mapping/ErrorMapBuilder.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace CR.Exceptions.Map; +namespace CR.Exceptions.Mapping; public sealed class ErrorMapBuilder { diff --git a/CR.Exceptions/Map/ErrorRegistration.cs b/CR.Exceptions/Mapping/ErrorRegistration.cs similarity index 92% rename from CR.Exceptions/Map/ErrorRegistration.cs rename to CR.Exceptions/Mapping/ErrorRegistration.cs index 1de12fd..1c4206c 100644 --- a/CR.Exceptions/Map/ErrorRegistration.cs +++ b/CR.Exceptions/Mapping/ErrorRegistration.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace CR.Exceptions.Map; +namespace CR.Exceptions.Mapping; public sealed record class ErrorRegistration { diff --git a/CR.Exceptions/Map/ExceptionFactory.cs b/CR.Exceptions/Mapping/ExceptionFactory.cs similarity index 96% rename from CR.Exceptions/Map/ExceptionFactory.cs rename to CR.Exceptions/Mapping/ExceptionFactory.cs index 038eaf0..1c30f0f 100644 --- a/CR.Exceptions/Map/ExceptionFactory.cs +++ b/CR.Exceptions/Mapping/ExceptionFactory.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace CR.Exceptions.Map; +namespace CR.Exceptions.Mapping; public sealed class ExceptionFactory { diff --git a/CR.Exceptions/Map/ExceptionFactoryBuilder.cs b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs similarity index 96% rename from CR.Exceptions/Map/ExceptionFactoryBuilder.cs rename to CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs index 73db013..121340f 100644 --- a/CR.Exceptions/Map/ExceptionFactoryBuilder.cs +++ b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs @@ -1,6 +1,6 @@ using System.Collections.Frozen; -namespace CR.Exceptions.Map; +namespace CR.Exceptions.Mapping; public sealed class ExceptionFactoryBuilder { diff --git a/CR.Exceptions/Map/ExceptionRegistration.cs b/CR.Exceptions/Mapping/ExceptionRegistration.cs similarity index 93% rename from CR.Exceptions/Map/ExceptionRegistration.cs rename to CR.Exceptions/Mapping/ExceptionRegistration.cs index 2ab5353..41cef9f 100644 --- a/CR.Exceptions/Map/ExceptionRegistration.cs +++ b/CR.Exceptions/Mapping/ExceptionRegistration.cs @@ -1,6 +1,6 @@ using System.Collections.Immutable; -namespace CR.Exceptions.Map; +namespace CR.Exceptions.Mapping; public sealed record class ExceptionRegistration { From 0eee8333fdd65d1320c15dcd4792b4155c0837cd Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 14:40:07 +0300 Subject: [PATCH 10/32] Rename --- ...ExceptionFactory.cs => ExceptionResolver.cs} | 17 +++++++++-------- ...ryBuilder.cs => ExceptionResolverBuilder.cs} | 8 ++++---- 2 files changed, 13 insertions(+), 12 deletions(-) rename CR.Exceptions/Mapping/{ExceptionFactory.cs => ExceptionResolver.cs} (53%) rename CR.Exceptions/Mapping/{ExceptionFactoryBuilder.cs => ExceptionResolverBuilder.cs} (69%) diff --git a/CR.Exceptions/Mapping/ExceptionFactory.cs b/CR.Exceptions/Mapping/ExceptionResolver.cs similarity index 53% rename from CR.Exceptions/Mapping/ExceptionFactory.cs rename to CR.Exceptions/Mapping/ExceptionResolver.cs index 1c30f0f..a6c6924 100644 --- a/CR.Exceptions/Mapping/ExceptionFactory.cs +++ b/CR.Exceptions/Mapping/ExceptionResolver.cs @@ -1,27 +1,28 @@ using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; namespace CR.Exceptions.Mapping; -public sealed class ExceptionFactory +public sealed class ExceptionResolver { private readonly FrozenDictionary _map; - public ExceptionFactory(FrozenDictionary map) + public ExceptionResolver(FrozenDictionary map) { _map = map; } - public CrException Create(string code) + public CrException Resolve(string code) { - if (_map.TryGetValue(code, out var registration)) + if (TryResolve(code, out var exception)) { - return registration.Factory(registration.Error.Errors); + return exception; } - throw new KeyNotFoundException($"Exception with code '{code}' is not added."); + throw new KeyNotFoundException($"Exception with code '{code}' is not found."); } - public bool TryCreate(string code, out CrException? exception) + public bool TryResolve(string code, [MaybeNullWhen(false)] out CrException exception) { if (_map.TryGetValue(code, out var registration)) { @@ -29,7 +30,7 @@ public bool TryCreate(string code, out CrException? exception) return true; } - exception = null; + exception = default; return false; } } \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs b/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs similarity index 69% rename from CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs rename to CR.Exceptions/Mapping/ExceptionResolverBuilder.cs index 121340f..c9628af 100644 --- a/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs +++ b/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs @@ -2,11 +2,11 @@ namespace CR.Exceptions.Mapping; -public sealed class ExceptionFactoryBuilder +public sealed class ExceptionResolverBuilder { private readonly List _registrations = []; - public ExceptionFactoryBuilder Add(ExceptionRegistration registration) + public ExceptionResolverBuilder Add(ExceptionRegistration registration) { ArgumentNullException.ThrowIfNull(registration); @@ -15,7 +15,7 @@ public ExceptionFactoryBuilder Add(ExceptionRegistration registration) return this; } - public ExceptionFactoryBuilder AddRange(IEnumerable registrations) + public ExceptionResolverBuilder AddRange(IEnumerable registrations) { ArgumentNullException.ThrowIfNull(registrations); @@ -24,7 +24,7 @@ public ExceptionFactoryBuilder AddRange(IEnumerable regis return this; } - public ExceptionFactory Build() + public ExceptionResolver Build() { return new(_registrations.ToFrozenDictionary(keySelector: k => k.Error.Code, comparer: StringComparer.Ordinal)); } From 96550206b64bcf86b48eb5f48e5699b5f7735a74 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 15:07:31 +0300 Subject: [PATCH 11/32] Create FrozenDictionaryExtensions.cs --- .../Extensions/FrozenDictionaryExtensions.cs | 29 +++++++++++++++++++ CR.Exceptions/Mapping/ErrorMapBuilder.cs | 7 +++-- .../Mapping/ExceptionResolverBuilder.cs | 7 +++-- 3 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 CR.Exceptions/Extensions/FrozenDictionaryExtensions.cs 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/Mapping/ErrorMapBuilder.cs b/CR.Exceptions/Mapping/ErrorMapBuilder.cs index 8f985a8..a6a70d7 100644 --- a/CR.Exceptions/Mapping/ErrorMapBuilder.cs +++ b/CR.Exceptions/Mapping/ErrorMapBuilder.cs @@ -1,4 +1,4 @@ -using System.Collections.Frozen; +using CR.Exceptions.Extensions; namespace CR.Exceptions.Mapping; @@ -26,6 +26,9 @@ public ErrorMapBuilder AddRange(IEnumerable registrations) public ErrorMap Build() { - return new(_registrations.ToFrozenDictionary(keySelector: k => k.Code, comparer: StringComparer.Ordinal)); + return new(_registrations.ToUniqueFrozenDictionary( + keySelector: k => k.Code, + elementSelector: v => v, + comparer: StringComparer.Ordinal)); } } \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs b/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs index c9628af..3fb900d 100644 --- a/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs +++ b/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs @@ -1,4 +1,4 @@ -using System.Collections.Frozen; +using CR.Exceptions.Extensions; namespace CR.Exceptions.Mapping; @@ -26,6 +26,9 @@ public ExceptionResolverBuilder AddRange(IEnumerable regi public ExceptionResolver Build() { - return new(_registrations.ToFrozenDictionary(keySelector: k => k.Error.Code, comparer: StringComparer.Ordinal)); + return new(_registrations.ToUniqueFrozenDictionary( + keySelector: k => k.Error.Code, + elementSelector: v => v, + comparer: StringComparer.Ordinal)); } } \ No newline at end of file From def8c773e1c758c9914466abf2b33006388f865d Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 15:23:48 +0300 Subject: [PATCH 12/32] Create EnumerableExtensions.cs --- CR.Exceptions/CrErrorValidator.cs | 20 ------------ CR.Exceptions/CrException.cs | 5 +-- .../Extensions/EnumerableExtensions.cs | 32 +++++++++++++++++++ CR.Exceptions/Mapping/ErrorRegistration.cs | 5 +-- 4 files changed, 38 insertions(+), 24 deletions(-) delete mode 100644 CR.Exceptions/CrErrorValidator.cs create mode 100644 CR.Exceptions/Extensions/EnumerableExtensions.cs diff --git a/CR.Exceptions/CrErrorValidator.cs b/CR.Exceptions/CrErrorValidator.cs deleted file mode 100644 index b0d423d..0000000 --- a/CR.Exceptions/CrErrorValidator.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Immutable; - -namespace CR.Exceptions; - -internal static class CrErrorValidator -{ - public static void ThrowExceptionIfInvalid(ImmutableArray errors) - { - if (errors.IsDefaultOrEmpty) - { - throw new ArgumentException("At least one error must be provided.", nameof(errors)); - } - - for (var i = 0; i < errors.Length; i++) - { - if (errors[i] is null) - throw new ArgumentNullException(nameof(errors), $"errors[{i}] is null."); - } - } -} \ No newline at end of file diff --git a/CR.Exceptions/CrException.cs b/CR.Exceptions/CrException.cs index f6eb2f8..d8eb908 100644 --- a/CR.Exceptions/CrException.cs +++ b/CR.Exceptions/CrException.cs @@ -1,4 +1,5 @@ -using System.Collections.Immutable; +using CR.Exceptions.Extensions; +using System.Collections.Immutable; namespace CR.Exceptions; @@ -9,7 +10,7 @@ public abstract class CrException : Exception protected CrException(ImmutableArray errors, string message, Exception? innerException = null) : base(message, innerException) { ArgumentException.ThrowIfNullOrWhiteSpace(message); - CrErrorValidator.ThrowExceptionIfInvalid(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/Mapping/ErrorRegistration.cs b/CR.Exceptions/Mapping/ErrorRegistration.cs index 1c4206c..8c30948 100644 --- a/CR.Exceptions/Mapping/ErrorRegistration.cs +++ b/CR.Exceptions/Mapping/ErrorRegistration.cs @@ -1,4 +1,5 @@ -using System.Collections.Immutable; +using CR.Exceptions.Extensions; +using System.Collections.Immutable; namespace CR.Exceptions.Mapping; @@ -10,7 +11,7 @@ public sealed record class ErrorRegistration public ErrorRegistration(string code, ImmutableArray errors) { ArgumentException.ThrowIfNullOrWhiteSpace(code); - CrErrorValidator.ThrowExceptionIfInvalid(errors); + errors.ThrowIfEmptyOrContainsNull(); Code = code; Errors = errors; From 1d025bf2a743bb0c3683cb4a98c8c2c3cb593047 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 15:34:14 +0300 Subject: [PATCH 13/32] Update mapping --- CR.Exceptions/Mapping/ErrorMap.cs | 14 +++++++++----- CR.Exceptions/Mapping/ExceptionRegistration.cs | 8 ++++---- CR.Exceptions/Mapping/ExceptionResolver.cs | 4 ++-- CR.Exceptions/Mapping/ExceptionResolverBuilder.cs | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/CR.Exceptions/Mapping/ErrorMap.cs b/CR.Exceptions/Mapping/ErrorMap.cs index ec65dbd..553370a 100644 --- a/CR.Exceptions/Mapping/ErrorMap.cs +++ b/CR.Exceptions/Mapping/ErrorMap.cs @@ -7,15 +7,19 @@ public sealed class ErrorMap { private readonly FrozenDictionary _map; - public ErrorMap(FrozenDictionary map) + internal ErrorMap(FrozenDictionary map) { _map = map; } - public ImmutableArray GetOrDefault(string code) + public ImmutableArray Get(string code) { - TryGet(code, out var errors); - return errors; + 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) @@ -26,7 +30,7 @@ public bool TryGet(string code, out ImmutableArray errors) return true; } - errors = default; + errors = []; return false; } } \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionRegistration.cs b/CR.Exceptions/Mapping/ExceptionRegistration.cs index 41cef9f..cb0d54f 100644 --- a/CR.Exceptions/Mapping/ExceptionRegistration.cs +++ b/CR.Exceptions/Mapping/ExceptionRegistration.cs @@ -4,15 +4,15 @@ namespace CR.Exceptions.Mapping; public sealed record class ExceptionRegistration { - public ErrorRegistration Error { get; init; } + public ErrorRegistration Definition { get; init; } public Func, CrException> Factory { get; init; } - public ExceptionRegistration(ErrorRegistration error, Func, CrException> factory) + public ExceptionRegistration(ErrorRegistration definition, Func, CrException> factory) { - ArgumentNullException.ThrowIfNull(error); + ArgumentNullException.ThrowIfNull(definition); ArgumentNullException.ThrowIfNull(factory); - Error = error; + Definition = definition; Factory = factory; } } \ No newline at end of file diff --git a/CR.Exceptions/Mapping/ExceptionResolver.cs b/CR.Exceptions/Mapping/ExceptionResolver.cs index a6c6924..3196e02 100644 --- a/CR.Exceptions/Mapping/ExceptionResolver.cs +++ b/CR.Exceptions/Mapping/ExceptionResolver.cs @@ -7,7 +7,7 @@ public sealed class ExceptionResolver { private readonly FrozenDictionary _map; - public ExceptionResolver(FrozenDictionary map) + internal ExceptionResolver(FrozenDictionary map) { _map = map; } @@ -26,7 +26,7 @@ public bool TryResolve(string code, [MaybeNullWhen(false)] out CrException excep { if (_map.TryGetValue(code, out var registration)) { - exception = registration.Factory(registration.Error.Errors); + exception = registration.Factory(registration.Definition.Errors); return true; } diff --git a/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs b/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs index 3fb900d..d3071f5 100644 --- a/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs +++ b/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs @@ -27,7 +27,7 @@ public ExceptionResolverBuilder AddRange(IEnumerable regi public ExceptionResolver Build() { return new(_registrations.ToUniqueFrozenDictionary( - keySelector: k => k.Error.Code, + keySelector: k => k.Definition.Code, elementSelector: v => v, comparer: StringComparer.Ordinal)); } From 8e96789d13d872a73db883d6e89aa313505d0d1e Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 15:50:13 +0300 Subject: [PATCH 14/32] Rename asp net mapping --- .../ExceptionMappingOptionsTests.cs | 2 +- CR.Exceptions.AspNet/CrExceptionHandler.cs | 5 ++--- CR.Exceptions.AspNet/CrExceptionOptions.cs | 7 +++++++ ...onMappingOptions.cs => ExceptionStatusCodeOptions.cs} | 6 +++--- ...nsions.cs => ExceptionStatusCodeOptionsExtensions.cs} | 9 ++++----- CR.Exceptions.AspNet/Options/CrExceptionOptions.cs | 7 ------- .../{Options => }/ProblemDetailsOptions.cs | 2 +- CR.Exceptions.AspNet/ServiceCollectionExtensions.cs | 5 ++--- 8 files changed, 20 insertions(+), 23 deletions(-) create mode 100644 CR.Exceptions.AspNet/CrExceptionOptions.cs rename CR.Exceptions.AspNet/{Options/ExceptionMappingOptions.cs => ExceptionStatusCodeOptions.cs} (78%) rename CR.Exceptions.AspNet/{ExceptionMappingOptionsExtensions.cs => ExceptionStatusCodeOptionsExtensions.cs} (70%) delete mode 100644 CR.Exceptions.AspNet/Options/CrExceptionOptions.cs rename CR.Exceptions.AspNet/{Options => }/ProblemDetailsOptions.cs (69%) diff --git a/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs b/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs index e526bb5..1824eca 100644 --- a/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs @@ -9,7 +9,7 @@ public sealed class ExceptionMappingOptionsTests [Fact] public void Should_Return_404_Status_Code_For_NotFoundException() { - var options = new ExceptionMappingOptions().AddDefaultMappings(); + var options = new ExceptionStatusCodeOptions().AddDefaultMappings(); var exception = new TestNotFoundException(); var statusCode = options.FindHttpStatusCode(exception); diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.cs b/CR.Exceptions.AspNet/CrExceptionHandler.cs index e41cca1..0095ea2 100644 --- a/CR.Exceptions.AspNet/CrExceptionHandler.cs +++ b/CR.Exceptions.AspNet/CrExceptionHandler.cs @@ -1,5 +1,4 @@ -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; @@ -51,7 +50,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e detail = crException.Message; errors = crException.Errors; - var statusCode = _options.ExceptionMapping.FindHttpStatusCode(crException); + var statusCode = _options.StatusCodes.FindHttpStatusCode(crException); if (statusCode is null) { diff --git a/CR.Exceptions.AspNet/CrExceptionOptions.cs b/CR.Exceptions.AspNet/CrExceptionOptions.cs new file mode 100644 index 0000000..77f75c8 --- /dev/null +++ b/CR.Exceptions.AspNet/CrExceptionOptions.cs @@ -0,0 +1,7 @@ +namespace CR.Exceptions.AspNet; + +public sealed class CrExceptionOptions +{ + public ExceptionStatusCodeOptions StatusCodes { get; init; } = new(); + public ProblemDetailsOptions ProblemDetails { get; init; } = new(); +} \ 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/ProblemDetailsOptions.cs similarity index 69% rename from CR.Exceptions.AspNet/Options/ProblemDetailsOptions.cs rename to CR.Exceptions.AspNet/ProblemDetailsOptions.cs index 3ee4c6f..173cc78 100644 --- a/CR.Exceptions.AspNet/Options/ProblemDetailsOptions.cs +++ b/CR.Exceptions.AspNet/ProblemDetailsOptions.cs @@ -1,4 +1,4 @@ -namespace CR.Exceptions.AspNet.Options; +namespace CR.Exceptions.AspNet; public sealed class ProblemDetailsOptions { diff --git a/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs b/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs index e3719c4..c621452 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(); }); } From 86773a8c51cbbe30fbd0314d2a609b20af5a6aa6 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 16:11:39 +0300 Subject: [PATCH 15/32] Rename ExceptionResolver to ExceptionFactory --- .../{ExceptionResolver.cs => ExceptionFactory.cs} | 10 +++++----- ...onResolverBuilder.cs => ExceptionFactoryBuilder.cs} | 10 ++++------ 2 files changed, 9 insertions(+), 11 deletions(-) rename CR.Exceptions/Mapping/{ExceptionResolver.cs => ExceptionFactory.cs} (66%) rename CR.Exceptions/Mapping/{ExceptionResolverBuilder.cs => ExceptionFactoryBuilder.cs} (71%) diff --git a/CR.Exceptions/Mapping/ExceptionResolver.cs b/CR.Exceptions/Mapping/ExceptionFactory.cs similarity index 66% rename from CR.Exceptions/Mapping/ExceptionResolver.cs rename to CR.Exceptions/Mapping/ExceptionFactory.cs index 3196e02..7ad83b8 100644 --- a/CR.Exceptions/Mapping/ExceptionResolver.cs +++ b/CR.Exceptions/Mapping/ExceptionFactory.cs @@ -3,18 +3,18 @@ namespace CR.Exceptions.Mapping; -public sealed class ExceptionResolver +public sealed class ExceptionFactory { private readonly FrozenDictionary _map; - internal ExceptionResolver(FrozenDictionary map) + internal ExceptionFactory(FrozenDictionary map) { _map = map; } - public CrException Resolve(string code) + public CrException Create(string code) { - if (TryResolve(code, out var exception)) + if (TryCreate(code, out var exception)) { return exception; } @@ -22,7 +22,7 @@ public CrException Resolve(string code) throw new KeyNotFoundException($"Exception with code '{code}' is not found."); } - public bool TryResolve(string code, [MaybeNullWhen(false)] out CrException exception) + public bool TryCreate(string code, [MaybeNullWhen(false)] out CrException exception) { if (_map.TryGetValue(code, out var registration)) { diff --git a/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs similarity index 71% rename from CR.Exceptions/Mapping/ExceptionResolverBuilder.cs rename to CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs index d3071f5..760022f 100644 --- a/CR.Exceptions/Mapping/ExceptionResolverBuilder.cs +++ b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs @@ -2,29 +2,27 @@ namespace CR.Exceptions.Mapping; -public sealed class ExceptionResolverBuilder +public sealed class ExceptionFactoryBuilder { private readonly List _registrations = []; - public ExceptionResolverBuilder Add(ExceptionRegistration registration) + public ExceptionFactoryBuilder Add(ExceptionRegistration registration) { ArgumentNullException.ThrowIfNull(registration); _registrations.Add(registration); - return this; } - public ExceptionResolverBuilder AddRange(IEnumerable registrations) + public ExceptionFactoryBuilder AddRange(IEnumerable registrations) { ArgumentNullException.ThrowIfNull(registrations); foreach (var registration in registrations) Add(registration); - return this; } - public ExceptionResolver Build() + public ExceptionFactory Build() { return new(_registrations.ToUniqueFrozenDictionary( keySelector: k => k.Definition.Code, From 3030a15fa3f893c0c8f2ecc331566aea15451b46 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 16:11:41 +0300 Subject: [PATCH 16/32] Update ErrorMapBuilder.cs --- CR.Exceptions/Mapping/ErrorMapBuilder.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/CR.Exceptions/Mapping/ErrorMapBuilder.cs b/CR.Exceptions/Mapping/ErrorMapBuilder.cs index a6a70d7..402ed8e 100644 --- a/CR.Exceptions/Mapping/ErrorMapBuilder.cs +++ b/CR.Exceptions/Mapping/ErrorMapBuilder.cs @@ -11,7 +11,6 @@ public ErrorMapBuilder Add(ErrorRegistration registration) ArgumentNullException.ThrowIfNull(registration); _registrations.Add(registration); - return this; } @@ -20,7 +19,6 @@ public ErrorMapBuilder AddRange(IEnumerable registrations) ArgumentNullException.ThrowIfNull(registrations); foreach (var registration in registrations) Add(registration); - return this; } From ed8d5a1ce43c44d8b8430887a4fe4400ff02590a Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 16:15:56 +0300 Subject: [PATCH 17/32] Create RegistrationCollection.cs --- CR.Exceptions/Mapping/ErrorMapBuilder.cs | 12 ++++------ .../Mapping/ExceptionFactoryBuilder.cs | 12 ++++------ .../Mapping/RegistrationCollection.cs | 24 +++++++++++++++++++ 3 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 CR.Exceptions/Mapping/RegistrationCollection.cs diff --git a/CR.Exceptions/Mapping/ErrorMapBuilder.cs b/CR.Exceptions/Mapping/ErrorMapBuilder.cs index 402ed8e..c15ee4c 100644 --- a/CR.Exceptions/Mapping/ErrorMapBuilder.cs +++ b/CR.Exceptions/Mapping/ErrorMapBuilder.cs @@ -4,27 +4,23 @@ namespace CR.Exceptions.Mapping; public sealed class ErrorMapBuilder { - private readonly List _registrations = []; + private readonly RegistrationCollection _collection = new(); public ErrorMapBuilder Add(ErrorRegistration registration) { - ArgumentNullException.ThrowIfNull(registration); - - _registrations.Add(registration); + _collection.Add(registration); return this; } public ErrorMapBuilder AddRange(IEnumerable registrations) { - ArgumentNullException.ThrowIfNull(registrations); - - foreach (var registration in registrations) Add(registration); + _collection.AddRange(registrations); return this; } public ErrorMap Build() { - return new(_registrations.ToUniqueFrozenDictionary( + return new(_collection.Items.ToUniqueFrozenDictionary( keySelector: k => k.Code, elementSelector: v => v, comparer: StringComparer.Ordinal)); diff --git a/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs index 760022f..e07da99 100644 --- a/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs +++ b/CR.Exceptions/Mapping/ExceptionFactoryBuilder.cs @@ -4,27 +4,23 @@ namespace CR.Exceptions.Mapping; public sealed class ExceptionFactoryBuilder { - private readonly List _registrations = []; + private readonly RegistrationCollection _collection = new(); public ExceptionFactoryBuilder Add(ExceptionRegistration registration) { - ArgumentNullException.ThrowIfNull(registration); - - _registrations.Add(registration); + _collection.Add(registration); return this; } public ExceptionFactoryBuilder AddRange(IEnumerable registrations) { - ArgumentNullException.ThrowIfNull(registrations); - - foreach (var registration in registrations) Add(registration); + _collection.AddRange(registrations); return this; } public ExceptionFactory Build() { - return new(_registrations.ToUniqueFrozenDictionary( + return new(_collection.Items.ToUniqueFrozenDictionary( keySelector: k => k.Definition.Code, elementSelector: v => v, comparer: StringComparer.Ordinal)); 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 From 6b657a78581a8ec8146b9c3d33b8459c5dbe5585 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 16:27:03 +0300 Subject: [PATCH 18/32] Update CR.Exceptions.AspNet.UnitTests --- ...ingOptionsTests.cs => ExceptionStatusCodeOptionsTests.cs} | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) rename CR.Exceptions.AspNet.UnitTests/{ExceptionMappingOptionsTests.cs => ExceptionStatusCodeOptionsTests.cs} (79%) diff --git a/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs similarity index 79% rename from CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs rename to CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs index 1824eca..1913a55 100644 --- a/CR.Exceptions.AspNet.UnitTests/ExceptionMappingOptionsTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs @@ -1,10 +1,9 @@ -using CR.Exceptions.AspNet.Options; -using FluentAssertions; +using FluentAssertions; using Microsoft.AspNetCore.Http; namespace CR.Exceptions.AspNet.UnitTests; -public sealed class ExceptionMappingOptionsTests +public sealed class ExceptionStatusCodeOptionsTests { [Fact] public void Should_Return_404_Status_Code_For_NotFoundException() From cf72969e82c395c19be5cca69189e3220fbeadfc Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 16:27:56 +0300 Subject: [PATCH 19/32] Create CR.Exceptions.UnitTests.csproj --- .../CR.Exceptions.UnitTests.csproj | 25 +++++++ CR.Exceptions.UnitTests/ErrorMapTests.cs | 65 +++++++++++++++++++ .../ExceptionFactoryTests.cs | 45 +++++++++++++ CR.Exceptions.UnitTests/TestException.cs | 10 +++ CR.Exceptions.slnx | 1 + 5 files changed, 146 insertions(+) create mode 100644 CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj create mode 100644 CR.Exceptions.UnitTests/ErrorMapTests.cs create mode 100644 CR.Exceptions.UnitTests/ExceptionFactoryTests.cs create mode 100644 CR.Exceptions.UnitTests/TestException.cs diff --git a/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj b/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj new file mode 100644 index 0000000..efb9aaa --- /dev/null +++ b/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ 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..033cd5c --- /dev/null +++ b/CR.Exceptions.UnitTests/ErrorMapTests.cs @@ -0,0 +1,65 @@ +using CR.Exceptions.Mapping; +using System.Collections.Immutable; + +namespace CR.Exceptions.UnitTests; + +public sealed class ErrorMapTests +{ + [Fact] + public void TryGet_ShouldReturnErrors_WhenCodeExists() + { + var errorCode = "InvalidGrant"; + var registrationCode = "invalid_grant"; + + ImmutableArray errors = + [ + new CrError(errorCode, "Invalid username or password.") + ]; + + var registration = new ErrorRegistration(registrationCode, errors); + + var map = new ErrorMapBuilder() + .Add(registration) + .Build(); + + var result = map.TryGet( + registrationCode, + out var resolvedErrors); + + Assert.True(result); + var singleError = Assert.Single(resolvedErrors); + Assert.Equal(errorCode, singleError.Code); + } + + [Fact] + public void TryGet_ShouldReturnFalse_WhenCodeDoesNotExist() + { + var map = new ErrorMapBuilder() + .Build(); + + var result = map.TryGet( + "unknown", + out var errors); + + Assert.False(result); + Assert.True(errors.IsEmpty); + } + + [Fact] + public void Build_ShouldThrow_WhenDuplicateCodesRegistered() + { + var first = new ErrorRegistration( + "duplicate", + [new("Code.One", "First")]); + + var second = new ErrorRegistration( + "duplicate", + [new("Code.Two", "Second")]); + + var builder = new ErrorMapBuilder() + .Add(first) + .Add(second); + + 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..d4b4a6a --- /dev/null +++ b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs @@ -0,0 +1,45 @@ +using CR.Exceptions.Mapping; + +namespace CR.Exceptions.UnitTests; + +public sealed class ExceptionFactoryTests +{ + [Fact] + public void Create_ShouldReturnRegisteredException() + { + var errorCode = "TestError"; + var registrationCode = "test_error"; + + var registration = new ErrorRegistration( + registrationCode, + [new CrError(errorCode, "Something went wrong.")]); + + var exceptionRegistration = new ExceptionRegistration( + registration, + errors => new TestException(errors)); + + var factory = new ExceptionFactoryBuilder() + .Add(exceptionRegistration) + .Build(); + + var exception = factory.Create(registrationCode); + + Assert.IsType(exception); + var singleError = Assert.Single(exception.Errors); + Assert.Equal(errorCode, singleError.Code); + } + + [Fact] + public void TryCreate_ShouldReturnFalse_WhenCodeDoesNotExist() + { + var factory = new ExceptionFactoryBuilder() + .Build(); + + var result = factory.TryCreate( + "unknown", + out var exception); + + Assert.False(result); + Assert.Null(exception); + } +} \ 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..cc9ac16 --- /dev/null +++ b/CR.Exceptions.UnitTests/TestException.cs @@ -0,0 +1,10 @@ +using System.Collections.Immutable; + +namespace CR.Exceptions.UnitTests; + +public sealed class TestException : CrException +{ + public TestException(ImmutableArray errors) : base(errors, "Test exception") + { + } +} \ 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 @@ + From 77975e286d6c66083b26f274b73b88df164ffcdc Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 16:50:04 +0300 Subject: [PATCH 20/32] Update CR.Exceptions.AspNet.UnitTests --- .../CR.Exceptions.AspNet.UnitTests.csproj | 15 +++++++---- .../CrExceptionHandlerTests.cs | 26 +++++++++---------- .../ExceptionStatusCodeOptionsTests.cs | 7 ++--- 3 files changed, 25 insertions(+), 23 deletions(-) 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..4788d57 100644 --- a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs @@ -1,10 +1,8 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using System.Text.Json; using System.Text.Json.Nodes; -using Xunit.Abstractions; namespace CR.Exceptions.AspNet.UnitTests; @@ -49,8 +47,8 @@ private async Task ShouldReturnStatusCode(Exception exception, int expectedStatu await LogResponseBody(context); - result.Should().BeTrue(); - context.Response.StatusCode.Should().Be(expectedStatusCode); + Assert.True(result); + Assert.Equal(expectedStatusCode, context.Response.StatusCode); var problem = await DeserializeProblemDetails(context); AssertProblemDetails(problem!, context, expectedStatusCode); @@ -92,17 +90,19 @@ private async Task LogResponseBody(DefaultHttpContext context) private static void AssertProblemDetails(ProblemDetailsResponse problem, HttpContext context, int expectedStatusCode) { - problem.Should().NotBeNull(); + Assert.NotNull(problem); - problem.Type.Should().NotBeNullOrWhiteSpace(); - problem.Title.Should().NotBeNullOrWhiteSpace(); - problem.Detail.Should().NotBeNullOrWhiteSpace(); + Assert.False(string.IsNullOrWhiteSpace(problem.Type)); + Assert.False(string.IsNullOrWhiteSpace(problem.Title)); + Assert.False(string.IsNullOrWhiteSpace(problem.Detail)); - problem.Status.Should().Be(expectedStatusCode); - problem.Instance.Should().Be(context.Request.Path); + Assert.Equal(expectedStatusCode, problem.Status); + Assert.Equal(context.Request.Path, problem.Instance); - problem.TraceId.Should().NotBeNullOrWhiteSpace(); - problem.Errors.Should().NotBeNull().And.NotBeEmpty(); + Assert.False(string.IsNullOrWhiteSpace(problem.TraceId)); + + Assert.NotNull(problem.Errors); + Assert.NotEmpty(problem.Errors); } private sealed class ProblemDetailsResponse diff --git a/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs index 1913a55..79e7d8c 100644 --- a/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs @@ -1,5 +1,4 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; namespace CR.Exceptions.AspNet.UnitTests; @@ -10,10 +9,8 @@ public void Should_Return_404_Status_Code_For_NotFoundException() { var options = new ExceptionStatusCodeOptions().AddDefaultMappings(); var exception = new TestNotFoundException(); - var statusCode = options.FindHttpStatusCode(exception); - statusCode.Should() - .Be(StatusCodes.Status404NotFound); + Assert.Equal(StatusCodes.Status404NotFound, statusCode); } } \ No newline at end of file From 18e86e8ff0d137fc9ad0fc7b7185d19977fb4b9c Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 16:51:15 +0300 Subject: [PATCH 21/32] Update CR.Exceptions.UnitTests.csproj --- .../CrExceptionHandlerTests.cs | 3 ++- .../CR.Exceptions.UnitTests.csproj | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs index 4788d57..8821af9 100644 --- a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs @@ -84,8 +84,9 @@ private async Task DeserializeProblemDetails(HttpContext private async Task LogResponseBody(DefaultHttpContext context) { context.Response.Body.Position = 0; + var jsonNode = await JsonNode.ParseAsync(context.Response.Body); - _output.WriteLine(jsonNode?.ToJsonString(_jsonOptions)); + if (jsonNode is not null) _output.WriteLine(jsonNode.ToJsonString(_jsonOptions)); } private static void AssertProblemDetails(ProblemDetailsResponse problem, HttpContext context, int expectedStatusCode) diff --git a/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj b/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj index efb9aaa..566be10 100644 --- a/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj +++ b/CR.Exceptions.UnitTests/CR.Exceptions.UnitTests.csproj @@ -8,10 +8,16 @@ - - - - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + From 37ab14f582882a9d122f5926a897b95a46ad2a3c Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 17:18:31 +0300 Subject: [PATCH 22/32] Update README.md --- README.md | 169 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 132 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index cd8e9a6..ca3c982 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,32 +68,104 @@ 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); +var _errorMap = _errorMapBuilder.Add(new ErrorRegistration( + "user_not_found", + [new CrError("Identity.UserNotFound", "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("Identity.InvalidCredentials", "Invalid username or password.")]); + +var _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 { @@ -73,27 +177,20 @@ Example: "traceId": "0HNNAF6ABMHQO", "errors": [ { - "code": "UserNotFound", + "code": "Identity.UserNotFound", "message": "User '123' was not found." } ] } ``` -## 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: @@ -103,8 +200,6 @@ Example: "title": "Internal Server Error", "status": 500, "detail": "An unexpected error occurred.", - "instance": "", - "traceId": "0HNNAF6ABMHQO", "errors": [ { "code": "InternalError", @@ -112,4 +207,4 @@ Example: } ] } -``` +``` \ No newline at end of file From df2ea62724eb1ced04709eb6e2a6d4def70f9334 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 18:15:09 +0300 Subject: [PATCH 23/32] Update Tests --- .../CrExceptionHandlerTests.cs | 79 ++++++------------- .../ExceptionStatusCodeOptionsTests.cs | 14 +++- .../TestUnregisteredException.cs | 8 ++ CR.Exceptions.UnitTests/ErrorMapTests.cs | 38 +++------ .../ExceptionFactoryTests.cs | 32 ++++---- 5 files changed, 73 insertions(+), 98 deletions(-) create mode 100644 CR.Exceptions.AspNet.UnitTests/TestUnregisteredException.cs diff --git a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs index 8821af9..d52ac4b 100644 --- a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs @@ -1,57 +1,52 @@ 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; namespace CR.Exceptions.AspNet.UnitTests; public sealed class CrExceptionHandlerTests { 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); } [Fact] public Task Should_Return_500_For_UnhandledException() { - return ShouldReturnStatusCode(new Exception("Something went wrong"), StatusCodes.Status500InternalServerError); + return AssertHandlerResult(new TestUnregisteredException(), StatusCodes.Status500InternalServerError); } - private async Task ShouldReturnStatusCode(Exception exception, int expectedStatusCode) + private async Task AssertHandlerResult(Exception exception, int expectedStatusCode) { + using var activity = new Activity("TestActivity").Start(); 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); - await LogResponseBody(context); + var isHandled = await handler.TryHandleAsync(context, exception, CancellationToken.None); - Assert.True(result); + Assert.True(isHandled); Assert.Equal(expectedStatusCode, context.Response.StatusCode); + Assert.Contains("application/problem+json", context.Response.ContentType); - var problem = await DeserializeProblemDetails(context); - AssertProblemDetails(problem!, context, expectedStatusCode); + responseStream.Position = 0; + var problem = await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions.Web); + + AssertProblemDetails(problem, context, expectedStatusCode, activity.Id); } private static ServiceProvider CreateServiceProvider() @@ -62,58 +57,36 @@ 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) - { - context.Response.Body.Position = 0; - - return (await JsonSerializer.DeserializeAsync( - context.Response.Body, _jsonOptions))!; - } - - private async Task LogResponseBody(DefaultHttpContext context) - { - context.Response.Body.Position = 0; - - var jsonNode = await JsonNode.ParseAsync(context.Response.Body); - if (jsonNode is not null) _output.WriteLine(jsonNode.ToJsonString(_jsonOptions)); - } - - private static void AssertProblemDetails(ProblemDetailsResponse problem, HttpContext context, int expectedStatusCode) + private void AssertProblemDetails(CustomProblemDetails? problem, HttpContext context, int expectedStatusCode, string? expectedTraceId) { Assert.NotNull(problem); + _output.WriteLine(JsonSerializer.Serialize(problem, options: new(JsonSerializerOptions.Web) { WriteIndented = true })); - Assert.False(string.IsNullOrWhiteSpace(problem.Type)); - Assert.False(string.IsNullOrWhiteSpace(problem.Title)); - Assert.False(string.IsNullOrWhiteSpace(problem.Detail)); + Assert.False(string.IsNullOrEmpty(problem.Type)); + Assert.False(string.IsNullOrEmpty(problem.Title)); + Assert.False(string.IsNullOrEmpty(problem.Detail)); Assert.Equal(expectedStatusCode, problem.Status); Assert.Equal(context.Request.Path, problem.Instance); - Assert.False(string.IsNullOrWhiteSpace(problem.TraceId)); + var actualTraceId = problem.Extensions.TryGetValue("traceId", out var id) ? id?.ToString() : null; + Assert.Equal(expectedTraceId, actualTraceId ?? context.TraceIdentifier); 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/ExceptionStatusCodeOptionsTests.cs b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs index 79e7d8c..3e7f68e 100644 --- a/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs @@ -5,12 +5,20 @@ namespace CR.Exceptions.AspNet.UnitTests; public sealed class ExceptionStatusCodeOptionsTests { [Fact] - public void Should_Return_404_Status_Code_For_NotFoundException() + public void Should_Return_404_For_NotFoundException() { var options = new ExceptionStatusCodeOptions().AddDefaultMappings(); - var exception = new TestNotFoundException(); - var statusCode = options.FindHttpStatusCode(exception); + var statusCode = options.FindHttpStatusCode(new TestNotFoundException()); Assert.Equal(StatusCodes.Status404NotFound, statusCode); } + + [Fact] + public void Should_Return_Null_For_UnregisteredException() + { + var options = new ExceptionStatusCodeOptions().AddDefaultMappings(); + var statusCode = options.FindHttpStatusCode(new TestUnregisteredException()); + + Assert.Null(statusCode); + } } \ 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..5d93592 --- /dev/null +++ b/CR.Exceptions.AspNet.UnitTests/TestUnregisteredException.cs @@ -0,0 +1,8 @@ +namespace CR.Exceptions.AspNet.UnitTests; + +public sealed class TestUnregisteredException : CrException +{ + public TestUnregisteredException() : base([new("TestUnregistered", "Test message")], "Unregistered") + { + } +} \ No newline at end of file diff --git a/CR.Exceptions.UnitTests/ErrorMapTests.cs b/CR.Exceptions.UnitTests/ErrorMapTests.cs index 033cd5c..bfd1677 100644 --- a/CR.Exceptions.UnitTests/ErrorMapTests.cs +++ b/CR.Exceptions.UnitTests/ErrorMapTests.cs @@ -1,5 +1,4 @@ using CR.Exceptions.Mapping; -using System.Collections.Immutable; namespace CR.Exceptions.UnitTests; @@ -11,54 +10,37 @@ public void TryGet_ShouldReturnErrors_WhenCodeExists() var errorCode = "InvalidGrant"; var registrationCode = "invalid_grant"; - ImmutableArray errors = - [ - new CrError(errorCode, "Invalid username or password.") - ]; - - var registration = new ErrorRegistration(registrationCode, errors); + 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 resolvedErrors); + var result = map.TryGet(registrationCode, out var errors); Assert.True(result); - var singleError = Assert.Single(resolvedErrors); + var singleError = Assert.Single(errors); Assert.Equal(errorCode, singleError.Code); } [Fact] - public void TryGet_ShouldReturnFalse_WhenCodeDoesNotExist() + public void TryGet_ShouldReturnFalse_WhenCodeNotExist() { - var map = new ErrorMapBuilder() - .Build(); - - var result = map.TryGet( - "unknown", - out var errors); + var map = new ErrorMapBuilder().Build(); + var result = map.TryGet("random", out var errors); Assert.False(result); - Assert.True(errors.IsEmpty); + Assert.True(errors.IsDefaultOrEmpty); } [Fact] public void Build_ShouldThrow_WhenDuplicateCodesRegistered() { - var first = new ErrorRegistration( - "duplicate", - [new("Code.One", "First")]); - - var second = new ErrorRegistration( - "duplicate", - [new("Code.Two", "Second")]); + var errorRegistration = new ErrorRegistration("duplicate", [new("code", "message")]); var builder = new ErrorMapBuilder() - .Add(first) - .Add(second); + .Add(errorRegistration) + .Add(errorRegistration); Assert.Throws(builder.Build); } diff --git a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs index d4b4a6a..248b54b 100644 --- a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs +++ b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs @@ -10,13 +10,8 @@ public void Create_ShouldReturnRegisteredException() var errorCode = "TestError"; var registrationCode = "test_error"; - var registration = new ErrorRegistration( - registrationCode, - [new CrError(errorCode, "Something went wrong.")]); - - var exceptionRegistration = new ExceptionRegistration( - registration, - errors => new TestException(errors)); + 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) @@ -30,16 +25,25 @@ public void Create_ShouldReturnRegisteredException() } [Fact] - public void TryCreate_ShouldReturnFalse_WhenCodeDoesNotExist() + public void TryCreate_ShouldReturnFalse_WhenCodeNotExist() { - var factory = new ExceptionFactoryBuilder() - .Build(); - - var result = factory.TryCreate( - "unknown", - out var exception); + var factory = new ExceptionFactoryBuilder().Build(); + var result = factory.TryCreate("random", 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 From c055f9eace494bd371c415aa92a2f05345c61631 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 19:29:25 +0300 Subject: [PATCH 24/32] CR.Exceptions.AspNet --- CR.Exceptions.AspNet/CrExceptionHandler.cs | 18 +++++++++++++----- .../ProblemDetailsExtensionNames.cs | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.cs b/CR.Exceptions.AspNet/CrExceptionHandler.cs index 0095ea2..b8f91da 100644 --- a/CR.Exceptions.AspNet/CrExceptionHandler.cs +++ b/CR.Exceptions.AspNet/CrExceptionHandler.cs @@ -12,9 +12,7 @@ namespace CR.Exceptions.AspNet; public sealed partial class CrExceptionHandler : IExceptionHandler { private static readonly ImmutableArray DefaultInternalErrors = - [ - new("InternalError", "An unexpected internal error occurred.") - ]; + [new("InternalError", "An unexpected internal error occurred.")]; private readonly IProblemDetailsService _problemDetailsService; private readonly CrExceptionOptions _options; @@ -71,8 +69,18 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e LogUnhandledException(_logger, exception, exceptionTypeName); } - var traceId = Activity.Current?.TraceId.ToHexString() ?? httpContext.TraceIdentifier; + var traceId = Activity.Current?.TraceId.ToHexString(); + if (string.IsNullOrEmpty(traceId)) + { + traceId = httpContext.TraceIdentifier; + } + var title = ReasonPhrases.GetReasonPhrase(httpStatusCode); + if (string.IsNullOrEmpty(title)) + { + title = "An error occurred"; + } + var problemDetailsContext = new ProblemDetailsContext { HttpContext = httpContext, @@ -81,7 +89,7 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e { Type = _options.ProblemDetails.Type, Status = httpStatusCode, - Title = string.IsNullOrWhiteSpace(title) ? "An error occurred" : title, + Title = title, Detail = detail, Instance = httpContext.Request.Path }, 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"; From 823df1b4bef8e1ef4c5078420c2659d5229b8b06 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 19:38:54 +0300 Subject: [PATCH 25/32] Update Tests --- .../CrExceptionHandlerTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs index d52ac4b..7ceefd7 100644 --- a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs @@ -9,6 +9,8 @@ namespace CR.Exceptions.AspNet.UnitTests; public sealed class CrExceptionHandlerTests { + private static readonly JsonSerializerOptions _prettyJsonOptions = new(JsonSerializerOptions.Web) { WriteIndented = true }; + private readonly ITestOutputHelper _output; public CrExceptionHandlerTests(ITestOutputHelper output) @@ -46,7 +48,7 @@ private async Task AssertHandlerResult(Exception exception, int expectedStatusCo responseStream.Position = 0; var problem = await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions.Web); - AssertProblemDetails(problem, context, expectedStatusCode, activity.Id); + AssertProblemDetails(problem, context, expectedStatusCode, activity.TraceId.ToHexString()); } private static ServiceProvider CreateServiceProvider() @@ -69,7 +71,7 @@ private static DefaultHttpContext CreateContext(MemoryStream responseStream) private void AssertProblemDetails(CustomProblemDetails? problem, HttpContext context, int expectedStatusCode, string? expectedTraceId) { Assert.NotNull(problem); - _output.WriteLine(JsonSerializer.Serialize(problem, options: new(JsonSerializerOptions.Web) { WriteIndented = true })); + _output.WriteLine(JsonSerializer.Serialize(problem, options: _prettyJsonOptions)); Assert.False(string.IsNullOrEmpty(problem.Type)); Assert.False(string.IsNullOrEmpty(problem.Title)); @@ -78,8 +80,8 @@ private void AssertProblemDetails(CustomProblemDetails? problem, HttpContext con Assert.Equal(expectedStatusCode, problem.Status); Assert.Equal(context.Request.Path, problem.Instance); - var actualTraceId = problem.Extensions.TryGetValue("traceId", out var id) ? id?.ToString() : null; - Assert.Equal(expectedTraceId, actualTraceId ?? context.TraceIdentifier); + var actualTraceId = problem.Extensions.TryGetValue(ProblemDetailsExtensionNames.TraceId, out var id) ? id?.ToString() : null; + Assert.Equal(expectedTraceId, actualTraceId); Assert.NotNull(problem.Errors); Assert.NotEmpty(problem.Errors); From ab7986e22bcb95fe091ff38aafd949ae09113878 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 20:23:42 +0300 Subject: [PATCH 26/32] Update CR.Exceptions.AspNet --- CR.Exceptions.AspNet/CrExceptionHandler.cs | 21 ++----------------- CR.Exceptions.AspNet/CrExceptionOptions.cs | 1 - CR.Exceptions.AspNet/ProblemDetailsOptions.cs | 6 ------ .../ServiceCollectionExtensions.cs | 18 ++++++++++++++-- 4 files changed, 18 insertions(+), 28 deletions(-) delete mode 100644 CR.Exceptions.AspNet/ProblemDetailsOptions.cs diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.cs b/CR.Exceptions.AspNet/CrExceptionHandler.cs index b8f91da..4481a68 100644 --- a/CR.Exceptions.AspNet/CrExceptionHandler.cs +++ b/CR.Exceptions.AspNet/CrExceptionHandler.cs @@ -1,11 +1,9 @@ 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.Collections.Immutable; -using System.Diagnostics; namespace CR.Exceptions.AspNet; @@ -69,37 +67,22 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e LogUnhandledException(_logger, exception, exceptionTypeName); } - var traceId = Activity.Current?.TraceId.ToHexString(); - if (string.IsNullOrEmpty(traceId)) - { - traceId = httpContext.TraceIdentifier; - } - - var title = ReasonPhrases.GetReasonPhrase(httpStatusCode); - if (string.IsNullOrEmpty(title)) - { - title = "An error occurred"; - } - var problemDetailsContext = new ProblemDetailsContext { HttpContext = httpContext, Exception = exception, ProblemDetails = { - Type = _options.ProblemDetails.Type, Status = httpStatusCode, - Title = title, Detail = detail, Instance = httpContext.Request.Path }, }; - AddProblemDetailsExtension(problemDetailsContext.ProblemDetails, ProblemDetailsExtensionNames.TraceId, traceId); - AddProblemDetailsExtension(problemDetailsContext.ProblemDetails, ProblemDetailsExtensionNames.Errors, errors); - httpContext.Response.StatusCode = httpStatusCode; + AddProblemDetailsExtension(problemDetailsContext.ProblemDetails, ProblemDetailsExtensionNames.Errors, errors); + var isWritten = await _problemDetailsService.TryWriteAsync(problemDetailsContext); if (!isWritten) diff --git a/CR.Exceptions.AspNet/CrExceptionOptions.cs b/CR.Exceptions.AspNet/CrExceptionOptions.cs index 77f75c8..0f83c58 100644 --- a/CR.Exceptions.AspNet/CrExceptionOptions.cs +++ b/CR.Exceptions.AspNet/CrExceptionOptions.cs @@ -3,5 +3,4 @@ public sealed class CrExceptionOptions { public ExceptionStatusCodeOptions StatusCodes { get; init; } = new(); - public ProblemDetailsOptions ProblemDetails { get; init; } = new(); } \ No newline at end of file diff --git a/CR.Exceptions.AspNet/ProblemDetailsOptions.cs b/CR.Exceptions.AspNet/ProblemDetailsOptions.cs deleted file mode 100644 index 173cc78..0000000 --- a/CR.Exceptions.AspNet/ProblemDetailsOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CR.Exceptions.AspNet; - -public sealed class ProblemDetailsOptions -{ - public string Type { get; set; } = "about:blank"; -} \ No newline at end of file diff --git a/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs b/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs index c621452..9e71888 100644 --- a/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs +++ b/CR.Exceptions.AspNet/ServiceCollectionExtensions.cs @@ -19,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 From d7f452391259cfc7dac5615dc114ca80e125c31e Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 20:24:47 +0300 Subject: [PATCH 27/32] Update tests --- .../CrExceptionHandlerTests.cs | 38 +++++++++++++++---- .../ExceptionStatusCodeOptionsTests.cs | 22 ++++++++--- .../TestNotFoundException.cs | 2 +- .../TestUnregisteredException.cs | 8 ---- CR.Exceptions.UnitTests/ErrorMapTests.cs | 7 ++-- .../ExceptionFactoryTests.cs | 13 ++++--- CR.Exceptions.UnitTests/TestException.cs | 4 +- 7 files changed, 61 insertions(+), 33 deletions(-) delete mode 100644 CR.Exceptions.AspNet.UnitTests/TestUnregisteredException.cs diff --git a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs index 7ceefd7..c6e1cd3 100644 --- a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs @@ -21,18 +21,36 @@ public CrExceptionHandlerTests(ITestOutputHelper output) [Fact] public Task Should_Return_404_For_NotFoundException() { - return AssertHandlerResult(new TestNotFoundException(), StatusCodes.Status404NotFound); + return AssertHandlerResult( + new TestNotFoundException(), + StatusCodes.Status404NotFound, + canCreateActivity: true); } [Fact] public Task Should_Return_500_For_UnhandledException() { - return AssertHandlerResult(new TestUnregisteredException(), StatusCodes.Status500InternalServerError); + return AssertHandlerResult( + new Exception("Unknown exception"), + StatusCodes.Status500InternalServerError, + canCreateActivity: true); } - private async Task AssertHandlerResult(Exception exception, int expectedStatusCode) + [Fact] + public Task Should_Use_HttpContext_TraceIdentifier_When_Activity_Is_Missing() + { + return AssertHandlerResult( + new Exception("Unknown exception"), + StatusCodes.Status500InternalServerError, + canCreateActivity: false); + } + + private async Task AssertHandlerResult(Exception exception, int expectedStatusCode, bool canCreateActivity) { - using var activity = new Activity("TestActivity").Start(); + using var activity = canCreateActivity + ? new Activity("TestActivity").Start() + : null; + using var provider = CreateServiceProvider(); var handler = provider.GetRequiredService(); @@ -46,9 +64,11 @@ private async Task AssertHandlerResult(Exception exception, int expectedStatusCo Assert.Contains("application/problem+json", context.Response.ContentType); responseStream.Position = 0; + var problem = await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions.Web); + var expectedTraceId = activity?.TraceId.ToHexString() ?? context.TraceIdentifier; - AssertProblemDetails(problem, context, expectedStatusCode, activity.TraceId.ToHexString()); + AssertProblemDetails(problem, context, expectedStatusCode, expectedTraceId); } private static ServiceProvider CreateServiceProvider() @@ -71,6 +91,7 @@ private static DefaultHttpContext CreateContext(MemoryStream responseStream) private void AssertProblemDetails(CustomProblemDetails? problem, HttpContext context, int expectedStatusCode, string? expectedTraceId) { Assert.NotNull(problem); + _output.WriteLine(JsonSerializer.Serialize(problem, options: _prettyJsonOptions)); Assert.False(string.IsNullOrEmpty(problem.Type)); @@ -80,8 +101,11 @@ private void AssertProblemDetails(CustomProblemDetails? problem, HttpContext con Assert.Equal(expectedStatusCode, problem.Status); Assert.Equal(context.Request.Path, problem.Instance); - var actualTraceId = problem.Extensions.TryGetValue(ProblemDetailsExtensionNames.TraceId, out var id) ? id?.ToString() : null; - Assert.Equal(expectedTraceId, actualTraceId); + Assert.True(problem.Extensions.TryGetValue( + ProblemDetailsExtensionNames.TraceId, + out var traceId)); + + Assert.Equal(expectedTraceId, traceId?.ToString()); Assert.NotNull(problem.Errors); Assert.NotEmpty(problem.Errors); diff --git a/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs index 3e7f68e..1ad37bb 100644 --- a/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/ExceptionStatusCodeOptionsTests.cs @@ -7,18 +7,28 @@ public sealed class ExceptionStatusCodeOptionsTests [Fact] public void Should_Return_404_For_NotFoundException() { - var options = new ExceptionStatusCodeOptions().AddDefaultMappings(); - var statusCode = options.FindHttpStatusCode(new TestNotFoundException()); - + var statusCode = GetStatusCodeFor(new TestNotFoundException()); Assert.Equal(StatusCodes.Status404NotFound, statusCode); } [Fact] public void Should_Return_Null_For_UnregisteredException() { - var options = new ExceptionStatusCodeOptions().AddDefaultMappings(); - var statusCode = options.FindHttpStatusCode(new TestUnregisteredException()); - + 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.UnitTests/TestUnregisteredException.cs b/CR.Exceptions.AspNet.UnitTests/TestUnregisteredException.cs deleted file mode 100644 index 5d93592..0000000 --- a/CR.Exceptions.AspNet.UnitTests/TestUnregisteredException.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CR.Exceptions.AspNet.UnitTests; - -public sealed class TestUnregisteredException : CrException -{ - public TestUnregisteredException() : base([new("TestUnregistered", "Test message")], "Unregistered") - { - } -} \ No newline at end of file diff --git a/CR.Exceptions.UnitTests/ErrorMapTests.cs b/CR.Exceptions.UnitTests/ErrorMapTests.cs index bfd1677..b1b8c14 100644 --- a/CR.Exceptions.UnitTests/ErrorMapTests.cs +++ b/CR.Exceptions.UnitTests/ErrorMapTests.cs @@ -7,8 +7,8 @@ public sealed class ErrorMapTests [Fact] public void TryGet_ShouldReturnErrors_WhenCodeExists() { - var errorCode = "InvalidGrant"; - var registrationCode = "invalid_grant"; + const string errorCode = "InvalidGrant"; + const string registrationCode = "invalid_grant"; var registration = new ErrorRegistration(registrationCode, [new(errorCode, "Invalid username or password.")]); @@ -27,7 +27,8 @@ public void TryGet_ShouldReturnErrors_WhenCodeExists() public void TryGet_ShouldReturnFalse_WhenCodeNotExist() { var map = new ErrorMapBuilder().Build(); - var result = map.TryGet("random", out var errors); + + var result = map.TryGet("non_existent_code", out var errors); Assert.False(result); Assert.True(errors.IsDefaultOrEmpty); diff --git a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs index 248b54b..22e69f3 100644 --- a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs +++ b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs @@ -5,10 +5,10 @@ namespace CR.Exceptions.UnitTests; public sealed class ExceptionFactoryTests { [Fact] - public void Create_ShouldReturnRegisteredException() + public void Create_ShouldReturnRegisteredException_WhenCodeExists() { - var errorCode = "TestError"; - var registrationCode = "test_error"; + 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)); @@ -19,8 +19,8 @@ public void Create_ShouldReturnRegisteredException() var exception = factory.Create(registrationCode); - Assert.IsType(exception); - var singleError = Assert.Single(exception.Errors); + var typedException = Assert.IsType(exception); + var singleError = Assert.Single(typedException.Errors); Assert.Equal(errorCode, singleError.Code); } @@ -28,7 +28,8 @@ public void Create_ShouldReturnRegisteredException() public void TryCreate_ShouldReturnFalse_WhenCodeNotExist() { var factory = new ExceptionFactoryBuilder().Build(); - var result = factory.TryCreate("random", out var exception); + + var result = factory.TryCreate("non_existent_code", out var exception); Assert.False(result); Assert.Null(exception); diff --git a/CR.Exceptions.UnitTests/TestException.cs b/CR.Exceptions.UnitTests/TestException.cs index cc9ac16..30d890a 100644 --- a/CR.Exceptions.UnitTests/TestException.cs +++ b/CR.Exceptions.UnitTests/TestException.cs @@ -2,9 +2,9 @@ namespace CR.Exceptions.UnitTests; -public sealed class TestException : CrException +internal sealed class TestException : CrException { - public TestException(ImmutableArray errors) : base(errors, "Test exception") + public TestException(ImmutableArray errors) : base(errors, "Test exception message") { } } \ No newline at end of file From aab4148e0a83bd3dcdc6f6b659ba0eef5a438bb1 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 21:38:06 +0300 Subject: [PATCH 28/32] Update CrExceptionHandler.cs --- CR.Exceptions.AspNet/CrExceptionHandler.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/CR.Exceptions.AspNet/CrExceptionHandler.cs b/CR.Exceptions.AspNet/CrExceptionHandler.cs index 4481a68..da07cef 100644 --- a/CR.Exceptions.AspNet/CrExceptionHandler.cs +++ b/CR.Exceptions.AspNet/CrExceptionHandler.cs @@ -38,8 +38,8 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e var exceptionType = exception.GetType(); var exceptionTypeName = exceptionType.FullName ?? exceptionType.Name; - ImmutableArray errors; - string detail; + var errors = DefaultInternalErrors; + var detail = "An unexpected error occurred."; if (exception is CrException crException) { @@ -61,12 +61,11 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e } else { - detail = "An unexpected error occurred."; - errors = DefaultInternalErrors; - LogUnhandledException(_logger, exception, exceptionTypeName); } + httpContext.Response.StatusCode = httpStatusCode; + var problemDetailsContext = new ProblemDetailsContext { HttpContext = httpContext, @@ -78,9 +77,6 @@ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception e Instance = httpContext.Request.Path }, }; - - httpContext.Response.StatusCode = httpStatusCode; - AddProblemDetailsExtension(problemDetailsContext.ProblemDetails, ProblemDetailsExtensionNames.Errors, errors); var isWritten = await _problemDetailsService.TryWriteAsync(problemDetailsContext); From 21052c457040bd67872707a8db0204f35a7ef765 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 21:40:14 +0300 Subject: [PATCH 29/32] Update CrExceptionHandlerTests.cs --- CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs index c6e1cd3..338705d 100644 --- a/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs +++ b/CR.Exceptions.AspNet.UnitTests/CrExceptionHandlerTests.cs @@ -37,7 +37,7 @@ public Task Should_Return_500_For_UnhandledException() } [Fact] - public Task Should_Use_HttpContext_TraceIdentifier_When_Activity_Is_Missing() + public Task Should_Return_500_For_UnhandledException_When_Activity_Is_Missing() { return AssertHandlerResult( new Exception("Unknown exception"), From ccb02af988c6dea6989c7728e95318ecfe4547d2 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 21:46:55 +0300 Subject: [PATCH 30/32] Update README.md --- README.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ca3c982..b14e3c7 100644 --- a/README.md +++ b/README.md @@ -96,16 +96,15 @@ user_not_found These codes can be registered in `ErrorMap` and converted into application-level errors: ```csharp -var _errorMap = _errorMapBuilder.Add(new ErrorRegistration( +ErrorMap errorMap = _errorMapBuilder.Add(new ErrorRegistration( "user_not_found", - [new CrError("Identity.UserNotFound", "User was not found.")])) - .Build(); + [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)) +if (errorMap.TryGet("user_not_found", out var errors)) { throw new UserNotFoundException(errors); } @@ -140,19 +139,18 @@ Example registration: ```csharp var registration = new ErrorRegistration( "invalid_grant", - [new CrError("Identity.InvalidCredentials", "Invalid username or password.")]); + [new CrError("IdentityInvalidCredentials", "Invalid username or password.")]); -var _factory = _exceptionFactoryBuilder.Add( +ExceptionFactory factory = _exceptionFactoryBuilder.Add( new ExceptionRegistration( registration, - errors => new InvalidCredentialsException(errors))) - .Build(); + errors => new InvalidCredentialsException(errors))).Build(); ``` After registration: ```csharp -var exception = _factory.Create("invalid_grant"); +var exception = factory.Create("invalid_grant"); throw exception; ``` @@ -169,18 +167,18 @@ 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": "Identity.UserNotFound", - "message": "User '123' was not found." + "code": "TestNotFound", + "message": "Test Entity not found" } - ] + ], + "traceId": "5a1192a06ca5cd006057ca7e6b84e231" } ``` @@ -196,15 +194,17 @@ 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": "/api/test", "errors": [ { "code": "InternalError", "message": "An unexpected internal error occurred." } - ] + ], + "traceId": "3e071f69b5f9e695a32a699369b57651" } ``` \ No newline at end of file From 7fa6b5762a96280a5029074f94bcb6783e3f14b9 Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 21:53:42 +0300 Subject: [PATCH 31/32] Update ExceptionFactory.cs --- CR.Exceptions/Mapping/ExceptionFactory.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CR.Exceptions/Mapping/ExceptionFactory.cs b/CR.Exceptions/Mapping/ExceptionFactory.cs index 7ad83b8..aec062a 100644 --- a/CR.Exceptions/Mapping/ExceptionFactory.cs +++ b/CR.Exceptions/Mapping/ExceptionFactory.cs @@ -19,14 +19,16 @@ public CrException Create(string code) return exception; } - throw new KeyNotFoundException($"Exception with code '{code}' is not found."); + 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); + exception = registration.Factory(registration.Definition.Errors) + ?? throw new NullReferenceException("The registered factory return null exception"); + return true; } From 813ee444d22356356a04b5c791dc83f289dc2fba Mon Sep 17 00:00:00 2001 From: apptade Date: Thu, 30 Jul 2026 21:58:37 +0300 Subject: [PATCH 32/32] Update ExceptionFactoryTests.cs --- CR.Exceptions.UnitTests/ExceptionFactoryTests.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs index 22e69f3..f183ad9 100644 --- a/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs +++ b/CR.Exceptions.UnitTests/ExceptionFactoryTests.cs @@ -5,7 +5,7 @@ namespace CR.Exceptions.UnitTests; public sealed class ExceptionFactoryTests { [Fact] - public void Create_ShouldReturnRegisteredException_WhenCodeExists() + public void TryCreate_ShouldReturnException_WhenCodeExists() { const string errorCode = "TestError"; const string registrationCode = "test_error"; @@ -17,7 +17,10 @@ public void Create_ShouldReturnRegisteredException_WhenCodeExists() .Add(exceptionRegistration) .Build(); - var exception = factory.Create(registrationCode); + 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);