diff --git a/CLAUDE.md b/CLAUDE.md index d7c81b7..74f0d6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ This is **Intility.JsonApiToolkit**, a .NET library that implements the JSON:API - Microsoft.AspNetCore.Mvc - Microsoft.EntityFrameworkCore - Microsoft.AspNetCore.JsonPatch + - Microsoft.Extensions.DependencyInjection.Abstractions - Intility.Logging.AspNetCore - **Test Framework**: xUnit with Moq for mocking - **Documentation**: DocFX for API documentation @@ -83,7 +84,7 @@ Documentation is built using DocFX and deployed to GitHub Pages. The documentati - **Convention-based mapping**: Properties are automatically mapped from C# PascalCase to JSON camelCase - **Query parameter parsing**: Standard JSON:API query syntax (`filter[field]=value`, `sort=field,-field2`, `page[number]=1&page[size]=10`, `include=relationship`) -- **Async-first**: Main controller method `JsonApiOkAsync()` is async and works with `IQueryable` +- **Async-first**: Main controller method `JsonApiQueryAsync()` is async and works with `IQueryable` - **Entity Framework integration**: Uses EF Core's `Include()` and query building capabilities - **Filter expressions**: Complex filtering with operators (eq, ne, gt, lt, contains, etc.), logical grouping, and enum support - **JSON column detection**: Collections and complex objects without ID properties are automatically mapped as JSON attributes instead of relationships (useful for EF Core owned entities stored as JSON columns) @@ -137,7 +138,7 @@ Tests are organized by component: ### Common Patterns - Controllers should inherit from `JsonApiController` -- Use `JsonApiOkAsync(queryable, "resourceType")` for collections with full query processing +- Use `JsonApiQueryAsync(queryable, "resourceType")` for collections with full query processing - Use `JsonApiOk(entity, "resourceType")` for already-loaded entities or collections - Entity types should have an `Id` property (auto-detected by `EntityMapper.GetIdProperty()`) - Use `QueryParameters queryParams = GetJsonApiQueryParameters()` to access parsed query parameters diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 660731f..aa486e4 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -132,7 +132,7 @@ protected IActionResult JsonApiOk( /// /// This is the recommended method for collection endpoints as it implements the complete JSON:API querying capabilities. /// - protected async Task JsonApiOkAsync( + protected async Task JsonApiQueryAsync( IQueryable queryable, string resourceType ) diff --git a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs b/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs index 15413fd..a0ce994 100644 --- a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs +++ b/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs @@ -40,7 +40,7 @@ public static class QueryHelpers if (property != null) return property; - string pascalCase = StringExtensions.ToPascalCase(jsonPropertyName); + string pascalCase = jsonPropertyName.ToPascalCase(); property = entityType.GetProperty(pascalCase); return property @@ -57,7 +57,7 @@ public static class QueryHelpers /// The string value from the query parameter /// The target property type to convert to /// - /// /// The converted value, or trows an exception if conversion fails or is not supported + /// /// The converted value, or throws an exception if conversion fails or is not supported /// /// /// Handles common primitive types (int, long, decimal, bool, DateTime, Guid, Uri, TimeSpan, diff --git a/JsonApiToolkit/Extensions/StringExtensions.cs b/JsonApiToolkit/Extensions/StringExtensions.cs index ded0465..0f77dc2 100644 --- a/JsonApiToolkit/Extensions/StringExtensions.cs +++ b/JsonApiToolkit/Extensions/StringExtensions.cs @@ -34,7 +34,7 @@ public static string ToCamelCase(this string str) /// Used for converting JSON property names (camelCase) to C# property names (PascalCase). /// Returns the original string if it's null or empty. /// - public static string ToPascalCase(string str) + public static string ToPascalCase(this string str) { if (string.IsNullOrEmpty(str)) return str; diff --git a/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs b/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs index 4094f8e..4f202db 100644 --- a/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs +++ b/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs @@ -28,45 +28,65 @@ public class JsonApiExceptionFilter(ILogger logger) : IE /// public void OnException(ExceptionContext context) { - var (status, title) = context.Exception switch - { - JsonApiBadRequestException => (400, "Bad Request"), - JsonApiNotFoundException => (404, "Not Found"), - JsonApiConflictException => (409, "Conflict"), - JsonApiUnauthorizedException => (401, "Unauthorized"), - JsonApiForbiddenException => (403, "Forbidden"), - JsonApiTooManyRequestsException => (429, "Too Many Requests"), - _ => (500, "Internal Server Error"), - }; + int status; + string title; + JsonApiError error; - if (status == 500) - { - // Log full stack trace for unexpected errors - _logger.LogError(context.Exception, "An unhandled exception occurred"); - } - else + if (context.Exception is JsonApiException jsonApiException) { - // Log only the message for handled exceptions + // Handle structured JSON:API exceptions + status = jsonApiException.StatusCode; + title = GetTitleForStatusCode(status); + + error = new JsonApiError + { + Status = status.ToString(), + Title = title, + Detail = jsonApiException.Message, + Code = jsonApiException.Code, + Source = jsonApiException.ErrorSource, + Meta = jsonApiException.Meta, + }; + + // Log handled exceptions _logger.LogInformation( "Handled JSON:API exception: {Type} - {Message}", - context.Exception.GetType().Name, - context.Exception.Message + jsonApiException.GetType().Name, + jsonApiException.Message ); } - - var error = new JsonApiError + else { - Status = status.ToString(), - Title = title, - Detail = - status != 500 - ? context.Exception.Message - : "An error occurred while processing your request.", - }; + // Handle unexpected exceptions + status = 500; + title = "Internal Server Error"; + + error = new JsonApiError + { + Status = status.ToString(), + Title = title, + Detail = "An error occurred while processing your request.", + }; + + // Log full stack trace for unexpected errors + _logger.LogError(context.Exception, "An unhandled exception occurred"); + } var response = new JsonApiErrorResponse { Errors = [error] }; context.Result = new ObjectResult(response) { StatusCode = status }; context.ExceptionHandled = true; } + + private static string GetTitleForStatusCode(int statusCode) => + statusCode switch + { + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + 404 => "Not Found", + 409 => "Conflict", + 429 => "Too Many Requests", + _ => "Error", + }; } diff --git a/JsonApiToolkit/Helpers/MapIncludePathsToClrProperties.cs b/JsonApiToolkit/Helpers/EfIncludePathHelper.cs similarity index 100% rename from JsonApiToolkit/Helpers/MapIncludePathsToClrProperties.cs rename to JsonApiToolkit/Helpers/EfIncludePathHelper.cs diff --git a/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs b/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs index 33d5b55..9d3c8c8 100644 --- a/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs +++ b/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs @@ -1,34 +1,236 @@ -// Supress warnings for this file -#pragma warning disable RCS1194 - namespace JsonApiToolkit.Models.Errors; +/// +/// Base class for all JSON:API exceptions that provides comprehensive error information. +/// +/// +/// This base class supports the full JSON:API error object specification, allowing +/// for detailed error reporting with structured metadata, source information, and error codes. +/// +public abstract class JsonApiException : Exception +{ + /// + /// HTTP status code for this error. + /// + public int StatusCode { get; } + + /// + /// Application-specific error code for categorizing the error. + /// + public string? Code { get; } + + /// + /// Source information indicating where the error occurred. + /// + public ErrorSource? ErrorSource { get; } + + /// + /// Additional metadata about the error. + /// + public Dictionary? Meta { get; } + + /// + /// Initializes a new instance of the JsonApiException class. + /// + /// HTTP status code + /// Error message + /// Application-specific error code + /// Source information + /// Additional metadata + /// Inner exception + protected JsonApiException( + int statusCode, + string message, + string? code = null, + ErrorSource? errorSource = null, + Dictionary? meta = null, + Exception? innerException = null + ) + : base(message, innerException) + { + StatusCode = statusCode; + Code = code; + ErrorSource = errorSource; + Meta = meta; + } +} + /// /// Exception representing a 400 Bad Request error. /// -public class JsonApiBadRequestException(string message) : Exception(message) { } +public class JsonApiBadRequestException : JsonApiException +{ + /// + /// Initializes a new instance of the JsonApiBadRequestException class. + /// + /// Error message + public JsonApiBadRequestException(string message) + : base(400, message) { } + + /// + /// Initializes a new instance of the JsonApiBadRequestException class with detailed error information. + /// + /// Error message + /// Application-specific error code + /// Source information + /// Additional metadata + /// Inner exception + public JsonApiBadRequestException( + string message, + string? code = null, + ErrorSource? errorSource = null, + Dictionary? meta = null, + Exception? innerException = null + ) + : base(400, message, code, errorSource, meta, innerException) { } +} /// /// Exception representing a 404 Not Found error. /// -public class JsonApiNotFoundException(string message) : Exception(message) { } +public class JsonApiNotFoundException : JsonApiException +{ + /// + /// Initializes a new instance of the JsonApiNotFoundException class. + /// + /// Error message + public JsonApiNotFoundException(string message) + : base(404, message) { } + + /// + /// Initializes a new instance of the JsonApiNotFoundException class with detailed error information. + /// + /// Error message + /// Application-specific error code + /// Source information + /// Additional metadata + /// Inner exception + public JsonApiNotFoundException( + string message, + string? code = null, + ErrorSource? errorSource = null, + Dictionary? meta = null, + Exception? innerException = null + ) + : base(404, message, code, errorSource, meta, innerException) { } +} /// /// Exception representing a 409 Conflict error. /// -public class JsonApiConflictException(string message) : Exception(message) { } +public class JsonApiConflictException : JsonApiException +{ + /// + /// Initializes a new instance of the JsonApiConflictException class. + /// + /// Error message + public JsonApiConflictException(string message) + : base(409, message) { } + + /// + /// Initializes a new instance of the JsonApiConflictException class with detailed error information. + /// + /// Error message + /// Application-specific error code + /// Source information + /// Additional metadata + /// Inner exception + public JsonApiConflictException( + string message, + string? code = null, + ErrorSource? errorSource = null, + Dictionary? meta = null, + Exception? innerException = null + ) + : base(409, message, code, errorSource, meta, innerException) { } +} /// /// Exception representing a 401 Unauthorized error. /// -public class JsonApiUnauthorizedException(string message) : Exception(message) { } +public class JsonApiUnauthorizedException : JsonApiException +{ + /// + /// Initializes a new instance of the JsonApiUnauthorizedException class. + /// + /// Error message + public JsonApiUnauthorizedException(string message) + : base(401, message) { } + + /// + /// Initializes a new instance of the JsonApiUnauthorizedException class with detailed error information. + /// + /// Error message + /// Application-specific error code + /// Source information + /// Additional metadata + /// Inner exception + public JsonApiUnauthorizedException( + string message, + string? code = null, + ErrorSource? errorSource = null, + Dictionary? meta = null, + Exception? innerException = null + ) + : base(401, message, code, errorSource, meta, innerException) { } +} /// /// Exception representing a 403 Forbidden error. /// -public class JsonApiForbiddenException(string message) : Exception(message) { } +public class JsonApiForbiddenException : JsonApiException +{ + /// + /// Initializes a new instance of the JsonApiForbiddenException class. + /// + /// Error message + public JsonApiForbiddenException(string message) + : base(403, message) { } + + /// + /// Initializes a new instance of the JsonApiForbiddenException class with detailed error information. + /// + /// Error message + /// Application-specific error code + /// Source information + /// Additional metadata + /// Inner exception + public JsonApiForbiddenException( + string message, + string? code = null, + ErrorSource? errorSource = null, + Dictionary? meta = null, + Exception? innerException = null + ) + : base(403, message, code, errorSource, meta, innerException) { } +} /// /// Exception representing a 429 Too Many Requests error. /// -public class JsonApiTooManyRequestsException(string message) : Exception(message) { } +public class JsonApiTooManyRequestsException : JsonApiException +{ + /// + /// Initializes a new instance of the JsonApiTooManyRequestsException class. + /// + /// Error message + public JsonApiTooManyRequestsException(string message) + : base(429, message) { } + + /// + /// Initializes a new instance of the JsonApiTooManyRequestsException class with detailed error information. + /// + /// Error message + /// Application-specific error code + /// Source information + /// Additional metadata + /// Inner exception + public JsonApiTooManyRequestsException( + string message, + string? code = null, + ErrorSource? errorSource = null, + Dictionary? meta = null, + Exception? innerException = null + ) + : base(429, message, code, errorSource, meta, innerException) { } +} diff --git a/README.md b/README.md index 8f38417..8304c22 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ public async Task GetBooks() { var query = _dbContext.Books.AsQueryable(); - // JsonApiOkAsync applies filtering, sorting, includes, and pagination automatically. - return await JsonApiOkAsync(query, "book"); + // JsonApiQueryAsync applies filtering, sorting, includes, and pagination automatically. + return await JsonApiQueryAsync(query, "book"); } ``` diff --git a/docs/docs/api-controller-examples.md b/docs/docs/api-controller-examples.md index 8727e86..5b397c6 100644 --- a/docs/docs/api-controller-examples.md +++ b/docs/docs/api-controller-examples.md @@ -31,7 +31,7 @@ public class BooksController : JsonApiController public async Task GetBooks() { IQueryable books = _context.Books.AsQueryable(); - return await JsonApiOkAsync(books, "book"); + return await JsonApiQueryAsync(books, "book"); } ``` @@ -123,13 +123,13 @@ public async Task DeleteBook(int id) [HttpGet("search")] public async Task SearchBooks() { - // The filtering is handled automatically by JsonApiOkAsync + // The filtering is handled automatically by JsonApiQueryAsync IQueryable books = _context.Books.AsQueryable(); // But you can also apply custom logic before the standard processing books = books.Where(b => b.IsPublished); // Custom business logic - return await JsonApiOkAsync(books, "book"); + return await JsonApiQueryAsync(books, "book"); } ``` @@ -216,21 +216,21 @@ Control which relationships can be included to prevent exposure of sensitive dat [AllowedIncludes("profile", "posts.*", "settings")] public async Task GetUsers() { - return await JsonApiOkAsync(_context.Users, "user"); + return await JsonApiQueryAsync(_context.Users, "user"); } [HttpGet("sensitive-data")] [AllowedIncludes("publicInfo")] public async Task GetSensitiveData() { - return await JsonApiOkAsync(_context.SensitiveEntities, "sensitiveEntity"); + return await JsonApiQueryAsync(_context.SensitiveEntities, "sensitiveEntity"); } [HttpGet("public-only")] [AllowedIncludes()] // No includes allowed public async Task GetPublicOnly() { - return await JsonApiOkAsync(_context.PublicData, "publicData"); + return await JsonApiQueryAsync(_context.PublicData, "publicData"); } ``` @@ -243,7 +243,7 @@ public async Task GetPublicOnly() ## Pro Tips 1. **Always use exception types** instead of returning error ActionResults - the filter handles conversion automatically -2. **Use JsonApiOkAsync for collections** when you want automatic query processing (filtering, sorting, pagination) +2. **Use JsonApiQueryAsync for collections** when you want automatic query processing (filtering, sorting, pagination) 3. **Use JsonApiOk for already-loaded data** when you've fetched and processed entities yourself 4. **Use AllowedIncludes** - restrict relationship access for security and performance diff --git a/docs/docs/enhanced-error-handling.md b/docs/docs/enhanced-error-handling.md index 309887e..9217c49 100644 --- a/docs/docs/enhanced-error-handling.md +++ b/docs/docs/enhanced-error-handling.md @@ -69,5 +69,73 @@ For the same error, your log will show: *No stack trace is logged for handled errors like 400, 404, or 409.* +--- + +## Advanced Error Information + +All JSON:API exceptions support additional structured error information following the JSON:API specification: + +### Enhanced Constructor + +```csharp +public JsonApiBadRequestException( + string message, + string? code = null, + ErrorSource? errorSource = null, + Dictionary? meta = null, + Exception? innerException = null +) +``` + +### Usage with Additional Error Details + +```csharp +// Basic usage (existing code continues to work) +throw new JsonApiBadRequestException("Invalid email format"); + +// Enhanced usage with error codes and source information +throw new JsonApiBadRequestException( + message: "Invalid email format", + code: "INVALID_EMAIL", + errorSource: new ErrorSource { Pointer = "/data/attributes/email" }, + meta: new Dictionary + { + ["expectedFormat"] = "user@domain.com", + ["provided"] = request.Email + } +); + +// For query parameter errors +throw new JsonApiBadRequestException( + message: "Invalid sort field 'invalidField'", + code: "INVALID_SORT", + errorSource: new ErrorSource { Parameter = "sort" } +); +``` + +### Enhanced Error Response + +The enhanced exception produces richer error responses: + +```json +{ + "errors": [ + { + "status": "400", + "title": "Bad Request", + "detail": "Invalid email format", + "code": "INVALID_EMAIL", + "source": { + "pointer": "/data/attributes/email" + }, + "meta": { + "expectedFormat": "user@domain.com", + "provided": "invalid-email" + } + } + ] +} +``` + > [!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. diff --git a/docs/docs/security.md b/docs/docs/security.md index f3cfcf2..4af90d2 100644 --- a/docs/docs/security.md +++ b/docs/docs/security.md @@ -15,7 +15,7 @@ Apply the attribute to controller actions: [AllowedIncludes("profile", "posts")] public async Task GetUsers() { - return await JsonApiOkAsync(_context.Users, "user"); + return await JsonApiQueryAsync(_context.Users, "user"); } ``` @@ -28,7 +28,7 @@ Use wildcards to allow nested includes at specific levels: [AllowedIncludes("author.*", "comments")] public async Task GetPosts() { - return await JsonApiOkAsync(_context.Posts, "post"); + return await JsonApiQueryAsync(_context.Posts, "post"); } ```