diff --git a/JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs b/JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs deleted file mode 100644 index 0d44212..0000000 --- a/JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using JsonApiToolkit.Filters; -using JsonApiToolkit.Models.Errors; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Moq; - -namespace JsonApiToolkit.Tests.Filters; - -public class JsonApiExceptionFilterTests -{ - private readonly Mock> _loggerMock; - private readonly Mock _environmentMock; - private readonly JsonApiExceptionFilter _filter; - - public JsonApiExceptionFilterTests() - { - _loggerMock = new Mock>(); - _environmentMock = new Mock(); - _filter = new JsonApiExceptionFilter(_loggerMock.Object, _environmentMock.Object); - } - - [Fact] - public void OnException_InDevelopment_ReturnsExceptionDetails() - { - // Arrange - _environmentMock.Setup(e => e.EnvironmentName).Returns("Development"); - - var exception = new InvalidOperationException("Test exception message"); - var context = CreateExceptionContext(exception); - - // Act - _filter.OnException(context); - - // Assert - Assert.True(context.ExceptionHandled); - var objectResult = Assert.IsType(context.Result); - Assert.Equal(500, objectResult.StatusCode); - - var errorResponse = Assert.IsType(objectResult.Value); - Assert.Single(errorResponse.Errors); - Assert.Equal("500", errorResponse.Errors[0].Status); - Assert.Equal("Internal Server Error", errorResponse.Errors[0].Title); - Assert.Equal("Test exception message", errorResponse.Errors[0].Detail); - } - - [Fact] - public void OnException_InProduction_ReturnsGenericMessage() - { - // Arrange - _environmentMock.Setup(e => e.EnvironmentName).Returns("Production"); - - var exception = new InvalidOperationException("Test exception message"); - var context = CreateExceptionContext(exception); - - // Act - _filter.OnException(context); - - // Assert - Assert.True(context.ExceptionHandled); - var objectResult = Assert.IsType(context.Result); - Assert.Equal(500, objectResult.StatusCode); - - var errorResponse = Assert.IsType(objectResult.Value); - Assert.Single(errorResponse.Errors); - Assert.Equal("500", errorResponse.Errors[0].Status); - Assert.Equal("Internal Server Error", errorResponse.Errors[0].Title); - Assert.Equal( - "An error occurred while processing your request.", - errorResponse.Errors[0].Detail - ); - } - - private static ExceptionContext CreateExceptionContext(Exception exception) - { - var httpContext = new DefaultHttpContext(); - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - return new ExceptionContext(actionContext, new List()) - { - Exception = exception, - }; - } -} diff --git a/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs b/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs index ffaee89..d04d8ce 100644 --- a/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs +++ b/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs @@ -1,77 +1,71 @@ using JsonApiToolkit.Models.Errors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Filters; /// -/// Exception filter that transforms unhandled exceptions into JSON:API compliant error responses. +/// Exception filter that transforms known and unknown exceptions into JSON:API compliant error responses. /// -/// -/// -/// This filter ensures that all unhandled exceptions in JSON:API controllers result in properly formatted -/// JSON:API error responses rather than the default ASP.NET Core error format. -/// -/// -/// In development environments, the filter includes detailed exception information in the response. -/// In production environments, it provides a generic error message to avoid exposing sensitive details. -/// -/// -/// The filter automatically logs all exceptions using the provided ILogger instance. -/// -/// -/// Logger for recording exception details -/// Host environment to determine the level of error detail -public class JsonApiExceptionFilter( - ILogger logger, - IHostEnvironment environment -) : IExceptionFilter +public class JsonApiExceptionFilter(ILogger logger) : IExceptionFilter { private readonly ILogger _logger = logger; - private readonly IHostEnvironment _environment = environment; /// - /// Transforms an unhandled exception into a standardized JSON:API error response. + /// Handles exceptions thrown during the execution of a controller action. /// - /// The exception context containing the exception and controller context + /// The context of the exception. /// - /// This method: - /// - /// - /// Logs the exception using the configured logger - /// - /// - /// Creates a JSON:API error object with a 500 status code - /// - /// - /// Sets appropriate error detail based on the environment (detailed in development, generic in production) - /// - /// - /// Returns the error response and marks the exception as handled - /// - /// /// - /// The resulting error response follows the JSON:API specification for error objects. + /// This method inspects the exception and determines the appropriate HTTP status code + /// and error message to return in the JSON:API error response. + /// + /// + /// It handles known exceptions (e.g., JsonApiBadRequestException, JsonApiNotFoundException) + /// and logs unexpected exceptions (500 Internal Server Error). /// /// public void OnException(ExceptionContext context) { - _logger.LogError(context.Exception, "An unhandled exception occurred"); + var (status, title) = context.Exception switch + { + JsonApiBadRequestException => (400, "Bad Request"), + JsonApiNotFoundException => (404, "Not Found"), + JsonApiConflictException => (409, "Conflict"), + JsonApiUnauthorizedException => (401, "Unauthorized"), + JsonApiForbiddenException => (403, "Forbidden"), + _ => (500, "Internal Server Error"), + }; + + if (status == 500) + { + // Log full stack trace for unexpected errors + _logger.LogError(context.Exception, "An unhandled exception occurred"); + } + else + { + // Log only the message for handled exceptions + _logger.LogInformation( + "Handled JSON:API exception: {Type} - {Message}", + context.Exception.GetType().Name, + context.Exception.Message + ); + } var error = new JsonApiError { - Status = "500", - Title = "Internal Server Error", - Detail = _environment.IsDevelopment() - ? context.Exception.Message - : "An error occurred while processing your request.", + Status = status.ToString(), + Title = title, + Detail = + status != 500 + ? context.Exception.Message + : "An error occurred while processing your request.", }; var response = new JsonApiErrorResponse { Errors = [error] }; - context.Result = new ObjectResult(response) { StatusCode = 500 }; + context.Result = new ObjectResult(response) { StatusCode = status }; context.ExceptionHandled = true; } } diff --git a/JsonApiToolkit/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index 914938b..48ef296 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 0.1.0 + 0.1.1 Intility Intility A toolkit for implementing JSON:API specification in .NET applications diff --git a/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs b/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs new file mode 100644 index 0000000..db5bceb --- /dev/null +++ b/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs @@ -0,0 +1,29 @@ +// Supress warnings for this file +#pragma warning disable RCS1194 + +namespace JsonApiToolkit.Models.Errors; + +/// +/// Exception representing a 400 Bad Request error. +/// +public class JsonApiBadRequestException(string message) : Exception(message) { } + +/// +/// Exception representing a 404 Not Found error. +/// +public class JsonApiNotFoundException(string message) : Exception(message) { } + +/// +/// Exception representing a 409 Conflict error. +/// +public class JsonApiConflictException(string message) : Exception(message) { } + +/// +/// Exception representing a 401 Unauthorized error. +/// +public class JsonApiUnauthorizedException(string message) : Exception(message) { } + +/// +/// Exception representing a 403 Forbidden error. +/// +public class JsonApiForbiddenException(string message) : Exception(message) { } diff --git a/docs/docs/enhanced-error-handling.md b/docs/docs/enhanced-error-handling.md new file mode 100644 index 0000000..7310f81 --- /dev/null +++ b/docs/docs/enhanced-error-handling.md @@ -0,0 +1,69 @@ +# Enhanced Error Handling in JsonApiToolkit + +JsonApiToolkit provides a clean, consistent way to handle errors in your ASP.NET Core APIs. By throwing specific exceptions in your services or controllers, you get: + +- **Standardized JSON:API error responses** for your clients +- **Clear, minimal logging**: only unexpected errors include stack traces + +## Supported Exceptions + +Throw these exceptions in your code to trigger the corresponding HTTP status and error response: + +| Exception Type | HTTP Status | Typical Use Case | +|-----------------------------------|-------------|---------------------------------------| +| `JsonApiBadRequestException` | 400 | Validation or malformed input | +| `JsonApiNotFoundException` | 404 | Resource not found | +| `JsonApiConflictException` | 409 | Unique constraint or conflict | +| `JsonApiUnauthorizedException` | 401 | Not authenticated | +| `JsonApiForbiddenException` | 403 | Not authorized | + +Any other unhandled exception will result in a 500 Internal Server Error. + +> [!TIP] +> If you are missing an exception type for your use case, please create an issue on GitHub. + +## How It Works + +- Throw a specific exception (e.g., `JsonApiNotFoundException`, `JsonApiBadRequestException`) in your code when an error occurs. +- The toolkit automatically converts this into the correct HTTP status code and a JSON:API error response. +- Only unexpected errors (500) are logged with stack traces; handled errors (400, 404, etc.) log just the type and message. + +## Example Usage + +```csharp +if (string.IsNullOrWhiteSpace(request.Title)) + throw new JsonApiBadRequestException("Todo title cannot be empty."); + +var todo = await _dbContext.Todos.FirstOrDefaultAsync(t => t.Id == todoId) + ?? throw new JsonApiNotFoundException($"Todo with ID {todoId} not found."); +``` + +--- + +## Example Client Response + +If a todo is not found, the client receives: + +```json +{ + "errors": [ + { + "status": "404", + "title": "Not Found", + "detail": "Todo with ID 42 not found." + } + ] +} +``` + +--- + +## Example Console Log + +For the same error, your log will show: + +``` +[09:07:11 INF] Handled JSON:API exception: JsonApiNotFoundException - Todo with ID 42 not found. +``` + +*No stack trace is logged for handled errors like 400, 404, or 409.* diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml index b87649b..53e093c 100644 --- a/docs/docs/toc.yml +++ b/docs/docs/toc.yml @@ -6,6 +6,8 @@ href: querying.md - name: Use Cases href: use-cases.md +- name: Enhanced Error Handling + href: enhanced-error-handling.md - name: API Controller Examples href: api-controller-examples.md - name: Integrations