Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 0 additions & 88 deletions JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs

This file was deleted.

88 changes: 41 additions & 47 deletions JsonApiToolkit/Filters/JsonApiExceptionFilter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// The filter automatically logs all exceptions using the provided ILogger instance.
/// </para>
/// </remarks>
/// <param name="logger">Logger for recording exception details</param>
/// <param name="environment">Host environment to determine the level of error detail</param>
public class JsonApiExceptionFilter(
ILogger<JsonApiExceptionFilter> logger,
IHostEnvironment environment
) : IExceptionFilter
public class JsonApiExceptionFilter(ILogger<JsonApiExceptionFilter> logger) : IExceptionFilter
{
private readonly ILogger<JsonApiExceptionFilter> _logger = logger;
private readonly IHostEnvironment _environment = environment;

/// <summary>
/// Transforms an unhandled exception into a standardized JSON:API error response.
/// Handles exceptions thrown during the execution of a controller action.
/// </summary>
/// <param name="context">The exception context containing the exception and controller context</param>
/// <param name="context">The context of the exception.</param>
/// <remarks>
/// This method:
/// <list type="number">
/// <item>
/// <description>Logs the exception using the configured logger</description>
/// </item>
/// <item>
/// <description>Creates a JSON:API error object with a 500 status code</description>
/// </item>
/// <item>
/// <description>Sets appropriate error detail based on the environment (detailed in development, generic in production)</description>
/// </item>
/// <item>
/// <description>Returns the error response and marks the exception as handled</description>
/// </item>
/// </list>
/// <para>
/// 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.
/// </para>
/// <para>
/// It handles known exceptions (e.g., JsonApiBadRequestException, JsonApiNotFoundException)
/// and logs unexpected exceptions (500 Internal Server Error).
/// </para>
/// </remarks>
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;
}
}
2 changes: 1 addition & 1 deletion JsonApiToolkit/JsonApiToolkit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<!-- Package metadata -->
<PackageId>Intility.JsonApiToolkit</PackageId>
<Version>0.1.0</Version>
<Version>0.1.1</Version>
<Authors>Intility</Authors>
<Company>Intility</Company>
<Description>A toolkit for implementing JSON:API specification in .NET applications</Description>
Expand Down
29 changes: 29 additions & 0 deletions JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Supress warnings for this file
#pragma warning disable RCS1194

namespace JsonApiToolkit.Models.Errors;

/// <summary>
/// Exception representing a 400 Bad Request error.
/// </summary>
public class JsonApiBadRequestException(string message) : Exception(message) { }

/// <summary>
/// Exception representing a 404 Not Found error.
/// </summary>
public class JsonApiNotFoundException(string message) : Exception(message) { }

/// <summary>
/// Exception representing a 409 Conflict error.
/// </summary>
public class JsonApiConflictException(string message) : Exception(message) { }

/// <summary>
/// Exception representing a 401 Unauthorized error.
/// </summary>
public class JsonApiUnauthorizedException(string message) : Exception(message) { }

/// <summary>
/// Exception representing a 403 Forbidden error.
/// </summary>
public class JsonApiForbiddenException(string message) : Exception(message) { }
69 changes: 69 additions & 0 deletions docs/docs/enhanced-error-handling.md
Original file line number Diff line number Diff line change
@@ -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.*
2 changes: 2 additions & 0 deletions docs/docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down