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