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
69 changes: 69 additions & 0 deletions JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,4 +238,73 @@ string expectedTitle
Assert.Equal(statusCode.ToString(), errorResponse.Errors[0].Status);
Assert.Equal(expectedTitle, errorResponse.Errors[0].Title);
}

[Fact]
public void OnException_WithFullMetadata_SerializesAllFields()
{
var exception = new JsonApiBadRequestException(
"Invalid filter value",
code: "INVALID_FILTER_VALUE",
errorSource: new ErrorSource { Parameter = "filter[age]" },
meta: new Dictionary<string, object>
{
["field"] = "age",
["expectedType"] = "Int32",
["actualValue"] = "abc",
}
);
var context = CreateExceptionContext(exception);

_filter.OnException(context);

var result = Assert.IsType<ObjectResult>(context.Result);
var errorResponse = Assert.IsType<JsonApiErrorResponse>(result.Value);
var error = errorResponse.Errors[0];
Comment on lines +258 to +262
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn’t assert context.ExceptionHandled or that exactly one error was produced. Adding Assert.True(context.ExceptionHandled) and Assert.Single(errorResponse.Errors) (and optionally verifying the 400 status) would align with the other tests and ensure regressions in exception handling/multi-error behavior are caught.

Copilot uses AI. Check for mistakes.

Assert.Equal("INVALID_FILTER_VALUE", error.Code);
Assert.NotNull(error.Source);
Assert.Equal("filter[age]", error.Source.Parameter);
Assert.NotNull(error.Meta);
Assert.Equal("age", error.Meta["field"]);
Assert.Equal("Int32", error.Meta["expectedType"]);
Assert.Equal("abc", error.Meta["actualValue"]);
}

[Fact]
public void OnException_WithFactoryException_SerializesCorrectly()
{
var exception = JsonApiErrors.NotFound("books", 123);
var context = CreateExceptionContext(exception);

_filter.OnException(context);

var result = Assert.IsType<ObjectResult>(context.Result);
Assert.Equal(404, result.StatusCode);

var errorResponse = Assert.IsType<JsonApiErrorResponse>(result.Value);
var error = errorResponse.Errors[0];

Comment on lines +279 to +286
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test indexes errorResponse.Errors[0] without asserting the collection size or that the exception was marked handled. Please add Assert.True(context.ExceptionHandled) and Assert.Single(errorResponse.Errors) before accessing [0] to make the test fail with clearer intent if behavior changes.

Copilot uses AI. Check for mistakes.
Assert.Equal("RESOURCE_NOT_FOUND", error.Code);
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using JsonApiErrorCodes.ResourceNotFound instead of the string literal "RESOURCE_NOT_FOUND" to avoid duplicating the constant value and keep the test aligned with the library’s public API.

Suggested change
Assert.Equal("RESOURCE_NOT_FOUND", error.Code);
Assert.Equal(JsonApiErrorCodes.ResourceNotFound, error.Code);

Copilot uses AI. Check for mistakes.
Assert.NotNull(error.Meta);
Assert.Equal("books", error.Meta["resourceType"]);
Assert.Equal(123, error.Meta["id"]);
}

[Fact]
public void OnException_WithSourcePointer_SerializesCorrectly()
{
var exception = JsonApiErrors.AlreadyExists("users", "email", "test@example.com");
var context = CreateExceptionContext(exception);

_filter.OnException(context);

var result = Assert.IsType<ObjectResult>(context.Result);
Assert.Equal(409, result.StatusCode);

var errorResponse = Assert.IsType<JsonApiErrorResponse>(result.Value);
var error = errorResponse.Errors[0];

Comment on lines +299 to +306
Copy link

Copilot AI Jan 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test should assert context.ExceptionHandled and Assert.Single(errorResponse.Errors) before accessing errorResponse.Errors[0], consistent with the earlier tests, so it also verifies the filter doesn’t emit multiple errors.

Copilot uses AI. Check for mistakes.
Assert.NotNull(error.Source);
Assert.Equal("/data/attributes/email", error.Source.Pointer);
}
}
86 changes: 86 additions & 0 deletions docs/docs/enhanced-error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,89 @@ The enhanced exception produces richer error responses:

> [!IMPORTANT]
> When using these exceptions, ensure that the parent is not wrapped in a try-catch block that catches all exceptions. This will prevent the toolkit from handling the error correctly.

---

## Error Factory Methods (v1.3.0+)

For common error scenarios, use the `JsonApiErrors` factory class to create consistent, well-structured errors with proper codes, source information, and metadata:

### Available Factory Methods

| Factory | Status | Use Case |
|---------|--------|----------|
| `JsonApiErrors.NotFound(type, id)` | 404 | Resource not found |
| `JsonApiErrors.RelatedNotFound(type, id, relationship, relatedId)` | 404 | Related resource not found |
| `JsonApiErrors.InvalidFilterValue(field, value, expectedType)` | 400 | Type conversion failed |
| `JsonApiErrors.InvalidFilterField(field, entityType)` | 400 | Field doesn't exist |
| `JsonApiErrors.InvalidFilterOperator(op)` | 400 | Unknown filter operator |
| `JsonApiErrors.InvalidSortField(field, entityType)` | 400 | Sort field doesn't exist |
| `JsonApiErrors.IncludeNotAllowed(include)` | 403 | Include blocked by AllowedIncludes |
| `JsonApiErrors.FilterNotAllowed(relationshipPath)` | 403 | Filter on disallowed relationship |
| `JsonApiErrors.AlreadyExists(type, field, value)` | 409 | Duplicate key violation |
| `JsonApiErrors.ValidationFailed(field, message)` | 400 | Generic validation error |
| `JsonApiErrors.RequiredFieldMissing(field)` | 400 | Required field not provided |
| `JsonApiErrors.QueryTooComplex(limitName, limit, actual, configKey)` | 400 | Query exceeds limits |

### Usage Examples

```csharp
// Resource not found
var book = await _db.Books.FindAsync(id)
?? throw JsonApiErrors.NotFound("books", id);

// Invalid filter value
if (!int.TryParse(filterValue, out _))
throw JsonApiErrors.InvalidFilterValue("age", filterValue, typeof(int));

// Duplicate resource
if (await _db.Users.AnyAsync(u => u.Email == email))
throw JsonApiErrors.AlreadyExists("users", "email", email);

// Validation error
if (string.IsNullOrWhiteSpace(request.Title))
throw JsonApiErrors.RequiredFieldMissing("title");
```

### Example Response

Using `JsonApiErrors.NotFound("books", 123)` produces:

```json
{
"errors": [{
"status": "404",
"code": "RESOURCE_NOT_FOUND",
"title": "Not Found",
"detail": "Resource 'books' with id '123' not found.",
"meta": {
"resourceType": "books",
"id": 123
}
}]
}
```

### Standard Error Codes

All factory methods use codes from `JsonApiErrorCodes`:

```csharp
public static class JsonApiErrorCodes
{
public const string ResourceNotFound = "RESOURCE_NOT_FOUND";
public const string ResourceAlreadyExists = "RESOURCE_ALREADY_EXISTS";
public const string InvalidFilterField = "INVALID_FILTER_FIELD";
public const string InvalidFilterValue = "INVALID_FILTER_VALUE";
public const string InvalidFilterOperator = "INVALID_FILTER_OPERATOR";
public const string FilterNotAllowed = "FILTER_NOT_ALLOWED";
public const string IncludeNotAllowed = "INCLUDE_NOT_ALLOWED";
public const string InvalidSortField = "INVALID_SORT_FIELD";
public const string QueryTooComplex = "QUERY_TOO_COMPLEX";
public const string ValidationFailed = "VALIDATION_FAILED";
public const string RequiredFieldMissing = "REQUIRED_FIELD_MISSING";
// ... and more
}
```

Use these codes in your client applications to handle specific error types programmatically.
14 changes: 7 additions & 7 deletions docs/docs/upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,15 +296,15 @@ public async Task<IActionResult> GetUsers()
**Release Date:** TBD

**Bug Fixes:**
- [ ] Fixed exception swallowing in InclusionMapper (now properly logged)
- [ ] Fixed unsafe string parsing in filter parser
- [ ] Fixed potential division by zero in pagination
- [ ] Added defensive checks for reflection method lookups
- [ ] Removed dead code (`AddIncludedResourcesRecursive`)
- [x] Fixed exception swallowing in InclusionMapper (dead code removed)
- [x] Fixed unsafe string parsing in filter parser
- [x] Fixed potential division by zero in pagination
- [x] Added defensive checks for reflection method lookups
- [x] Removed dead code (`AddIncludedResourcesRecursive`)

**New Features:**
- [ ] `JsonApiErrorCodes` - Standard error codes for consistent error identification
- [ ] `JsonApiErrors` - Factory methods for creating rich, well-structured errors
- [x] `JsonApiErrorCodes` - Standard error codes for consistent error identification
- [x] `JsonApiErrors` - Factory methods for creating rich, well-structured errors

**Usage:**
```csharp
Expand Down
Loading