diff --git a/CLAUDE.md b/CLAUDE.md index 3fcbd80..a03f4fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,11 @@ dotnet csharpier . --check ### Documentation Documentation is built using DocFX and deployed to GitHub Pages. The documentation source is in `/docs/` and the built site goes to `/docs/_site/`. +### Debugging +Enable detailed logging for query processing and troubleshooting: +- Set `"JsonApiToolkit": "Debug"` in appsettings.json +- See `/docs/docs/debugging.md` for comprehensive debugging guide + ## Architecture ### Core Components @@ -66,7 +71,12 @@ Documentation is built using DocFX and deployed to GitHub Pages. The documentati 3. **Query Processing Pipeline** (`Extensions/Querying/`) - `JsonApiQueryParser`: Parses JSON:API query parameters - `FilterExpressionBuilder`: Builds LINQ expressions from filters - - `QueryableExtensions`: Extension methods for applying filters, sorting, pagination + - `FilterHandler`: Applies filter expressions to queryables + - `SortingHandler`: Applies sorting to queryables + - `PaginationHandler`: Applies pagination to queryables + - `QueryHelpers`: Helper methods for property name mapping and type conversion + - `IncludeFilterParser`: Separates filters targeting included resources from main entity filters + - `FilteredIncludeBuilder`: Applies filtered includes using EF Core's filtered Include functionality 4. **Models** (`Models/`) - Document structures: `JsonApiDocument`, `JsonApiCollectionDocument` @@ -79,11 +89,15 @@ Documentation is built using DocFX and deployed to GitHub Pages. The documentati 6. **Validation** (`Validation/`) - `IncludePatternValidator`: Validates include patterns with wildcard support + - `IncludeValidator`: Validates include paths against entity relationships + - `IncludePattern`: Model representing validated include patterns -7. **Include Filtering** (`Extensions/Querying/`) - - `IncludeFilterParser`: Separates filters targeting included resources from main entity filters - - `FilteredIncludeBuilder`: Applies filtered includes using EF Core's filtered Include functionality - - Enables filtering on relationships (e.g., `filter[author.name]=John` with `include=author`) +7. **Services** (`Services/`) + - `IJsonApiQueryParser`: Interface for query parameter parsing + - `JsonApiQueryParserService`: Service implementation for parsing JSON:API query strings + +8. **Helpers** (`Helpers/`) + - `EfIncludePathHelper`: Utilities for building EF Core Include expressions ### Key Patterns @@ -91,7 +105,7 @@ Documentation is built using DocFX and deployed to GitHub Pages. The documentati - **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 `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, enum support, and filtering on included resources +- **Filter expressions**: Complex filtering with operators (eq, ne, gt, lt, contains, etc.), logical grouping, enum support, and filtering on included resources via dot notation (e.g., `filter[author.name]=John` with `include=author`) - **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) - **Pagination safety**: Invalid page numbers are automatically clamped to valid ranges (page 1 for negative/zero, last page for overflow) - **Include whitelisting**: Use `AllowedIncludesAttribute` on controller actions to restrict which relationships can be included, preventing unauthorized data exposure @@ -148,6 +162,7 @@ Tests are organized by component: - Entity types should have an `Id` property (auto-detected by `EntityMapper.GetIdProperty()`) - Use `QueryParameters queryParams = GetJsonApiQueryParameters()` to access parsed query parameters - For manual mapping, use `JsonApiMapper.ToDocument()` or `ToCollectionDocument()` +- Enable debug logging with `"JsonApiToolkit": "Debug"` in appsettings.json for detailed query processing insights ## Package Publication diff --git a/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs b/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs index 414cced..b79f5c6 100644 --- a/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs +++ b/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs @@ -2,9 +2,13 @@ using JsonApiToolkit.Models.Documents; using JsonApiToolkit.Models.Errors; using JsonApiToolkit.Models.Resources; +using JsonApiToolkit.Services; using JsonApiToolkit.Tests.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; namespace JsonApiToolkit.Tests.Controllers; @@ -42,9 +46,25 @@ public class JsonApiControllerTests public JsonApiControllerTests() { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddScoped(provider => + { + var mock = new Mock(); + mock.Setup(x => x.Parse(It.IsAny())) + .Returns( + new JsonApiToolkit.Models.Querying.QueryParameters + { + Include = new List(), + } + ); + return mock.Object; + }); + + var serviceProvider = services.BuildServiceProvider(); _controller = new TestJsonApiController(); - var httpContext = new DefaultHttpContext(); + var httpContext = new DefaultHttpContext { RequestServices = serviceProvider }; httpContext.Request.Scheme = "https"; httpContext.Request.Host = new HostString("api.example.com"); httpContext.Request.Path = "/test-entities"; diff --git a/JsonApiToolkit.Tests/Extensions/IncludesWithPaginationTests.cs b/JsonApiToolkit.Tests/Extensions/IncludesWithPaginationTests.cs new file mode 100644 index 0000000..6366734 --- /dev/null +++ b/JsonApiToolkit.Tests/Extensions/IncludesWithPaginationTests.cs @@ -0,0 +1,258 @@ +using JsonApiToolkit.Extensions; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiToolkit.Tests.Extensions; + +/// +/// Tests for EF Core Include behavior with pagination to catch split query issues. +/// +public class IncludesWithPaginationTests +{ + private class TestEntity + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public List Related { get; set; } = new(); + } + + private class RelatedEntity + { + public int Id { get; set; } + public string Value { get; set; } = string.Empty; + public int TestEntityId { get; set; } + public TestEntity? TestEntity { get; set; } + } + + private class TestDbContext : DbContext + { + public DbSet TestEntities { get; set; } = null!; + public DbSet RelatedEntities { get; set; } = null!; + + public TestDbContext(DbContextOptions options) + : base(options) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasMany(e => e.Related).WithOne(r => r.TestEntity); + } + } + + private static TestDbContext CreateInMemoryContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + var context = new TestDbContext(options); + + // Create a large dataset similar to the user's scenario + for (int i = 1; i <= 100; i++) + { + var entity = new TestEntity + { + Id = i, + Name = $"Entity {i}", + Related = new(), + }; + + // Add 2-5 related entities per main entity + var relatedCount = (i % 4) + 2; + for (int j = 1; j <= relatedCount; j++) + { + entity.Related.Add( + new RelatedEntity + { + Id = i * 100 + j, + Value = $"Related {i}-{j}", + TestEntityId = i, + } + ); + } + + context.TestEntities.Add(entity); + } + + context.SaveChanges(); + return context; + } + + [Theory] + [InlineData(1)] + [InlineData(5)] + [InlineData(9)] + [InlineData(10)] + [InlineData(20)] + [InlineData(50)] + public async Task ApplyIncludesSingleQuery_WithPagination_LoadsAllRelatedEntitiesAsync( + int pageSize + ) + { + // Arrange + using var context = CreateInMemoryContext(); + + // Act - Apply includes with AsSingleQuery and pagination + var query = context + .TestEntities.OrderBy(e => e.Id) + .ApplyIncludesSingleQuery(new List { "Related" }) + .Skip(0) + .Take(pageSize); + + var results = await query.ToListAsync(); + + // Assert + Assert.NotEmpty(results); + Assert.Equal(pageSize, results.Count); + + // Verify that ALL entities have their related entities loaded + foreach (var entity in results) + { + Assert.NotNull(entity.Related); + Assert.NotEmpty(entity.Related); + Assert.InRange(entity.Related.Count, 2, 5); // We created 2-5 related entities per entity + } + } + + [Theory] + [InlineData(1)] + [InlineData(5)] + [InlineData(9)] + public async Task ApplyIncludes_WithPagination_MayFailOnSmallPageSizesAsync(int pageSize) + { + // Arrange + using var context = CreateInMemoryContext(); + + // Act - Regular includes (may exhibit split query issues) + var query = context + .TestEntities.OrderBy(e => e.Id) + .ApplyIncludes(new List { "Related" }) + .Skip(0) + .Take(pageSize); + + var results = await query.ToListAsync(); + + // Assert - Just verify we got results, may or may not have includes loaded + Assert.NotEmpty(results); + Assert.Equal(pageSize, results.Count); + + // Note: This test documents the issue - with regular ApplyIncludes, + // related entities may not be loaded consistently across page sizes + } + + [Fact] + public async Task ApplyIncludesSingleQuery_WithLargeDatasetAndPaginationAtFirstPage_LoadsCorrectEntitiesAsync() + { + // Arrange + using var context = CreateInMemoryContext(); + + // Act - Get first page with page size 1 (the failing scenario from user's issue) + var results = await context + .TestEntities.OrderBy(e => e.Id) + .ApplyIncludesSingleQuery(new List { "Related" }) + .Skip(0) + .Take(1) + .ToListAsync(); + + // Assert + var entity = Assert.Single(results); + Assert.Equal(1, entity.Id); + Assert.NotEmpty(entity.Related); + Assert.All(entity.Related, r => Assert.Equal(1, r.TestEntityId)); + } + + [Fact] + public async Task ApplyIncludesSingleQuery_WithLargeDatasetAndPaginationAtMiddlePage_LoadsCorrectEntitiesAsync() + { + // Arrange + using var context = CreateInMemoryContext(); + + // Act - Get middle page (page 50) with page size 1 + var results = await context + .TestEntities.OrderBy(e => e.Id) + .ApplyIncludesSingleQuery(new List { "Related" }) + .Skip(49) + .Take(1) + .ToListAsync(); + + // Assert + var entity = Assert.Single(results); + Assert.Equal(50, entity.Id); + Assert.NotEmpty(entity.Related); + Assert.All(entity.Related, r => Assert.Equal(50, r.TestEntityId)); + } + + [Fact] + public async Task ApplyIncludesSingleQuery_WithMultiplePagesSmallPageSize_EachPageHasCorrectIncludesAsync() + { + // Arrange + using var context = CreateInMemoryContext(); + const int pageSize = 3; + const int totalPages = 5; + + // Act & Assert - Verify each page has correct includes + for (int page = 0; page < totalPages; page++) + { + var results = await context + .TestEntities.OrderBy(e => e.Id) + .ApplyIncludesSingleQuery(new List { "Related" }) + .Skip(page * pageSize) + .Take(pageSize) + .ToListAsync(); + + Assert.Equal(pageSize, results.Count); + + foreach (var entity in results) + { + Assert.NotEmpty(entity.Related); + // Verify the related entities belong to this entity + Assert.All(entity.Related, r => Assert.Equal(entity.Id, r.TestEntityId)); + } + } + } + + [Fact] + public async Task ApplyIncludesSingleQuery_WithNullIncludePaths_ReturnsQueryUnmodifiedAsync() + { + // Arrange + using var context = CreateInMemoryContext(); + + // Act + var query = context.TestEntities.ApplyIncludesSingleQuery(null); + var results = await query.ToListAsync(); + + // Assert + Assert.NotEmpty(results); + } + + [Fact] + public async Task ApplyIncludesSingleQuery_WithEmptyIncludePaths_ReturnsQueryUnmodifiedAsync() + { + // Arrange + using var context = CreateInMemoryContext(); + + // Act + var query = context.TestEntities.ApplyIncludesSingleQuery(new List()); + var results = await query.ToListAsync(); + + // Assert + Assert.NotEmpty(results); + } + + [Fact] + public async Task ApplyIncludesSingleQuery_WithoutPagination_WorksCorrectlyAsync() + { + // Arrange + using var context = CreateInMemoryContext(); + + // Act + var results = await context + .TestEntities.OrderBy(e => e.Id) + .ApplyIncludesSingleQuery(new List { "Related" }) + .Take(10) + .ToListAsync(); + + // Assert + Assert.Equal(10, results.Count); + Assert.All(results, e => Assert.NotEmpty(e.Related)); + } +} diff --git a/JsonApiToolkit.Tests/Extensions/QueryHelpersTests.cs b/JsonApiToolkit.Tests/Extensions/QueryHelpersTests.cs index 7725bf2..3d28733 100644 --- a/JsonApiToolkit.Tests/Extensions/QueryHelpersTests.cs +++ b/JsonApiToolkit.Tests/Extensions/QueryHelpersTests.cs @@ -76,7 +76,7 @@ public void ConvertToPropertyType_WithInvalidInt_ThrowsFormatException() ); Assert.Contains( - "Failed to convert 'not-a-number' to type 'System.Int32'", + "Failed to convert filter value 'not-a-number' to type 'System.Int32'", exception.Message ); } diff --git a/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs b/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs index ee70064..4a44c19 100644 --- a/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs +++ b/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs @@ -4,6 +4,7 @@ using JsonApiToolkit.Controllers; using JsonApiToolkit.Extensions; using JsonApiToolkit.Models.Errors; +using JsonApiToolkit.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; @@ -11,6 +12,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Tests.Integration; diff --git a/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs b/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs index 05429d6..5aa9f2d 100644 --- a/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs +++ b/JsonApiToolkit/Attributes/AllowedIncludesAttribute.cs @@ -9,12 +9,9 @@ namespace JsonApiToolkit.Attributes; /// -/// Action filter attribute that restricts which relationships can be included in JSON:API responses. +/// Restricts which relationships can be included in responses. +/// Returns 403 Forbidden if requested includes don't match the whitelist. /// -/// -/// This attribute validates the 'include' query parameter against a whitelist of allowed includes. -/// If a client requests an include that is not in the whitelist, a 403 Forbidden error is returned. -/// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class AllowedIncludesAttribute : ActionFilterAttribute { @@ -22,14 +19,14 @@ public class AllowedIncludesAttribute : ActionFilterAttribute private readonly Dictionary _compiledPatterns; /// - /// Gets the list of allowed include patterns. + /// Gets the allowed include patterns. /// public string[] AllowedIncludes => _allowedIncludes; /// - /// Initializes a new instance of the class. + /// Initializes a new instance with the specified allowed include patterns. /// - /// The list of allowed include patterns. If empty, no includes are allowed. + /// Include patterns to allow (supports wildcards). public AllowedIncludesAttribute(params string[] allowedIncludes) { _allowedIncludes = allowedIncludes ?? []; @@ -44,9 +41,8 @@ public AllowedIncludesAttribute(params string[] allowedIncludes) } /// - /// Validates the include query parameters before the action executes. + /// Validates requested includes against the allowed patterns. /// - /// The action executing context. public override void OnActionExecuting(ActionExecutingContext context) { // Skip validation for JsonApiCreated methods diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 8fb9ae3..5139f83 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -8,62 +8,49 @@ using JsonApiToolkit.Models.Metadata; using JsonApiToolkit.Models.Querying; using JsonApiToolkit.Models.Resources; -using JsonApiToolkit.Parsing; +using JsonApiToolkit.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Controllers; /// -/// Base controller class that implements JSON:API specification-compliant responses and request handling. -/// Provides standardized methods for returning JSON:API document structures with proper content negotiation. +/// Base controller for JSON:API compliant responses. +/// Handles content negotiation and applies JsonApiExceptionFilter automatically. /// -/// -/// Automatically configures content type handling for "application/vnd.api+json" and applies the JsonApiExceptionFilter. -/// Use this as the base class for all controllers that need to return JSON:API compliant responses. -/// [Produces("application/vnd.api+json")] [Consumes("application/vnd.api+json")] [ServiceFilter(typeof(JsonApiExceptionFilter))] public abstract class JsonApiController : ControllerBase { + private ILogger? _logger; + private IJsonApiQueryParser? _queryParser; + + /// + /// Gets the logger instance. + /// + protected ILogger Logger => + _logger ??= HttpContext.RequestServices.GetRequiredService>(); + /// - /// Extracts and parses JSON:API query parameters from the current HTTP request. + /// Gets the query parser service. + /// + protected IJsonApiQueryParser QueryParser => + _queryParser ??= HttpContext.RequestServices.GetRequiredService(); + + /// + /// Parses JSON:API query parameters (filter, sort, page, include). /// - /// A QueryParameters object containing parsed filter, sort, pagination, and include parameters. - /// - /// Handles standard JSON:API query parameter formats including: - /// - /// - /// filter[fieldName]=value - /// - /// - /// sort=field or sort=-descendingField - /// - /// - /// page[number]=1&page[size]=10 - /// - /// - /// include=relationship1,relationship2 - /// - /// - /// protected QueryParameters GetJsonApiQueryParameters() { - return JsonApiQueryParser.Parse(Request); + return QueryParser.Parse(Request); } /// - /// Creates a 200 OK response containing a single resource as a JSON:API document. + /// Returns 200 OK with a single resource as JSON:API document. /// - /// The entity type being returned - /// The already-loaded entity to serialize into the response - /// The JSON:API resource type identifier (typically the entity name in camelCase) - /// An IActionResult with a properly formatted JSON:API document - /// - /// Serializes the provided entity into JSON:API format. Any relationships that are already loaded - /// on the entity will be included in the response. - /// protected IActionResult JsonApiOk(T entity, string resourceType) where T : class { @@ -77,23 +64,15 @@ protected IActionResult JsonApiOk(T entity, string resourceType) entity, resourceType, baseUrl, - mappedIncludes + mappedIncludes, + Logger ); return Ok(document); } /// - /// Creates a 200 OK response containing a collection of resources as a JSON:API document. + /// Returns 200 OK with a collection of resources as JSON:API document. /// - /// The entity type of the collection items - /// The already-loaded collection of entities to serialize into the response - /// The JSON:API resource type identifier (typically the entity name in camelCase) - /// Optional pagination metadata to include in the response - /// An IActionResult with a properly formatted JSON:API collection document - /// - /// Serializes the provided collection into JSON:API format. Any relationships that are already loaded - /// on the entities will be included in the response. When pagination metadata is provided, adds pagination links. - /// protected IActionResult JsonApiOk( IEnumerable entities, string resourceType, @@ -112,36 +91,15 @@ protected IActionResult JsonApiOk( resourceType, baseUrl, paginationMeta, - mappedIncludes + mappedIncludes, + Logger ); return Ok(document); } /// - /// Creates a 200 OK response for a queryable collection with full JSON:API query parameter support. + /// Returns 200 OK for queryable with full JSON:API query support (filter, sort, page, include). /// - /// The entity type of the queryable items - /// The queryable collection to apply filters, sorting, and pagination to - /// The JSON:API resource type identifier (typically the entity name in camelCase) - /// An IActionResult with a properly formatted JSON:API collection document with query parameters applied - /// - /// This method provides comprehensive support for JSON:API query parameters: - /// - /// - /// Automatically applies any filter parameters to the queryable - /// - /// - /// Applies sorting based on sort parameters - /// - /// - /// Handles pagination and generates pagination metadata and links - /// - /// - /// Processes includes to add related resources - /// - /// - /// This is the recommended method for collection endpoints as it implements the complete JSON:API querying capabilities. - /// protected async Task JsonApiQueryAsync( IQueryable queryable, string resourceType @@ -149,12 +107,39 @@ string resourceType where T : class { QueryParameters parameters = GetJsonApiQueryParameters(); + + Logger.LogDebug( + "Query for {EntityType}: Filters={FilterCount}, Sorts={SortCount}, Includes={IncludeCount}, Pagination={HasPagination}", + typeof(T).Name, + parameters.Filter?.Filters?.Count ?? 0, + parameters.Sort?.Count ?? 0, + parameters.Include?.Count ?? 0, + parameters.Pagination != null + ); + + if (parameters.Filter?.Filters?.Count > 20) + { + Logger.LogInformation( + "Complex query with {Count} filters on {EntityType}", + parameters.Filter.Filters.Count, + typeof(T).Name + ); + } + string baseUrl = GetFullRequestUrl(); var mappedIncludes = EfIncludePathHelper.MapIncludePathsToClrProperties( parameters.Include ); - // Separate include filters from main filters + if (parameters.Include?.Count > 0 && mappedIncludes.Count == 0) + { + Logger.LogWarning( + "No valid includes for {EntityType}. Requested: {Includes}", + typeof(T).Name, + string.Join(", ", parameters.Include) + ); + } + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( parameters.Filter, parameters.Include @@ -162,32 +147,53 @@ string resourceType IQueryable filteredQuery = queryable; - // Apply main entity filters first if (mainFilters != null) - filteredQuery = filteredQuery.ApplyFilters(mainFilters); + filteredQuery = filteredQuery.ApplyFilters(mainFilters, Logger); - // Different ordering strategy based on whether we have filtered includes if (includeFilters.Count > 0) { - // For filtered includes: Apply sorting first to prevent EF Core query translation issues - if (parameters.Sort?.Count > 0) - filteredQuery = filteredQuery.ApplySorting(parameters.Sort); - - // Then apply filtered includes + Logger.LogDebug( + "Applying {FilterCount} filtered includes for {EntityType}", + includeFilters.Count, + typeof(T).Name + ); filteredQuery = filteredQuery.ApplyFilteredIncludes(mappedIncludes, includeFilters); } - else + else if (mappedIncludes.Count > 0) { - // For regular includes: Apply includes first for better compatibility - filteredQuery = filteredQuery.ApplyIncludes(mappedIncludes); + // Use single query with pagination to avoid EF Core split query issues + filteredQuery = parameters.Pagination != null + ? filteredQuery.ApplyIncludesSingleQuery(mappedIncludes) + : filteredQuery.ApplyIncludes(mappedIncludes); - // Then apply sorting after includes - if (parameters.Sort?.Count > 0) - filteredQuery = filteredQuery.ApplySorting(parameters.Sort); + Logger.LogDebug( + "Applied {IncludeCount} includes for {EntityType} using {QueryType}", + mappedIncludes.Count, + typeof(T).Name, + parameters.Pagination != null ? "SingleQuery" : "SplitQuery" + ); } + if (parameters.Sort?.Count > 0) + filteredQuery = filteredQuery.ApplySorting(parameters.Sort, Logger); + int totalCount = await filteredQuery.CountAsync().ConfigureAwait(false); + if (totalCount == 0 && parameters.Filter?.Filters?.Count > 0) + { + Logger.LogInformation( + "Query returned 0 results for {EntityType}", + typeof(T).Name + ); + } + else if (totalCount > 1000 && parameters.Pagination == null) + { + Logger.LogWarning( + "Large result set ({TotalCount}) without pagination. Consider adding pagination to improve performance", + totalCount + ); + } + if (parameters.Pagination != null) filteredQuery = filteredQuery.ApplyPagination(parameters.Pagination); @@ -203,6 +209,13 @@ string resourceType }; } + Logger.LogDebug( + "Executing query for {EntityType}: TotalCount={TotalCount}, Returning={ReturnCount}", + typeof(T).Name, + totalCount, + parameters.Pagination?.Size ?? totalCount + ); + List results = await filteredQuery.ToListAsync().ConfigureAwait(false); JsonApiCollectionDocument document = JsonApiMapper.ToCollectionDocument( @@ -210,25 +223,16 @@ string resourceType resourceType, baseUrl, paginationMeta, - mappedIncludes + mappedIncludes, + Logger ); return Ok(document); } /// - /// Creates a 201 Created response containing a newly created resource as a JSON:API document. + /// Returns 201 Created with new resource and Location header. /// - /// The entity type being returned - /// The newly created entity - /// The JSON:API resource type identifier (typically the entity name in camelCase) - /// The ID of the newly created resource - /// An IActionResult with Status201Created and a properly formatted JSON:API document - /// - /// Sets the Location header to the resource's URL and includes the resource in the response body. - /// Serializes the provided entity into JSON:API format. Any relationships that are already loaded - /// on the entity will be included in the response. - /// protected IActionResult JsonApiCreated(T entity, string resourceType, string id) where T : class { @@ -243,31 +247,20 @@ protected IActionResult JsonApiCreated(T entity, string resourceType, string entity, resourceType, selfUrl, - mappedIncludes + mappedIncludes, + Logger ); return Created(selfUrl, document); } /// - /// Creates a 204 No Content response for successful operations that don't return data. + /// Returns 204 No Content (for DELETE/PUT operations). /// - /// An IActionResult with Status204NoContent and an empty response body - /// - /// Use this method for successful DELETE operations or updates that don't return the modified resource. - /// - protected IActionResult JsonApiNoContent() - { - return NoContent(); - } + protected IActionResult JsonApiNoContent() => NoContent(); /// - /// Creates a 404 Not Found response with a JSON:API compliant error object. + /// Returns 404 Not Found with JSON:API error. /// - /// Custom error message explaining what resource was not found - /// An IActionResult with Status404NotFound and a properly formatted JSON:API error document - /// - /// Use this method when a requested resource doesn't exist to provide a consistent error response format. - /// protected IActionResult JsonApiNotFound(string detail = "Resource not found") { var error = new JsonApiError @@ -276,18 +269,12 @@ protected IActionResult JsonApiNotFound(string detail = "Resource not found") Title = "Not Found", Detail = detail, }; - return NotFound(new JsonApiErrorResponse { Errors = [error] }); } /// - /// Creates a 400 Bad Request response with a JSON:API compliant error object. + /// Returns 400 Bad Request with JSON:API error. /// - /// Specific error message explaining the validation or request problem - /// An IActionResult with Status400BadRequest and a properly formatted JSON:API error document - /// - /// Use this method for validation errors, malformed requests, or other client errors. - /// protected IActionResult JsonApiBadRequest(string detail) { var error = new JsonApiError @@ -296,19 +283,12 @@ protected IActionResult JsonApiBadRequest(string detail) Title = "Bad Request", Detail = detail, }; - return BadRequest(new JsonApiErrorResponse { Errors = [error] }); } /// - /// Constructs the complete URL for the current request including scheme, host, path, and query string. + /// Gets full request URL for self/pagination links. /// - /// The full URL of the current request as a string - /// - /// Used internally to generate self links and pagination links in JSON:API responses. - /// - protected string GetFullRequestUrl() - { - return $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"; - } + protected string GetFullRequestUrl() => + $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"; } diff --git a/JsonApiToolkit/Extensions/QueryableExtensions.cs b/JsonApiToolkit/Extensions/QueryableExtensions.cs index 5558db0..ff093a6 100644 --- a/JsonApiToolkit/Extensions/QueryableExtensions.cs +++ b/JsonApiToolkit/Extensions/QueryableExtensions.cs @@ -6,35 +6,13 @@ namespace JsonApiToolkit.Extensions; /// -/// Provides extension methods for applying JSON:API query parameters to IQueryable data sources. +/// Extension methods for applying JSON:API query parameters to IQueryable. /// -/// -/// Consolidates the application of filtering, sorting, and pagination in a single convenient extension method. -/// public static class QueryableExtensions { /// - /// Applies all JSON:API query parameters to an IQueryable data source in the correct order. + /// Applies all JSON:API parameters: filters → sort (defaults to Id) → pagination. /// - /// The entity type of the queryable - /// The source IQueryable to apply parameters to - /// The complete set of JSON:API query parameters - /// A new IQueryable with all query parameters applied - /// - /// Applies parameters in the following order: - /// - /// - /// Filtering - narrows the result set based on field conditions - /// - /// - /// Sorting - orders the results (defaults to Id ascending if not specified) - /// - /// - /// Pagination - limits the number of results and supports paging - /// - /// - /// This is the recommended method for applying all JSON:API query parameters in a single operation. - /// public static IQueryable ApplyJsonApiParameters( this IQueryable query, QueryParameters parameters @@ -58,12 +36,8 @@ QueryParameters parameters } /// - /// Dynamically applies EF Core Include() calls for each include path (dot notation supported). + /// Applies EF Core Include() for each path (supports dot notation). /// - /// The entity type. - /// The source queryable. - /// A list of include paths (e.g. "todo", "todo.category"). - /// The queryable with all includes applied. public static IQueryable ApplyIncludes( this IQueryable query, List? includePaths @@ -79,4 +53,26 @@ public static IQueryable ApplyIncludes( } return query; } + + /// + /// Applies EF Core Include() using AsSingleQuery() to prevent split query issues with pagination. + /// Forces single query with JOINs instead of separate queries. + /// + public static IQueryable ApplyIncludesSingleQuery( + this IQueryable query, + List? includePaths + ) + where T : class + { + if (includePaths == null || includePaths.Count == 0) + return query; + + query = query.AsSingleQuery(); + + foreach (string path in includePaths) + { + query = query.Include(path.Trim()); + } + return query; + } } diff --git a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs index 2b9e194..c7ae788 100644 --- a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs +++ b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs @@ -2,34 +2,28 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiToolkit.Models.Querying.Filtering; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Extensions.Querying; /// -/// Builds LINQ expressions for applying JSON:API filter parameters to entity queries. +/// Builds LINQ expressions for JSON:API filter parameters. +/// Converts filter syntax to strongly-typed expressions for Entity Framework. /// -/// -/// This utility class converts JSON:API filter syntax into strongly-typed LINQ expressions -/// that can be used with Entity Framework or other LINQ providers. -/// public static class FilterExpressionBuilder { /// - /// Builds a composite filter expression from a group of filter conditions. + /// Builds a composite filter expression from filter conditions and nested groups. + /// Supports dot notation for nested properties (e.g., "user.address.city"). /// - /// The entity type being filtered - /// The filter group containing conditions and nested groups - /// The parameter expression representing the entity in the LINQ expression - /// - /// A composite Expression that can be used in a LINQ Where clause, or null if no valid filters exist - /// - /// - /// Handles both simple filters and complex nested filter groups with different logical operators. - /// For nested properties, supports dot notation (e.g., "user.address.city"). - /// + /// Filter group with conditions and nested groups + /// Parameter expression for the entity + /// Optional logger + /// Expression for LINQ Where clause, or null if no valid filters public static Expression? BuildFilterExpression( FilterGroup group, - ParameterExpression parameter + ParameterExpression parameter, + ILogger? logger = null ) { var expressions = new List(); @@ -39,7 +33,7 @@ ParameterExpression parameter Expression? expr; if (filter.Field.Contains('.')) { - expr = BuildSingleFilterExpression(parameter, filter); + expr = BuildSingleFilterExpression(parameter, filter, logger); } else { @@ -48,19 +42,34 @@ ParameterExpression parameter filter.Field ); if (property == null) + { + logger?.LogWarning( + "Property '{Field}' not found on {Type}, skipping filter", + filter.Field, + typeof(T).Name + ); continue; - expr = BuildSingleFilterExpression(parameter, filter); + } + expr = BuildSingleFilterExpression(parameter, filter, logger); } if (expr != null) + { expressions.Add(expr); + } + else + { + logger?.LogWarning("Failed to build filter for '{Field}'", filter.Field); + } } foreach (FilterGroup nestedGroup in group.Groups) { - Expression? nestedExpr = BuildFilterExpression(nestedGroup, parameter); + Expression? nestedExpr = BuildFilterExpression(nestedGroup, parameter, logger); if (nestedExpr != null) + { expressions.Add(nestedExpr); + } } if (expressions.Count == 0) @@ -78,72 +87,126 @@ ParameterExpression parameter Expression? combinedExpression = null; - foreach (Expression expr in expressions) + // For NOT: apply De Morgan's law + // NOT(A AND B) = NOT(A) OR NOT(B) + if (group.LogicalOperator == LogicalOperator.Not) { - if (combinedExpression == null) + foreach (Expression expr in expressions) { - combinedExpression = expr; + var notExpr = Expression.Not(expr); + combinedExpression = + combinedExpression == null + ? notExpr + : Expression.OrElse(combinedExpression, notExpr); } - else + } + else + { + foreach (Expression expr in expressions) { - combinedExpression = group.LogicalOperator switch + if (combinedExpression == null) { - LogicalOperator.And => Expression.AndAlso(combinedExpression, expr), - LogicalOperator.Or => Expression.OrElse(combinedExpression, expr), - LogicalOperator.Not => Expression.AndAlso(combinedExpression, expr), - _ => Expression.AndAlso(combinedExpression, expr), - }; + combinedExpression = expr; + } + else + { + combinedExpression = group.LogicalOperator switch + { + LogicalOperator.And => Expression.AndAlso(combinedExpression, expr), + LogicalOperator.Or => Expression.OrElse(combinedExpression, expr), + _ => Expression.AndAlso(combinedExpression, expr), + }; + } } } - if (group.LogicalOperator == LogicalOperator.Not && combinedExpression != null) - { - combinedExpression = Expression.Not(combinedExpression); - } - return combinedExpression; } /// /// Builds a filter expression for a single FilterParameter. /// - /// The parameter expression representing the entity - /// The filter parameter to build an expression for - /// An expression representing the filter condition, or null if the filter cannot be applied + /// Parameter expression for the entity + /// Filter parameter to build + /// Optional logger + /// Expression for the filter, or null if invalid public static Expression? BuildSingleFilterExpression( ParameterExpression parameter, - FilterParameter filter + FilterParameter filter, + ILogger? logger = null ) { if (filter.Field.Contains('.')) { - return BuildSafeNestedFilterExpression(parameter, filter); + return BuildSafeNestedFilterExpression(parameter, filter, logger); } - else + + PropertyInfo? property = QueryHelpers.GetPropertyByJsonName(parameter.Type, filter.Field); + if (property == null) { - PropertyInfo? property = QueryHelpers.GetPropertyByJsonName( - parameter.Type, - filter.Field + logger?.LogWarning( + "Property '{Field}' not found on {EntityType}", + filter.Field, + parameter.Type.Name ); - if (property == null) - return null; - Expression propertyAccess = Expression.Property(parameter, property); - return BuildPropertyFilterExpression(propertyAccess, filter); + return null; } + + Expression propertyAccess = Expression.Property(parameter, property); + return BuildPropertyFilterExpression(propertyAccess, filter, logger); } private static Expression BuildLikeExpression(Expression property, string value) { if (property.Type == typeof(string)) { + // For string types, use Contains directly MethodInfo? method = typeof(string).GetMethod("Contains", [typeof(string)]); return Expression.Call(property, method!, Expression.Constant(value)); } - MethodInfo? toStringMethod = property.Type.GetMethod("ToString", Type.EmptyTypes); - MethodCallExpression toStringCall = Expression.Call(property, toStringMethod!); - MethodInfo? containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]); - return Expression.Call(toStringCall, containsMethod!, Expression.Constant(value)); + // For non-string types, we need to handle nulls properly + // Check if the property is nullable + Type? underlyingType = Nullable.GetUnderlyingType(property.Type); + if (underlyingType != null || !property.Type.IsValueType) + { + // Property is nullable or reference type - need null check + // Create: property != null && property.ToString().Contains(value) + + // Null check + Expression notNullCheck = Expression.NotEqual( + property, + Expression.Constant(null, property.Type) + ); + + // ToString call with null check + MethodInfo? toStringMethod = property.Type.GetMethod("ToString", Type.EmptyTypes); + if (toStringMethod == null) + { + // If no ToString method, use Object.ToString + toStringMethod = typeof(object).GetMethod("ToString", Type.EmptyTypes); + property = Expression.Convert(property, typeof(object)); + } + + MethodCallExpression toStringCall = Expression.Call(property, toStringMethod!); + MethodInfo? containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]); + Expression containsCall = Expression.Call( + toStringCall, + containsMethod!, + Expression.Constant(value) + ); + + // Combine: not null && contains + return Expression.AndAlso(notNullCheck, containsCall); + } + else + { + // Non-nullable value type - can call ToString directly + MethodInfo? toStringMethod = property.Type.GetMethod("ToString", Type.EmptyTypes); + MethodCallExpression toStringCall = Expression.Call(property, toStringMethod!); + MethodInfo? containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]); + return Expression.Call(toStringCall, containsMethod!, Expression.Constant(value)); + } } private static Expression BuildInExpression( @@ -152,16 +215,41 @@ private static Expression BuildInExpression( Type propertyType ) { - var rawConvertedValues = value + var rawValues = value .Split(',') .Select(v => v.Trim()) .Where(v => !string.IsNullOrEmpty(v)) - .Select(v => QueryHelpers.ConvertToPropertyType(v, propertyType)) - .Where(v => v != null) - .Select(v => v!) .ToList(); - if (rawConvertedValues.Count == 0) + var convertedValues = new List(); + var failedValues = new List(); + + foreach (var rawValue in rawValues) + { + try + { + var converted = QueryHelpers.ConvertToPropertyType(rawValue, propertyType); + if (converted != null) + { + convertedValues.Add(converted); + } + } + catch (Exception) + { + // Track failed conversions + failedValues.Add(rawValue); + } + } + + // If any values failed to convert, throw an exception with details + if (failedValues.Count > 0) + { + throw new ArgumentException( + $"Failed to convert the following values to type '{propertyType.Name}' for IN operator: {string.Join(", ", failedValues)}" + ); + } + + if (convertedValues.Count == 0) return Expression.Constant(false); Type listElementType = propertyType; @@ -177,7 +265,7 @@ Type propertyType var typedList = (IList)Activator.CreateInstance(listType)!; - foreach (object? item in rawConvertedValues) + foreach (object? item in convertedValues) typedList.Add(item); ConstantExpression listConstant = Expression.Constant(typedList, listType); @@ -196,19 +284,27 @@ Type propertyType private static Expression? BuildSafeNestedFilterExpression( ParameterExpression parameter, - FilterParameter filter + FilterParameter filter, + ILogger? logger = null ) { string[] parts = filter.Field.Split('.'); Expression current = parameter; var nullChecks = new List(); - // Build null-safe navigation for all but the last property + // Navigate through all but the last property for (int i = 0; i < parts.Length - 1; i++) { PropertyInfo? prop = QueryHelpers.GetPropertyByJsonName(current.Type, parts[i]); if (prop == null) + { + logger?.LogWarning( + "Property '{PropertyName}' not found on {Type} during navigation", + parts[i], + current.Type.Name + ); return null; + } current = Expression.Property(current, prop); @@ -225,20 +321,45 @@ FilterParameter filter // Get the final property PropertyInfo? finalProp = QueryHelpers.GetPropertyByJsonName(current.Type, parts[^1]); if (finalProp == null) + { + logger?.LogWarning( + "Property '{PropertyName}' not found on {Type}", + parts[^1], + current.Type.Name + ); return null; + } Expression finalProperty = Expression.Property(current, finalProp); - - // Build the actual filter expression - Expression? filterExpression = BuildPropertyFilterExpression(finalProperty, filter); + Expression? filterExpression = BuildPropertyFilterExpression(finalProperty, filter, logger); if (filterExpression == null) return null; - // Combine null checks with the filter expression - Expression result = filterExpression; - foreach (Expression nullCheck in nullChecks) + // For inequality: null != value is true + // For equality: null == value needs all non-null checks + Expression result; + if (filter.Operator == FilterOperator.Ne || filter.Operator == FilterOperator.Nin) { - result = Expression.AndAlso(nullCheck, result); + if (nullChecks.Count > 0) + { + Expression allNotNull = nullChecks[0]; + for (int i = 1; i < nullChecks.Count; i++) + allNotNull = Expression.AndAlso(allNotNull, nullChecks[i]); + + Expression anyNull = Expression.Not(allNotNull); + Expression notNullAndFilter = Expression.AndAlso(allNotNull, filterExpression); + result = Expression.OrElse(anyNull, notNullAndFilter); + } + else + { + result = filterExpression; + } + } + else + { + result = filterExpression; + foreach (Expression nullCheck in nullChecks) + result = Expression.AndAlso(nullCheck, result); } return result; @@ -246,19 +367,17 @@ FilterParameter filter private static Expression? BuildPropertyFilterExpression( Expression propertyAccess, - FilterParameter filter + FilterParameter filter, + ILogger? logger = null ) { Type targetType = propertyAccess.Type; if (filter.Operator == FilterOperator.IsNull) - { return Expression.Equal(propertyAccess, Expression.Constant(null)); - } + if (filter.Operator == FilterOperator.IsNotNull) - { return Expression.NotEqual(propertyAccess, Expression.Constant(null)); - } if (filter.Operator == FilterOperator.In) { @@ -276,11 +395,9 @@ FilterParameter filter ); return Expression.AndAlso(notNullExpr, containsExpr); } - else - { - return BuildInExpression(propertyAccess, filter.Value, targetType); - } + return BuildInExpression(propertyAccess, filter.Value, targetType); } + if (filter.Operator == FilterOperator.Nin) { Type? underlying = Nullable.GetUnderlyingType(targetType); @@ -297,10 +414,7 @@ FilterParameter filter ); return Expression.OrElse(isNullExpr, Expression.Not(containsExpr)); } - else - { - return Expression.Not(BuildInExpression(propertyAccess, filter.Value, targetType)); - } + return Expression.Not(BuildInExpression(propertyAccess, filter.Value, targetType)); } object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, targetType); @@ -310,8 +424,14 @@ FilterParameter filter && filter.Operator != FilterOperator.Ne ) { + logger?.LogWarning( + "Failed to convert '{Value}' to {PropertyType}", + filter.Value, + targetType.Name + ); return null; } + ConstantExpression constant = Expression.Constant(filterValue, targetType); return filter.Operator switch diff --git a/JsonApiToolkit/Extensions/Querying/FilterHandler.cs b/JsonApiToolkit/Extensions/Querying/FilterHandler.cs index 16ab2c8..2540549 100644 --- a/JsonApiToolkit/Extensions/Querying/FilterHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/FilterHandler.cs @@ -1,50 +1,43 @@ using System.Linq.Expressions; using JsonApiToolkit.Models.Querying.Filtering; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Extensions.Querying; /// -/// Provides extension methods to apply JSON:API filter conditions to IQueryable sources. +/// Applies JSON:API filter conditions to IQueryable sources. /// -/// -/// This class serves as the bridge between JSON:API filter parameters and queryable data sources, -/// translating filter specifications into LINQ expressions. -/// public static class FilterHandler { /// - /// Applies a set of JSON:API filter conditions to an IQueryable data source. + /// Applies filters to queryable (supports nested conditions, operators, AND/OR/NOT). /// - /// The entity type of the queryable - /// The source IQueryable to filter - /// The filter group defining the conditions to apply - /// A new IQueryable with the filter conditions applied - /// - /// Supports complex filtering with nested conditions, different operators (eq, ne, gt, lt, etc.), - /// and logical combinations (AND, OR, NOT). Returns the original query if no valid filters exist. - /// - public static IQueryable ApplyFilters(this IQueryable query, FilterGroup filterGroup) + public static IQueryable ApplyFilters( + this IQueryable query, + FilterGroup filterGroup, + ILogger? logger = null + ) { if ( filterGroup == null || (filterGroup.Filters.Count == 0 && filterGroup.Groups.Count == 0) ) - { return query; - } ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); Expression? expression = FilterExpressionBuilder.BuildFilterExpression( filterGroup, - parameter + parameter, + logger ); if (expression != null) { var lambda = Expression.Lambda>(expression, parameter); - query = query.Where(lambda); + return query.Where(lambda); } + logger?.LogWarning("Filter expression returned null for {Type}", typeof(T).Name); return query; } } diff --git a/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs index 4ad0753..7550f7e 100644 --- a/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs +++ b/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs @@ -6,18 +6,14 @@ namespace JsonApiToolkit.Extensions.Querying; /// -/// Builds filtered Include expressions for Entity Framework Core queries. +/// Builds filtered Include expressions for EF Core queries. +/// Uses EF Core's filtered Include() to apply filters on relationships. /// public static class FilteredIncludeBuilder { /// - /// Applies filtered includes to a queryable, using EF Core's filtered Include functionality. + /// Applies filtered includes using EF Core's Include().Where() pattern. /// - /// The entity type of the queryable - /// The source queryable - /// The list of relationships to include - /// The filters to apply to included relationships - /// A queryable with filtered includes applied public static IQueryable ApplyFilteredIncludes( this IQueryable query, List? includePaths, diff --git a/JsonApiToolkit/Extensions/Querying/IncludeFilterParser.cs b/JsonApiToolkit/Extensions/Querying/IncludeFilterParser.cs index 68e9e3c..d38a408 100644 --- a/JsonApiToolkit/Extensions/Querying/IncludeFilterParser.cs +++ b/JsonApiToolkit/Extensions/Querying/IncludeFilterParser.cs @@ -5,7 +5,7 @@ namespace JsonApiToolkit.Extensions.Querying; /// -/// Provides functionality to parse and separate filters that target included resources from main entity filters. +/// Separates filters targeting included resources from main entity filters. /// public static class IncludeFilterParser { @@ -14,16 +14,9 @@ public static class IncludeFilterParser private const int MaxOrConditions = 10; /// - /// Separates filters targeting included resources from filters targeting the main entity. + /// Separates main filters from include filters. + /// Validates that filtered relationships are included in the query. /// - /// The original filter group containing all filters - /// The list of include paths requested in the query - /// - /// A tuple containing the main entity filters and a list of filters for included resources - /// - /// - /// Thrown when filters reference relationships that aren't included or exceed complexity limits - /// public static ( FilterGroup? mainFilters, List includeFilters diff --git a/JsonApiToolkit/Extensions/Querying/PaginationHandler.cs b/JsonApiToolkit/Extensions/Querying/PaginationHandler.cs index 94d3d96..d29e847 100644 --- a/JsonApiToolkit/Extensions/Querying/PaginationHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/PaginationHandler.cs @@ -5,35 +5,21 @@ namespace JsonApiToolkit.Extensions.Querying; /// -/// Provides extension methods for implementing JSON:API pagination on IQueryable data sources. +/// Applies JSON:API pagination to IQueryable sources. +/// Uses page-based pagination (page number + size). /// -/// -/// Implements the pagination strategy specified in the JSON:API specification using page-based pagination -/// with configurable page size and page number. -/// public static class PaginationHandler { /// - /// Applies pagination parameters to an IQueryable data source. + /// Applies pagination using Skip/Take. Clamps invalid page numbers. /// - /// The entity type of the queryable - /// The source IQueryable to paginate - /// The pagination parameters defining page number and size - /// A new IQueryable with pagination applied (Skip/Take) - /// - /// Translates the page-based pagination model (page number and size) into the offset-based - /// pagination used by LINQ (Skip and Take). Invalid page numbers are clamped to valid ranges. - /// public static IQueryable ApplyPagination( this IQueryable query, PaginationParameters pagination ) { - // Calculate total count and pages to determine valid page range int totalCount = query.Count(); int totalPages = (int)Math.Ceiling(totalCount / (double)pagination.Size); - - // Clamp page number to valid range (1 to totalPages, default to 1 if empty) int effectivePage = Math.Max(1, Math.Min(pagination.Number, Math.Max(totalPages, 1))); int skip = (effectivePage - 1) * pagination.Size; @@ -41,16 +27,8 @@ PaginationParameters pagination } /// - /// Creates pagination metadata for use in JSON:API responses. + /// Creates pagination metadata (executes COUNT query). /// - /// The entity type of the queryable - /// The source IQueryable before pagination was applied - /// The pagination parameters that were applied - /// A PaginationMeta object containing total counts and pagination information - /// - /// This method executes a COUNT query on the database to determine the total number of resources - /// and calculates total pages based on the page size. Invalid page numbers are clamped to valid ranges. - /// public static async Task CreatePaginationMetaAsync( this IQueryable query, PaginationParameters pagination @@ -63,13 +41,10 @@ PaginationParameters pagination } catch (InvalidOperationException) { - // Fallback for in-memory queryables that don't support async operations - totalCount = query.Count(); + totalCount = query.Count(); // Fallback for in-memory queryables } int totalPages = (int)Math.Ceiling(totalCount / (double)pagination.Size); - - // Clamp page number to valid range (1 to totalPages, default to 1 if empty) int effectivePage = Math.Max(1, Math.Min(pagination.Number, Math.Max(totalPages, 1))); return new PaginationMeta diff --git a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs b/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs index a0ce994..babc4ea 100644 --- a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs +++ b/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs @@ -1,70 +1,50 @@ +using System.Collections.Concurrent; using System.Globalization; using System.Reflection; namespace JsonApiToolkit.Extensions.Querying; /// -/// Provides helper methods for interpreting and converting query parameters in JSON:API requests. +/// Helper methods for query parameter interpretation and conversion. +/// Caches property lookups for performance. /// -/// -/// Contains utilities for property name mapping between JSON and C# conventions, type conversion, -/// and other common query handling functions. -/// public static class QueryHelpers { + private static readonly ConcurrentDictionary<(Type, string), PropertyInfo?> s_propertyCache = + new(); + /// - /// Resolves a JSON property name to the corresponding C# property in an entity type. + /// Resolves JSON property name to C# property. + /// Tries: exact match, PascalCase, then case-insensitive. /// - /// The entity type to search for the property - /// The JSON property name (typically camelCase) - /// The matching PropertyInfo if found, or null if no matching property exists - /// - /// Attempts to match properties in the following order: - /// - /// - /// Exact match (case-sensitive) - /// - /// - /// PascalCase version of the JSON name - /// - /// - /// Case-insensitive match - /// - /// - /// This handles the common case of converting between camelCase (JSON) and PascalCase (C#) property names. - /// public static PropertyInfo? GetPropertyByJsonName(Type entityType, string jsonPropertyName) { - PropertyInfo? property = entityType.GetProperty(jsonPropertyName); + return s_propertyCache.GetOrAdd( + (entityType, jsonPropertyName), + key => + { + var (type, name) = key; - if (property != null) - return property; + PropertyInfo? property = type.GetProperty(name); + if (property != null) + return property; - string pascalCase = jsonPropertyName.ToPascalCase(); - property = entityType.GetProperty(pascalCase); + string pascalCase = name.ToPascalCase(); + property = type.GetProperty(pascalCase); - return property - ?? entityType - .GetProperties() - .FirstOrDefault(p => - string.Equals(p.Name, jsonPropertyName, StringComparison.OrdinalIgnoreCase) - ); + return property + ?? type.GetProperties() + .FirstOrDefault(p => + string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase) + ); + } + ); } /// - /// Converts a string value from a query parameter to the appropriate target property type. + /// Converts query parameter string to target property type. + /// Supports primitives, enums, DateTime (assumes UTC), Guid, Uri, TimeSpan, byte[]. /// - /// The string value from the query parameter - /// The target property type to convert to - /// - /// /// 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, - /// byte[], etc.) and their nullable variants. - /// Also supports enum types, converting the string to the corresponding enum value. - /// For DateTime values, assumes UTC if no timezone is specified. - /// public static object? ConvertToPropertyType(string value, Type targetType) { try @@ -109,7 +89,15 @@ public static class QueryHelpers DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal ); } + if (nonNullableType == typeof(DateOnly)) + { + return DateOnly.Parse(value, CultureInfo.InvariantCulture); + } + if (nonNullableType == typeof(TimeOnly)) + { + return TimeOnly.Parse(value, CultureInfo.InvariantCulture); + } if (nonNullableType == typeof(Guid)) return Guid.Parse(value); @@ -138,7 +126,11 @@ public static class QueryHelpers catch (Exception ex) { throw new FormatException( - $"Failed to convert '{value}' to type '{targetType.FullName}': {ex.Message}", + $"Failed to convert filter value '{value}' to type '{targetType.FullName}'. " + + $"Expected format examples: " + + $"int: '42', decimal: '12.34', DateTime: '2023-12-25T10:30:00Z', bool: 'true'/'false', " + + $"Guid: '550e8400-e29b-41d4-a716-446655440000'. " + + $"Error: {ex.Message}", ex ); } diff --git a/JsonApiToolkit/Extensions/Querying/SortingHandler.cs b/JsonApiToolkit/Extensions/Querying/SortingHandler.cs index 4e35f91..7da919a 100644 --- a/JsonApiToolkit/Extensions/Querying/SortingHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/SortingHandler.cs @@ -1,43 +1,27 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiToolkit.Models.Querying; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Extensions.Querying; /// -/// Provides extension methods to apply JSON:API sorting parameters to IQueryable sources. +/// Applies JSON:API sorting to IQueryable sources. +/// Supports multiple fields with OrderBy/ThenBy chaining. /// -/// -/// Implements the sorting strategy defined in the JSON:API specification, supporting multiple -/// sort fields and ascending/descending direction. -/// public static class SortingHandler { /// - /// Applies JSON:API sort parameters to an IQueryable data source. + /// Applies sort parameters (supports multiple fields in priority order). /// - /// The entity type of the queryable - /// The source IQueryable to sort - /// The list of sort parameters specifying fields and directions - /// A new IQueryable with the specified sorting applied - /// - /// - /// Supports multiple sort fields in priority order. For each field, dynamically creates an OrderBy - /// or OrderByDescending expression, followed by ThenBy or ThenByDescending for subsequent fields. - /// - /// - /// Returns the original query if no valid sort parameters are provided. - /// - /// public static IQueryable ApplySorting( this IQueryable query, - List sortParameters + List sortParameters, + ILogger? logger = null ) { if (sortParameters == null || sortParameters.Count == 0) - { return query; - } IOrderedQueryable? orderedQuery = null; Type entityType = typeof(T); diff --git a/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs b/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs index 9da380a..b172c6f 100644 --- a/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs +++ b/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using JsonApiToolkit.Filters; +using JsonApiToolkit.Services; using JsonApiToolkit.Validation; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; @@ -11,16 +12,13 @@ namespace JsonApiToolkit.Extensions { /// - /// Provides extension methods for integrating JsonApiToolkit into the ASP.NET Core dependency injection system. + /// Extension methods for registering JsonApiToolkit services. /// public static class ServiceCollectionExtensions { /// - /// Configures all necessary services and options for JsonApiToolkit in an ASP.NET Core application. - /// Also configures OpenAPI/Swagger to use the correct JSON:API content types for controllers tagged with GroupName = "JsonApi". + /// Registers JsonApiToolkit services: JSON serialization, filters, query parser, and include validation. /// - /// The service collection to add JsonApiToolkit services to. - /// The service collection for method chaining. public static IServiceCollection AddJsonApiToolkit(this IServiceCollection services) { // Configure JSON serialization options @@ -66,6 +64,9 @@ public static IServiceCollection AddJsonApiToolkit(this IServiceCollection servi services.AddScoped(); services.AddScoped(); + // Register query parser service + services.AddScoped(); + // Register include pattern validator for startup validation services.TryAddEnumerable( ServiceDescriptor.Transient() diff --git a/JsonApiToolkit/Extensions/StringExtensions.cs b/JsonApiToolkit/Extensions/StringExtensions.cs index 0f77dc2..df21805 100644 --- a/JsonApiToolkit/Extensions/StringExtensions.cs +++ b/JsonApiToolkit/Extensions/StringExtensions.cs @@ -1,22 +1,13 @@ namespace JsonApiToolkit.Extensions; /// -/// Provides extension methods for string manipulation related to JSON:API implementation. +/// String extension methods for case conversion (PascalCase ↔ camelCase). /// -/// -/// Contains utilities for case conversion between JSON and C# naming conventions. -/// public static class StringExtensions { /// - /// Converts a string from PascalCase to camelCase format. + /// Converts PascalCase to camelCase. /// - /// The string to convert - /// The camelCase version of the input string - /// - /// Used for converting C# property names (PascalCase) to JSON property names (camelCase). - /// Returns the original string if it's null, empty, or already in camelCase format. - /// public static string ToCamelCase(this string str) { if (string.IsNullOrEmpty(str) || !char.IsUpper(str[0])) @@ -26,14 +17,8 @@ public static string ToCamelCase(this string str) } /// - /// Converts a string from camelCase to PascalCase format. + /// Converts camelCase to PascalCase. /// - /// The string to convert - /// The PascalCase version of the input string - /// - /// 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(this string str) { if (string.IsNullOrEmpty(str)) diff --git a/JsonApiToolkit/Filters/JsonApiContentTypeFilter.cs b/JsonApiToolkit/Filters/JsonApiContentTypeFilter.cs index 1ca179c..0e7afe2 100644 --- a/JsonApiToolkit/Filters/JsonApiContentTypeFilter.cs +++ b/JsonApiToolkit/Filters/JsonApiContentTypeFilter.cs @@ -4,21 +4,19 @@ namespace JsonApiToolkit.Filters; /// -/// A filter that sets the content type of the response to "application/vnd.api+json" -/// for all JSON API responses. +/// Sets response content type to "application/vnd.api+json" for all JSON:API responses. /// public class JsonApiContentTypeFilter : IActionFilter { private const string s_jsonApiMediaType = "application/vnd.api+json"; /// - /// Does nothing before the action executes. + /// Called before the action executes. /// public void OnActionExecuting(ActionExecutingContext context) { } /// - /// Sets the content type of the response to "application/vnd.api+json" - /// for all JSON API responses. + /// Called after the action executes. Sets the content type to JSON:API media type. /// public void OnActionExecuted(ActionExecutedContext context) { diff --git a/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs b/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs index 4f202db..b5bf91a 100644 --- a/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs +++ b/JsonApiToolkit/Filters/JsonApiExceptionFilter.cs @@ -6,26 +6,16 @@ namespace JsonApiToolkit.Filters; /// -/// Exception filter that transforms known and unknown exceptions into JSON:API compliant error responses. +/// Transforms exceptions into JSON:API compliant error responses. +/// Handles known JsonApiException types and logs unexpected errors. /// public class JsonApiExceptionFilter(ILogger logger) : IExceptionFilter { private readonly ILogger _logger = logger; /// - /// Handles exceptions thrown during the execution of a controller action. + /// Handles exceptions and converts them to JSON:API error responses. /// - /// The context of the exception. - /// - /// - /// 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) { int status; diff --git a/JsonApiToolkit/Helpers/EfIncludePathHelper.cs b/JsonApiToolkit/Helpers/EfIncludePathHelper.cs index 8a93502..7be104f 100644 --- a/JsonApiToolkit/Helpers/EfIncludePathHelper.cs +++ b/JsonApiToolkit/Helpers/EfIncludePathHelper.cs @@ -1,63 +1,78 @@ using System.Collections; +using System.Collections.Concurrent; +using System.Reflection; namespace JsonApiToolkit.Helpers; /// -/// Provides utilities for mapping between include paths and CLR properties. +/// Maps include paths to CLR property names. +/// Caches property lookups for performance. /// public static class EfIncludePathHelper { + private static readonly ConcurrentDictionary<(Type, string), string> s_includePathCache = new(); + /// - /// Maps a list of include paths to CLR properties for a given type. + /// Maps include paths to CLR property names for the given type. /// - /// The type to map include paths to - /// The list of include paths to map - /// A list of mapped CLR property names public static List MapIncludePathsToClrProperties(List? includePaths) { - if (includePaths == null) + if (includePaths == null || includePaths.Count == 0) return []; var type = typeof(T); - var mapped = new List(); + var mapped = new List(includePaths.Count); foreach (var path in includePaths) { if (string.IsNullOrWhiteSpace(path)) continue; - var parts = path.Split('.'); - var mappedParts = new List(); - var currentType = type; + var mappedPath = s_includePathCache.GetOrAdd( + (type, path), + key => MapSinglePath(key.Item1, key.Item2) + ); + + mapped.Add(mappedPath); + } + + return mapped; + } + + private static string MapSinglePath(Type startType, string path) + { + var parts = path.Split('.'); + var mappedParts = new string[parts.Length]; + var currentType = startType; + + for (int i = 0; i < parts.Length; i++) + { + PropertyInfo? prop = currentType + .GetProperties() + .FirstOrDefault(p => + string.Equals(p.Name, parts[i], StringComparison.OrdinalIgnoreCase) + ); - foreach (var part in parts) + if (prop == null) { - var prop = currentType - .GetProperties() - .FirstOrDefault(p => - string.Equals(p.Name, part, StringComparison.OrdinalIgnoreCase) - ); - if (prop == null) - { - mappedParts.Add(part); - break; - } - mappedParts.Add(prop.Name); - currentType = prop.PropertyType; - if ( - typeof(IEnumerable).IsAssignableFrom(currentType) - && currentType != typeof(string) - ) - { - currentType = currentType.IsGenericType - ? currentType.GetGenericArguments()[0] - : typeof(object); - } + // Property not found, keep original and stop + for (int j = i; j < parts.Length; j++) + mappedParts[j] = parts[j]; + break; } - mapped.Add(string.Join('.', mappedParts)); + mappedParts[i] = prop.Name; + currentType = prop.PropertyType; + + // Handle collections + if (typeof(IEnumerable).IsAssignableFrom(currentType) && currentType != typeof(string)) + { + currentType = currentType.IsGenericType + ? currentType.GetGenericArguments()[0] + : typeof(object); + } } - return mapped; + return string.Join('.', mappedParts); } } diff --git a/JsonApiToolkit/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index a47987f..75117f3 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 1.1.3-local + 1.1.4 Intility Intility A toolkit for implementing JSON:API specification in .NET applications @@ -24,6 +24,7 @@ + diff --git a/JsonApiToolkit/Mapping/EntityMapper.cs b/JsonApiToolkit/Mapping/EntityMapper.cs index 184353e..b11b44b 100644 --- a/JsonApiToolkit/Mapping/EntityMapper.cs +++ b/JsonApiToolkit/Mapping/EntityMapper.cs @@ -6,12 +6,9 @@ namespace JsonApiToolkit.Mapping; /// -/// Provides utilities for mapping between entity models and JSON:API resource objects. +/// Maps entity models to JSON:API resource objects. +/// Caches property information for performance. /// -/// -/// This static class contains methods for determining ID properties, attributes, and relationships -/// for entity mapping purposes. It caches property information to improve performance in repeated mapping operations. -/// public static class EntityMapper { private static readonly ConcurrentDictionary s_idPropertyCache = new(); @@ -25,24 +22,9 @@ private static readonly ConcurrentDictionary< > s_relationshipPropertyCache = new(); /// - /// Identifies and retrieves the primary key property for an entity type. + /// Gets the primary key property for an entity. + /// Searches for: "Id", "{TypeName}Id", or any property ending with "Id". /// - /// The entity type to analyze - /// The PropertyInfo for the ID property, or null if not found - /// - /// Uses a cached approach to improve performance over repeated calls. Attempts to find the ID property in the following order: - /// - /// - /// A property named "Id" - /// - /// - /// A property named "{TypeName}Id" (e.g., "PersonId" for a "Person" entity) - /// - /// - /// Any property ending with "Id" - /// - /// - /// public static PropertyInfo? GetIdProperty(Type type) { return s_idPropertyCache.GetOrAdd( @@ -57,28 +39,9 @@ private static readonly ConcurrentDictionary< } /// - /// Identifies the properties that should be mapped as attributes in a JSON:API resource object. + /// Gets properties to map as JSON:API attributes. + /// Excludes: ID property, relationships, non-public/unreadable properties. /// - /// The entity type to analyze - /// A list of PropertyInfo objects representing the attributes - /// - /// Uses a cached approach to improve performance over repeated calls. Excludes: - /// - /// - /// The primary ID property (to avoid duplication with the resource's id field) - /// - /// - /// Properties identified as relationships - /// - /// - /// Properties that can't be read or aren't public - /// - /// - /// Collection properties (except strings) - /// - /// - /// The resulting properties typically represent scalar values of the entity, including foreign key IDs. - /// public static List GetAttributeProperties(Type type) { return s_attributePropertyCache.GetOrAdd( @@ -102,25 +65,10 @@ public static List GetAttributeProperties(Type type) } /// - /// Identifies the properties that should be mapped as relationships in a JSON:API resource object. + /// Gets properties to map as JSON:API relationships. + /// Includes collections and complex objects that have ID properties. + /// Excludes primitives, value types, strings, DateTime, Guid, and owned entities without IDs. /// - /// The entity type to analyze - /// A list of PropertyInfo objects representing the relationships - /// - /// Uses a cached approach to improve performance over repeated calls. - /// Identifies two types of relationships: - /// - /// - /// Collections (IEnumerable properties that aren't strings) - representing to-many relationships - /// - /// - /// Complex object properties (non-primitive, non-value types) - representing to-one relationships - /// - /// - /// Excludes common value types like string, DateTime, and Guid. - /// Collections of entities without ID properties (e.g., EF Core owned entities stored as JSON) are excluded and treated as attributes instead. - /// Single complex objects without ID properties (e.g., EF Core owned entities stored as JSON) are excluded and treated as attributes instead. - /// public static List GetRelationshipProperties(Type type) { return s_relationshipPropertyCache.GetOrAdd( @@ -156,14 +104,9 @@ public static List GetRelationshipProperties(Type type) } /// - /// Determines the JSON:API resource type name for an entity type. + /// Gets the JSON:API resource type name (entity class name in camelCase). + /// Example: "Person" becomes "person". /// - /// The entity type - /// The camelCase resource type name - /// - /// By convention, uses the entity class name in camelCase as the resource type. - /// For example, a "Person" entity class becomes a "person" resource type. - /// public static string GetResourceType(Type type) { string name = type.Name; diff --git a/JsonApiToolkit/Mapping/InclusionMapper.cs b/JsonApiToolkit/Mapping/InclusionMapper.cs index 2c78b05..fa3fd48 100644 --- a/JsonApiToolkit/Mapping/InclusionMapper.cs +++ b/JsonApiToolkit/Mapping/InclusionMapper.cs @@ -2,39 +2,25 @@ using System.Reflection; using JsonApiToolkit.Extensions; using JsonApiToolkit.Models.Resources; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Mapping; /// -/// Handles the mapping of included (related) resources in JSON:API responses. +/// Maps included (related) resources for JSON:API responses. +/// Handles to-one/to-many relationships and nested paths (e.g., "author.comments"). /// -/// -/// Responsible for processing the "include" query parameter and adding the specified related resources -/// to the "included" section of a JSON:API response. Handles both to-one and to-many relationships, -/// and supports nested inclusion paths (e.g., "author.comments"). -/// public static class InclusionMapper { /// - /// Processes specified include paths and adds related resources to the included collection. + /// Processes include paths and adds related resources to included collection. + /// Uses HashSet to track processed entities and prevent duplicates. /// - /// The primary entity or collection of entities to process - /// List of relationship paths to include (e.g., ["author", "comments.user"]) - /// Collection to add included resources to - /// Optional set tracking already processed entities to prevent duplicates - /// - /// - /// The entry point for inclusion processing. Starts from the primary entity and traverses all specified - /// relationship paths, collecting related entities for inclusion in the response. - /// - /// - /// Uses a HashSet to track processed entities and prevent duplicate inclusions. - /// - /// public static void AddIncludedResources( object entityOrCollection, List includePaths, List included, + ILogger? logger = null, HashSet? processedEntities = null ) { @@ -43,7 +29,6 @@ public static void AddIncludedResources( processedEntities ??= []; - // Group include paths by their first segment IEnumerable> grouped = includePaths .Select(path => path.Split('.', 2)) .GroupBy(parts => parts[0], parts => parts.Length > 1 ? parts[1] : null); @@ -62,7 +47,8 @@ public static void AddIncludedResources( relationshipName, nestedPaths, included, - processedEntities + processedEntities, + logger ); } } @@ -73,7 +59,8 @@ public static void AddIncludedResources( relationshipName, nestedPaths, included, - processedEntities + processedEntities, + logger ); } } @@ -84,19 +71,28 @@ private static void AddIncludedForEntity( string relationshipName, List nestedPaths, List included, - HashSet processedEntities + HashSet processedEntities, + ILogger? logger = null ) { if (entity == null) return; Type type = entity.GetType(); + PropertyInfo? relProp = type.GetProperties() .FirstOrDefault(p => string.Equals(p.Name, relationshipName, StringComparison.OrdinalIgnoreCase) ); if (relProp == null) + { + logger?.LogWarning( + "Relationship '{Relationship}' not found on {Type}", + relationshipName, + type.Name + ); return; + } object? relValue = relProp.GetValue(entity); if (relValue == null) @@ -106,12 +102,12 @@ HashSet processedEntities { foreach (object? relEntity in relCollection) { - AddSingleIncluded(relEntity, included, processedEntities, nestedPaths); + AddSingleIncluded(relEntity, included, processedEntities, nestedPaths, logger); } } else { - AddSingleIncluded(relValue, included, processedEntities, nestedPaths); + AddSingleIncluded(relValue, included, processedEntities, nestedPaths, logger); } } @@ -119,7 +115,8 @@ private static void AddSingleIncluded( object relEntity, List included, HashSet processedEntities, - List nestedPaths + List nestedPaths, + ILogger? logger = null ) { if (relEntity == null) @@ -128,73 +125,35 @@ List nestedPaths Type type = relEntity.GetType(); PropertyInfo? idProp = EntityMapper.GetIdProperty(type); if (idProp == null) + { + logger?.LogWarning("No ID property on {Type}", type.Name); return; + } + object? idValue = idProp.GetValue(relEntity); if (idValue == null) - return; // <-- Defensive: skip if no ID + return; string id = idValue.ToString()!; - string key = $"{EntityMapper.GetResourceType(type)}:{id}"; + string resourceType = EntityMapper.GetResourceType(type); + string key = $"{resourceType}:{id}"; + if (!processedEntities.Add(key)) return; // Already processed - // Map the related entity to a ResourceObject (attributes + relationships) - var resourceObject = JsonApiMapper.ToResourceObject( - relEntity, - EntityMapper.GetResourceType(type), - nestedPaths - ); + var resourceObject = JsonApiMapper.ToResourceObject(relEntity, resourceType, nestedPaths); included.Add(resourceObject); - // Recursively process nested include paths if (nestedPaths?.Count > 0) { - AddIncludedResources(relEntity, nestedPaths, included, processedEntities); + AddIncludedResources(relEntity, nestedPaths, included, logger, processedEntities); } } /// - /// Recursively processes a single include path to extract related resources. + /// Recursively processes include path to extract related resources. + /// Handles to-one/to-many relationships and nested paths with duplicate prevention. /// - /// The entity type at the current recursion level - /// The entity at the current recursion level - /// Array of relationship names forming the include path - /// Current depth in the path - /// Collection to add included resources to - /// Set tracking already processed entities to prevent duplicates - /// - /// - /// Internal recursive method that handles both to-one and to-many relationships: - /// - /// - /// For to-many relationships, iterates through the collection and processes each item - /// - /// - /// For to-one relationships, processes the single related entity - /// - /// - /// - /// - /// For each related entity: - /// - /// - /// Extracts its ID and type to form a unique key - /// - /// - /// Checks if it has already been processed (to avoid duplicates) - /// - /// - /// Adds it to the included collection with all its attributes - /// - /// - /// Recursively processes the next part of the include path if needed - /// - /// - /// - /// - /// Handles nested includes (e.g., "author.comments") by recursively calling itself with an incremented depth. - /// - /// public static void AddIncludedResourcesRecursive( T entity, string[] pathParts, diff --git a/JsonApiToolkit/Mapping/JsonApiMapper.cs b/JsonApiToolkit/Mapping/JsonApiMapper.cs index 7b0345d..2dbb64c 100644 --- a/JsonApiToolkit/Mapping/JsonApiMapper.cs +++ b/JsonApiToolkit/Mapping/JsonApiMapper.cs @@ -4,59 +4,25 @@ using JsonApiToolkit.Models.Documents; using JsonApiToolkit.Models.Metadata; using JsonApiToolkit.Models.Resources; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Mapping; /// -/// Core mapper for converting entities and entity collections to JSON:API resource structures. +/// Maps entities to JSON:API resource structures. +/// Handles attributes, relationships, included resources, pagination, and links. /// -/// -/// -/// This static class provides the primary mapping functionality between application entities and -/// JSON:API document structures. It handles mapping of attributes, relationships, included resources, -/// pagination, and links. -/// -/// -/// All JSON:API document creation should use these methods to ensure consistency and compliance -/// with the JSON:API specification. -/// -/// public static class JsonApiMapper { /// - /// Maps an entity to a JSON:API resource object with attributes and relationships. + /// Maps entity to JSON:API resource object. + /// Extracts ID, maps properties to attributes, and maps relationships. /// - /// The entity to map - /// The JSON:API resource type identifier - /// Optional list of relationships to include in the resource object - /// A fully populated ResourceObject representing the entity - /// - /// Maps the entity to a JSON:API resource object by: - /// - /// - /// Extracting the entity's ID - /// - /// - /// Mapping primitive properties to attributes - /// - /// - /// Mapping related entities to relationships (both to-one and to-many) - /// - /// - /// - /// Only maps relationships that are explicitly included in the includedRelationships parameter. - /// Performs smart mapping of different relationship types (to-one vs to-many). - /// - /// - /// This is the core mapping method used by all other document creation methods. - /// - /// - /// Thrown if the entity parameter is null - /// Thrown if the entity's ID cannot be determined public static ResourceObject ToResourceObject( object entity, string resourceType, - List? includedRelationships = null + List? includedRelationships = null, + ILogger? logger = null ) { ArgumentNullException.ThrowIfNull(entity); @@ -188,6 +154,7 @@ public static ResourceObject ToResourceObject( /// The JSON:API resource type identifier /// The self link URL for the resource /// Optional list of relationship paths to include + /// Optional logger for debugging and tracing /// A fully populated JSON:API document representing the entity /// /// @@ -210,11 +177,23 @@ public static JsonApiDocument ToDocument( T entity, string resourceType, string selfLink, - List? includedRelationships = null + List? includedRelationships = null, + ILogger? logger = null ) where T : class { - ResourceObject resource = ToResourceObject(entity, resourceType, includedRelationships); + logger?.LogDebug( + "Creating JSON:API document for entity of type {EntityType} with resource type '{ResourceType}'", + typeof(T).Name, + resourceType + ); + + ResourceObject resource = ToResourceObject( + entity, + resourceType, + includedRelationships, + logger + ); resource.Links = new Links { Self = selfLink }; var document = new JsonApiDocument @@ -225,12 +204,30 @@ public static JsonApiDocument ToDocument( if (includedRelationships?.Count > 0) { + logger?.LogDebug( + "Processing includes for single entity: {IncludeCount} relationships requested", + includedRelationships.Count + ); + var included = new List(); - InclusionMapper.AddIncludedResources(entity, includedRelationships, included); + InclusionMapper.AddIncludedResources(entity, includedRelationships, included, logger); + + logger?.LogDebug( + "Include processing completed for single entity: {IncludedCount} resources added to included section", + included.Count + ); + if (included.Count > 0) { document.Included = included; } + else + { + logger?.LogWarning( + "No included resources were processed for single entity despite {IncludeCount} relationships being requested. Check if relationships are properly loaded", + includedRelationships.Count + ); + } } return document; @@ -245,22 +242,35 @@ public static JsonApiDocument ToDocument( /// The self link of the resource object. /// Optional pagination metadata. /// Optional list of relationship paths to include. + /// Optional logger for debugging and tracing /// The JSON:API collection document. public static JsonApiCollectionDocument ToCollectionDocument( IEnumerable entities, string resourceType, string selfLink, PaginationMeta? paginationMeta = null, - List? includedRelationships = null + List? includedRelationships = null, + ILogger? logger = null ) where T : class { + logger?.LogDebug( + "Creating JSON:API collection document for entities of type {EntityType} with resource type '{ResourceType}'", + typeof(T).Name, + resourceType + ); + string baseUrl = selfLink.Split('?')[0]; var resources = entities .Select(e => { - ResourceObject resource = ToResourceObject(e, resourceType, includedRelationships); + ResourceObject resource = ToResourceObject( + e, + resourceType, + includedRelationships, + logger + ); resource.Links = new Links { Self = $"{baseUrl}/{resource.Id}" }; return resource; }) @@ -306,12 +316,31 @@ public static JsonApiCollectionDocument ToCollectionDocument( if (includedRelationships?.Count > 0) { + logger?.LogDebug( + "Processing includes for collection: {IncludeCount} relationships requested for {EntityCount} entities", + includedRelationships.Count, + resources.Count + ); + var included = new List(); - InclusionMapper.AddIncludedResources(entities, includedRelationships, included); + InclusionMapper.AddIncludedResources(entities, includedRelationships, included, logger); + + logger?.LogDebug( + "Include processing completed: {IncludedCount} resources added to included section", + included.Count + ); + if (included.Count > 0) { document.Included = included; } + else + { + logger?.LogWarning( + "No included resources were processed despite {IncludeCount} relationships being requested. Check if relationships are properly loaded on entities", + includedRelationships.Count + ); + } } return document; diff --git a/JsonApiToolkit/Models/Documents/JsonApiCollectionDocument.cs b/JsonApiToolkit/Models/Documents/JsonApiCollectionDocument.cs index f7c1a07..4d20fce 100644 --- a/JsonApiToolkit/Models/Documents/JsonApiCollectionDocument.cs +++ b/JsonApiToolkit/Models/Documents/JsonApiCollectionDocument.cs @@ -5,29 +5,14 @@ namespace JsonApiToolkit.Models.Documents; /// -/// Represents a JSON:API document containing a collection of resources as the primary data. +/// JSON:API document containing a collection of resources with optional includes, meta, and links. /// -/// The type of resources in the collection (typically ResourceObject) -/// -/// -/// Used for endpoints that return multiple resources, such as collection GET requests. -/// Follows the JSON:API specification structure with data as an array of resources. -/// -/// -/// Can include related resources in the "included" array, metadata in the "meta" object, -/// and navigation links in the "links" object. -/// -/// public class JsonApiCollectionDocument where T : class { /// - /// The primary data of the document as a collection of resources. + /// The collection of primary resources. /// - /// - /// According to the JSON:API specification, this is always an array of resource objects - /// for collection documents, even if empty. - /// [JsonPropertyName("data")] public IEnumerable Data { get; set; } = []; diff --git a/JsonApiToolkit/Models/Documents/JsonApiDocument.cs b/JsonApiToolkit/Models/Documents/JsonApiDocument.cs index c39c639..9fd4c33 100644 --- a/JsonApiToolkit/Models/Documents/JsonApiDocument.cs +++ b/JsonApiToolkit/Models/Documents/JsonApiDocument.cs @@ -5,61 +5,34 @@ namespace JsonApiToolkit.Models.Documents; /// -/// Represents a JSON:API document containing a single resource as the primary data. +/// JSON:API document containing a single resource with optional includes, meta, and links. /// -/// The type of the primary resource (typically ResourceObject) -/// -/// -/// Used for endpoints that return a single resource, such as individual GET, POST, or PATCH requests. -/// Follows the JSON:API specification structure with data as a single resource object. -/// -/// -/// Can include related resources in the "included" array, metadata in the "meta" object, -/// and navigation links in the "links" object. -/// -/// public class JsonApiDocument where T : class { /// - /// The primary data of the document as a single resource. + /// The primary resource. /// - /// - /// According to the JSON:API specification, this is either a single resource object - /// or null for an empty response. - /// [JsonPropertyName("data")] public T? Data { get; set; } /// - /// Related resources included in the document to reduce the need for additional requests. + /// Related resources requested via include parameter. /// - /// - /// Contains resource objects that are related to the primary data and requested via the - /// "include" query parameter. This array is omitted when no related resources are included. - /// [JsonPropertyName("included")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IEnumerable? Included { get; set; } /// - /// Non-standard metadata about the document or the resources it contains. + /// Metadata (pagination, statistics, etc.). /// - /// - /// Can include arbitrary information such as pagination details, processing statistics, - /// or any other non-standard information related to the request or response. - /// [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? Meta { get; set; } /// - /// Links related to the document and its primary data. + /// Navigation and pagination links. /// - /// - /// Contains links for navigation and relationship traversal, including self links, - /// pagination links, and other related links as defined by the JSON:API specification. - /// [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Links? Links { get; set; } diff --git a/JsonApiToolkit/Models/Errors/ErrorSource.cs b/JsonApiToolkit/Models/Errors/ErrorSource.cs index 1e5c992..aa2a8cf 100644 --- a/JsonApiToolkit/Models/Errors/ErrorSource.cs +++ b/JsonApiToolkit/Models/Errors/ErrorSource.cs @@ -3,48 +3,20 @@ namespace JsonApiToolkit.Models.Errors; /// -/// Identifies the specific source of a JSON:API error within a request document or URL parameters. +/// Identifies the source of an error (JSON pointer or query parameter). /// -/// -/// The error source object allows API providers to pinpoint exactly what part of the request -/// led to an error, whether it's a specific field in the request body or a specific query parameter. -/// public class ErrorSource { /// - /// A JSON Pointer to the associated entity in the request document. + /// JSON Pointer to the error location in request body (e.g., "/data/attributes/title"). /// - /// - /// Examples: - /// - /// - /// "/data" for errors related to the entire resource object - /// - /// - /// "/data/attributes/title" for errors related to a specific attribute - /// - /// - /// "/data/relationships/author" for errors related to a relationship - /// - /// - /// Only applicable for errors related to the request body. - /// [JsonPropertyName("pointer")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Pointer { get; set; } /// - /// The URL query parameter that caused the error. + /// Query parameter that caused the error. /// - /// - /// - /// Used for errors related to query parameters, such as invalid filter syntax, - /// unsupported include paths, or pagination issues. - /// - /// - /// Only applicable for errors related to query parameters. - /// - /// [JsonPropertyName("parameter")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Parameter { get; set; } diff --git a/JsonApiToolkit/Models/Errors/JsonApiError.cs b/JsonApiToolkit/Models/Errors/JsonApiError.cs index 56fba91..6cd94ff 100644 --- a/JsonApiToolkit/Models/Errors/JsonApiError.cs +++ b/JsonApiToolkit/Models/Errors/JsonApiError.cs @@ -3,83 +3,54 @@ namespace JsonApiToolkit.Models.Errors; /// -/// Represents a standardized error object in a JSON:API error response. +/// JSON:API error object with status, title, detail, code, source, and meta. /// -/// -/// Follows the JSON:API specification for error objects, providing a consistent structure -/// for conveying error information to clients. Each field is optional but should be used -/// appropriately to provide meaningful error context. -/// public class JsonApiError { /// - /// A unique identifier for this specific occurrence of the error. + /// Unique identifier for this error. /// - /// - /// Can be used for logging and tracking purposes. Useful when referencing errors - /// in server logs. - /// [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Id { get; set; } /// - /// The HTTP status code applicable to this error. + /// HTTP status code as a string. /// - /// - /// Should match the actual HTTP response status code. Formatted as a string - /// to allow for application-specific non-numeric codes if needed. - /// [JsonPropertyName("status")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Status { get; set; } /// - /// An application-specific error code. + /// Application-specific error code. /// - /// - /// Provides a more specific error categorization than the HTTP status code. - /// Useful for client-side error handling and display. - /// [JsonPropertyName("code")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Code { get; set; } /// - /// A short, human-readable summary of the error. + /// Short, human-readable summary of the error. /// - /// - /// Should be the same for all occurrences of a given error type. - /// Typically corresponds to an HTTP status text for the status. - /// [JsonPropertyName("title")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Title { get; set; } /// - /// A human-readable explanation specific to this occurrence of the error. + /// Detailed explanation of the error. /// - /// - /// Provides more detailed information than the title. Should clarify what went wrong - /// and potentially how to fix it. May include instance-specific details. - /// [JsonPropertyName("detail")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Detail { get; set; } /// - /// Information about the source of the error in the request. + /// Location in the request where the error occurred. /// - /// - /// Helps pinpoint which part of the request caused the error, either in the - /// request body (pointer) or in query parameters (parameter). - /// [JsonPropertyName("source")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ErrorSource? Source { get; set; } /// - /// A meta object containing non-standard meta-information about the error. + /// Additional metadata about the error. /// [JsonPropertyName("meta")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/JsonApiToolkit/Models/Errors/JsonApiErrorResponse.cs b/JsonApiToolkit/Models/Errors/JsonApiErrorResponse.cs index cc5775e..b14b17a 100644 --- a/JsonApiToolkit/Models/Errors/JsonApiErrorResponse.cs +++ b/JsonApiToolkit/Models/Errors/JsonApiErrorResponse.cs @@ -3,26 +3,13 @@ namespace JsonApiToolkit.Models.Errors; /// -/// Represents a JSON:API compliant error response document. +/// JSON:API error response document. /// -/// -/// -/// According to the JSON:API specification, error responses contain an array of error objects -/// in the "errors" member of the top-level document. This class encapsulates that structure. -/// -/// -/// Error responses must not include any other top-level members alongside "errors". -/// -/// public class JsonApiErrorResponse { /// - /// An array of error objects describing the errors that occurred. + /// Collection of error objects. /// - /// - /// The JSON:API specification requires that this array contain at least one error object. - /// Each error object provides information about a specific error condition. - /// [JsonPropertyName("errors")] public List Errors { get; set; } = []; } diff --git a/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs b/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs index 9d3c8c8..3e9aff6 100644 --- a/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs +++ b/JsonApiToolkit/Models/Errors/JsonApiErrorTypes.cs @@ -1,26 +1,22 @@ namespace JsonApiToolkit.Models.Errors; /// -/// Base class for all JSON:API exceptions that provides comprehensive error information. +/// Base class for JSON:API exceptions with status code, code, source, and meta. /// -/// -/// 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. + /// HTTP status code for the error. /// public int StatusCode { get; } /// - /// Application-specific error code for categorizing the error. + /// Application-specific error code. /// public string? Code { get; } /// - /// Source information indicating where the error occurred. + /// Location in the request where the error occurred. /// public ErrorSource? ErrorSource { get; } @@ -30,14 +26,8 @@ public abstract class JsonApiException : Exception public Dictionary? Meta { get; } /// - /// Initializes a new instance of the JsonApiException class. + /// Initializes a new JSON:API exception. /// - /// HTTP status code - /// Error message - /// Application-specific error code - /// Source information - /// Additional metadata - /// Inner exception protected JsonApiException( int statusCode, string message, @@ -56,25 +46,19 @@ protected JsonApiException( } /// -/// Exception representing a 400 Bad Request error. +/// Exception for bad request errors (400). /// public class JsonApiBadRequestException : JsonApiException { /// - /// Initializes a new instance of the JsonApiBadRequestException class. + /// Initializes a new bad request exception. /// - /// Error message public JsonApiBadRequestException(string message) : base(400, message) { } /// - /// Initializes a new instance of the JsonApiBadRequestException class with detailed error information. + /// Initializes a new bad request exception with additional details. /// - /// Error message - /// Application-specific error code - /// Source information - /// Additional metadata - /// Inner exception public JsonApiBadRequestException( string message, string? code = null, @@ -86,25 +70,19 @@ public JsonApiBadRequestException( } /// -/// Exception representing a 404 Not Found error. +/// Exception for not found errors (404). /// public class JsonApiNotFoundException : JsonApiException { /// - /// Initializes a new instance of the JsonApiNotFoundException class. + /// Initializes a new not found exception. /// - /// Error message public JsonApiNotFoundException(string message) : base(404, message) { } /// - /// Initializes a new instance of the JsonApiNotFoundException class with detailed error information. + /// Initializes a new not found exception with additional details. /// - /// Error message - /// Application-specific error code - /// Source information - /// Additional metadata - /// Inner exception public JsonApiNotFoundException( string message, string? code = null, @@ -116,25 +94,19 @@ public JsonApiNotFoundException( } /// -/// Exception representing a 409 Conflict error. +/// Exception for conflict errors (409). /// public class JsonApiConflictException : JsonApiException { /// - /// Initializes a new instance of the JsonApiConflictException class. + /// Initializes a new conflict exception. /// - /// Error message public JsonApiConflictException(string message) : base(409, message) { } /// - /// Initializes a new instance of the JsonApiConflictException class with detailed error information. + /// Initializes a new conflict exception with additional details. /// - /// Error message - /// Application-specific error code - /// Source information - /// Additional metadata - /// Inner exception public JsonApiConflictException( string message, string? code = null, @@ -146,25 +118,19 @@ public JsonApiConflictException( } /// -/// Exception representing a 401 Unauthorized error. +/// Exception for unauthorized errors (401). /// public class JsonApiUnauthorizedException : JsonApiException { /// - /// Initializes a new instance of the JsonApiUnauthorizedException class. + /// Initializes a new unauthorized exception. /// - /// Error message public JsonApiUnauthorizedException(string message) : base(401, message) { } /// - /// Initializes a new instance of the JsonApiUnauthorizedException class with detailed error information. + /// Initializes a new unauthorized exception with additional details. /// - /// Error message - /// Application-specific error code - /// Source information - /// Additional metadata - /// Inner exception public JsonApiUnauthorizedException( string message, string? code = null, @@ -176,25 +142,19 @@ public JsonApiUnauthorizedException( } /// -/// Exception representing a 403 Forbidden error. +/// Exception for forbidden errors (403). /// public class JsonApiForbiddenException : JsonApiException { /// - /// Initializes a new instance of the JsonApiForbiddenException class. + /// Initializes a new forbidden exception. /// - /// Error message public JsonApiForbiddenException(string message) : base(403, message) { } /// - /// Initializes a new instance of the JsonApiForbiddenException class with detailed error information. + /// Initializes a new forbidden exception with additional details. /// - /// Error message - /// Application-specific error code - /// Source information - /// Additional metadata - /// Inner exception public JsonApiForbiddenException( string message, string? code = null, @@ -206,25 +166,19 @@ public JsonApiForbiddenException( } /// -/// Exception representing a 429 Too Many Requests error. +/// Exception for rate limit errors (429). /// public class JsonApiTooManyRequestsException : JsonApiException { /// - /// Initializes a new instance of the JsonApiTooManyRequestsException class. + /// Initializes a new rate limit exception. /// - /// Error message public JsonApiTooManyRequestsException(string message) : base(429, message) { } /// - /// Initializes a new instance of the JsonApiTooManyRequestsException class with detailed error information. + /// Initializes a new rate limit exception with additional details. /// - /// Error message - /// Application-specific error code - /// Source information - /// Additional metadata - /// Inner exception public JsonApiTooManyRequestsException( string message, string? code = null, diff --git a/JsonApiToolkit/Models/Metadata/Links.cs b/JsonApiToolkit/Models/Metadata/Links.cs index 5bd5a45..2a86039 100644 --- a/JsonApiToolkit/Models/Metadata/Links.cs +++ b/JsonApiToolkit/Models/Metadata/Links.cs @@ -3,85 +3,48 @@ namespace JsonApiToolkit.Models.Metadata; /// -/// Represents a collection of hypermedia links in a JSON:API document. +/// Hypermedia links for navigation and pagination. /// -/// -/// Links provide navigation capabilities between resources and related data in a JSON:API document. -/// They enable clients to traverse the API without having to construct URLs manually. -/// public class Links { /// - /// A link to the resource represented by this document. + /// Link to the current resource. /// - /// - /// - /// - /// For resource objects, points to the resource itself. - /// - /// - /// For resource collections, points to the collection. - /// - /// - /// For relationship objects, points to the relationship endpoint. - /// - /// - /// [JsonPropertyName("self")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Self { get; set; } /// - /// A link to the related resource(s) when in a relationship context. + /// Link to the related resource. /// - /// - /// Used in relationship objects to provide direct access to the related resource(s) - /// without requiring the client to extract and construct the URL. - /// [JsonPropertyName("related")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Related { get; set; } /// - /// A link to the first page of data in a paginated collection. + /// Link to the first page. /// - /// - /// Only relevant for paginated collection responses. - /// Typically includes page[number]=1 in the query string. - /// [JsonPropertyName("first")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? First { get; set; } /// - /// A link to the last page of data in a paginated collection. + /// Link to the last page. /// - /// - /// Only relevant for paginated collection responses. - /// Requires knowledge of the total number of pages. - /// [JsonPropertyName("last")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Last { get; set; } /// - /// A link to the previous page of data in a paginated collection. + /// Link to the previous page. /// - /// - /// Only relevant for paginated collection responses. - /// Should be omitted when on the first page. - /// [JsonPropertyName("prev")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Prev { get; set; } /// - /// A link to the next page of data in a paginated collection. + /// Link to the next page. /// - /// - /// Only relevant for paginated collection responses. - /// Should be omitted when on the last page. - /// [JsonPropertyName("next")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Next { get; set; } diff --git a/JsonApiToolkit/Models/Metadata/PaginationMeta.cs b/JsonApiToolkit/Models/Metadata/PaginationMeta.cs index 489bf65..700581b 100644 --- a/JsonApiToolkit/Models/Metadata/PaginationMeta.cs +++ b/JsonApiToolkit/Models/Metadata/PaginationMeta.cs @@ -1,48 +1,27 @@ namespace JsonApiToolkit.Models.Metadata; /// -/// Contains metadata about pagination for JSON:API collection responses. +/// Pagination metadata for collection responses. /// -/// -/// This class provides additional pagination information that complements the pagination links. -/// While pagination links help with navigation, pagination metadata provides the context -/// about the overall size and structure of the paginated collection. -/// public class PaginationMeta { /// - /// The total number of resources in the collection before pagination. + /// Total number of resources across all pages. /// - /// - /// This represents the complete count of resources that match the current filter criteria, - /// regardless of pagination. Used by clients to show total counts or calculate percentages. - /// public int TotalResources { get; set; } /// - /// The total number of pages available given the current page size. + /// Total number of pages. /// - /// - /// Calculated as ceiling(TotalResources / PageSize). This helps clients understand - /// the total page range and can be used to implement pagination controls. - /// public int TotalPages { get; set; } /// - /// The current page number being displayed (1-based). + /// Current page number. /// - /// - /// Corresponds to the page[number] query parameter. Starts at 1 for the first page, - /// allowing clients to track current pagination position. - /// public int CurrentPage { get; set; } /// - /// The number of resources displayed per page. + /// Number of resources per page. /// - /// - /// Corresponds to the page[size] query parameter. Helps clients understand how - /// many resources to expect on each page and calculate positions within the collection. - /// public int PageSize { get; set; } } diff --git a/JsonApiToolkit/Models/Querying/Filtering/FilterGroup.cs b/JsonApiToolkit/Models/Querying/Filtering/FilterGroup.cs index 7e1cc59..2826843 100644 --- a/JsonApiToolkit/Models/Querying/Filtering/FilterGroup.cs +++ b/JsonApiToolkit/Models/Querying/Filtering/FilterGroup.cs @@ -1,45 +1,23 @@ namespace JsonApiToolkit.Models.Querying.Filtering; /// -/// Represents a group of filter conditions combined with a logical operator. +/// Group of filter conditions combined with a logical operator (AND/OR/NOT). +/// Supports nested groups for complex expressions. /// -/// -/// -/// Provides a hierarchical structure for complex filtering scenarios with nested conditions. -/// Each group contains a list of individual filter parameters and can also contain nested -/// groups for representing complex logical expressions. -/// -/// -/// Filter groups can be combined with different logical operators (AND, OR, NOT) to build -/// sophisticated query conditions that map to SQL WHERE clauses. -/// -/// public class FilterGroup { /// - /// The logical operator to apply when combining filter conditions within this group. + /// Logical operator for combining filters and groups. /// - /// - /// Defaults to AND, meaning all conditions in the group must be satisfied. - /// Other options include OR (any condition may be satisfied) and NOT (negate the group). - /// public LogicalOperator LogicalOperator { get; set; } = LogicalOperator.And; /// - /// The list of individual filter conditions contained in this group. + /// Individual filter conditions. /// - /// - /// Each FilterParameter represents a single condition on a specific field. - /// These conditions are combined using the logical operator specified for the group. - /// public List Filters { get; set; } = []; /// - /// Nested filter groups that can be used to create complex logical expressions. + /// Nested filter groups for complex expressions. /// - /// - /// Allows for hierarchical grouping of conditions, similar to parentheses in logical expressions. - /// Each nested group has its own logical operator that applies to its own conditions and sub-groups. - /// public List Groups { get; set; } = []; } diff --git a/JsonApiToolkit/Models/Querying/Filtering/FilterOperator.cs b/JsonApiToolkit/Models/Querying/Filtering/FilterOperator.cs index 06da51c..13aa4d9 100644 --- a/JsonApiToolkit/Models/Querying/Filtering/FilterOperator.cs +++ b/JsonApiToolkit/Models/Querying/Filtering/FilterOperator.cs @@ -1,68 +1,30 @@ namespace JsonApiToolkit.Models.Querying.Filtering; /// -/// Defines comparison operators used in JSON:API filter expressions. +/// Comparison operators for filter expressions. /// -/// -/// These operators map to SQL comparison operators and determine how field values -/// are compared against filter values. They support a wide range of comparison types -/// from basic equality to range checks and pattern matching. -/// public enum FilterOperator { - /// - /// Equal (default). - /// + /// Equal to. Eq, - - /// - /// Not equal. - /// + /// Not equal to. Ne, - - /// - /// Greater than. - /// + /// Greater than. Gt, - - /// - /// Greater than or equal. - /// + /// Greater than or equal to. Ge, - - /// - /// Less than. - /// + /// Less than. Lt, - - /// - /// Less than or equal. - /// - + /// Less than or equal to. Le, - - /// - /// Contains. - /// + /// String contains (case-insensitive). Like, - - /// - /// In list. - /// + /// In list of values. In, - - /// - /// Not in list. - /// + /// Not in list of values. Nin, - - /// - /// Is null. - /// + /// Is null. IsNull, - - /// - /// Is not null. - /// + /// Is not null. IsNotNull, } diff --git a/JsonApiToolkit/Models/Querying/Filtering/FilterParameter.cs b/JsonApiToolkit/Models/Querying/Filtering/FilterParameter.cs index 63f2748..9ea27a6 100644 --- a/JsonApiToolkit/Models/Querying/Filtering/FilterParameter.cs +++ b/JsonApiToolkit/Models/Querying/Filtering/FilterParameter.cs @@ -1,45 +1,22 @@ namespace JsonApiToolkit.Models.Querying.Filtering; /// -/// Represents a single filter condition specifying a field, operator, and comparison value. +/// Single filter condition with field, operator, and value. /// -/// -/// -/// Filter parameters are the basic building blocks of the filtering system. Each parameter -/// represents a condition like "age >= 18" or "name LIKE 'Smith'". -/// -/// -/// Filter parameters are typically combined in filter groups using logical operators. -/// -/// public class FilterParameter { /// - /// The name of the entity field to filter on. + /// Field to filter on. /// - /// - /// Can refer to direct entity properties or, in some cases, nested properties using dot notation - /// (e.g., "user.address.city"). The field name is typically the JSON property name (camelCase). - /// public string Field { get; set; } = string.Empty; /// - /// The comparison operator to apply in the filter condition. + /// Comparison operator. /// - /// - /// Defines how the field value should be compared against the filter value. - /// Default is equality (Eq). Other options include greater than, less than, - /// pattern matching, and existence checks. - /// public FilterOperator Operator { get; set; } = FilterOperator.Eq; /// - /// The value to compare against the field value. + /// Value to compare against. /// - /// - /// The string representation of the comparison value. Will be converted to the appropriate - /// type based on the field's type during filter application. For 'in' and 'nin' operators, - /// this can be a comma-separated list of values. - /// public string Value { get; set; } = string.Empty; } diff --git a/JsonApiToolkit/Models/Querying/Filtering/IncludeFilter.cs b/JsonApiToolkit/Models/Querying/Filtering/IncludeFilter.cs index 92864ea..55cccd9 100644 --- a/JsonApiToolkit/Models/Querying/Filtering/IncludeFilter.cs +++ b/JsonApiToolkit/Models/Querying/Filtering/IncludeFilter.cs @@ -1,44 +1,28 @@ namespace JsonApiToolkit.Models.Querying.Filtering; /// -/// Represents a filter that should be applied to an included relationship in a JSON:API query. +/// Filter applied to an included relationship (e.g., filter[author.name]=John). /// -/// -/// Include filters allow filtering of related resources that are being included in the response. -/// For example, when including comments on a post, you can filter to only include comments from a specific author. -/// public class IncludeFilter { /// - /// The relationship path to filter on, using JSON property names. + /// Relationship path (e.g., "author"). /// - /// - /// This is the navigation property path from the main entity to the relationship being filtered. - /// For example: "cveComments" for a direct relationship, or "cveComments.author" for a nested relationship. - /// public string RelationshipPath { get; set; } = string.Empty; /// - /// The field path within the related entity to filter on. + /// Field path within the relationship (e.g., "name"). /// - /// - /// This is the property path within the related entity that should be filtered. - /// Can be a simple property name like "companyCode" or a nested path like "author.department". - /// public string FieldPath { get; set; } = string.Empty; /// - /// The filter parameter containing the operator and value for the filter condition. + /// Filter condition to apply. /// public FilterParameter Filter { get; set; } = new(); /// - /// The full field path from the original filter parameter. + /// Full path combining relationship and field (e.g., "author.name"). /// - /// - /// This combines RelationshipPath and FieldPath with a dot separator. - /// For example: "cveComments.companyCode" - /// public string FullPath => string.IsNullOrEmpty(FieldPath) ? RelationshipPath : $"{RelationshipPath}.{FieldPath}"; } diff --git a/JsonApiToolkit/Models/Querying/Filtering/LogicalOperator.cs b/JsonApiToolkit/Models/Querying/Filtering/LogicalOperator.cs index 0d3c1e8..7f11255 100644 --- a/JsonApiToolkit/Models/Querying/Filtering/LogicalOperator.cs +++ b/JsonApiToolkit/Models/Querying/Filtering/LogicalOperator.cs @@ -1,27 +1,14 @@ namespace JsonApiToolkit.Models.Querying.Filtering; /// -/// Defines logical operators for combining multiple filter conditions. +/// Logical operators for combining filter conditions. /// -/// -/// These operators determine how multiple conditions within a filter group are combined. -/// They correspond to the SQL logical operators AND, OR, and NOT, and are used to build -/// complex filtering expressions. -/// public enum LogicalOperator { - /// - /// Logical AND (default). - /// + /// All conditions must be true. And, - - /// - /// Logical OR. - /// + /// At least one condition must be true. Or, - - /// - /// Logical NOT. - /// + /// Negates the condition. Not, } diff --git a/JsonApiToolkit/Models/Querying/PaginationParameters.cs b/JsonApiToolkit/Models/Querying/PaginationParameters.cs index 01deb2e..5b7c4d2 100644 --- a/JsonApiToolkit/Models/Querying/PaginationParameters.cs +++ b/JsonApiToolkit/Models/Querying/PaginationParameters.cs @@ -1,33 +1,17 @@ namespace JsonApiToolkit.Models.Querying; /// -/// Contains pagination parameters for limiting and paging through collection resources. +/// Pagination parameters (page[number] and page[size]). /// -/// -/// -/// Implements the JSON:API pagination strategy with page-based pagination using the -/// page[number] and page[size] query parameters. -/// -/// -/// Pagination is used to limit the number of resources returned in a response and to -/// navigate through large collections across multiple requests. -/// -/// public class PaginationParameters { /// - /// The current page number (1-based). + /// Page number (1-based). /// - /// - /// Corresponds to the page[number] query parameter in JSON:API requests. - /// public int Number { get; set; } = 1; /// - /// The number of resources to include per page. + /// Number of resources per page. /// - /// - /// Corresponds to the page[size] query parameter in JSON:API requests. - /// public int Size { get; set; } = 10; } diff --git a/JsonApiToolkit/Models/Querying/QueryParameters.cs b/JsonApiToolkit/Models/Querying/QueryParameters.cs index 80145b6..006b258 100644 --- a/JsonApiToolkit/Models/Querying/QueryParameters.cs +++ b/JsonApiToolkit/Models/Querying/QueryParameters.cs @@ -3,54 +3,27 @@ namespace JsonApiToolkit.Models.Querying; /// -/// Encapsulates all JSON:API query parameters (pagination, filtering, sorting, and inclusion). +/// All JSON:API query parameters: pagination, filtering, sorting, and includes. /// -/// -/// -/// This class aggregates all the possible query parameters defined in the JSON:API specification -/// into a single structure. It's used to parse and apply query parameters consistently across -/// the API implementation. -/// -/// -/// All properties are optional, allowing for partial application of query parameters. -/// -/// public class QueryParameters { /// - /// Parameters for limiting and paging through collection resources. + /// Pagination parameters (page number and size). /// - /// - /// Based on the page[number] and page[size] query parameter structure. - /// Used to implement pagination for large collections. - /// public PaginationParameters? Pagination { get; set; } /// - /// Parameters for filtering resources based on attribute values. + /// Filter criteria with conditions and logical operators. /// - /// - /// Represents parsed filter[fieldName] query parameters. Can include simple filters, - /// complex filters with operators, and logical groups of filters. - /// public FilterGroup? Filter { get; set; } /// - /// Parameters for ordering collection results by specified fields. + /// Sort parameters (field and direction). /// - /// - /// Based on the sort query parameter. Supports multiple sort fields and - /// ascending/descending direction for each field. - /// public List? Sort { get; set; } /// - /// List of relationship paths to include in the response. + /// Relationships to include in the response. /// - /// - /// Based on the include query parameter. Specifies which related resources - /// should be included in the response to reduce the number of API requests needed. - /// Supports dot notation for nested relationships (e.g., "author.comments"). - /// public List? Include { get; set; } } diff --git a/JsonApiToolkit/Models/Querying/SortParameter.cs b/JsonApiToolkit/Models/Querying/SortParameter.cs index 6226040..72ddf97 100644 --- a/JsonApiToolkit/Models/Querying/SortParameter.cs +++ b/JsonApiToolkit/Models/Querying/SortParameter.cs @@ -1,40 +1,17 @@ namespace JsonApiToolkit.Models.Querying; /// -/// Represents a sort criterion specifying a field and direction for ordering results. +/// Sort criterion with field and direction. /// -/// -/// Each sort parameter defines how a collection should be sorted by a specific field. -/// Multiple sort parameters can be combined in priority order to define complex sorting. -/// public class SortParameter { /// - /// The name of the field to sort by. + /// Field to sort by. /// - /// - /// Corresponds to the field name in the sort query parameter. - /// Field names typically match JSON property names (camelCase). - /// public string Field { get; set; } = string.Empty; /// - /// Indicates whether to sort in descending order. + /// Sort in descending order (true) or ascending (false). /// - /// - /// - /// - /// - /// When true, sorts in descending order (high to low). - /// - /// - /// When false, sorts in ascending order (low to high). - /// - /// - /// - /// - /// In JSON:API query parameters, descending sort is indicated by a minus prefix on the field name. - /// - /// public bool IsDescending { get; set; } } diff --git a/JsonApiToolkit/Models/Resources/Relationship.cs b/JsonApiToolkit/Models/Resources/Relationship.cs index c6a1064..22bc5b5 100644 --- a/JsonApiToolkit/Models/Resources/Relationship.cs +++ b/JsonApiToolkit/Models/Resources/Relationship.cs @@ -5,41 +5,13 @@ namespace JsonApiToolkit.Models.Resources; /// -/// Represents a relationship between resources in a JSON:API document. +/// Relationship to other resources (to-one or to-many). /// -/// -/// -/// Relationships in JSON:API can be to-one (a single resource identifier) or to-many -/// (an array of resource identifiers). This class supports both types through its Data property. -/// -/// -/// Relationships may also include links to related resources and relationship manipulation endpoints. -/// -/// public class Relationship { /// - /// Resource linkage defining the related resource(s). + /// Resource linkage: null, single ResourceIdentifier, or array of ResourceIdentifiers. /// - /// - /// - /// Can be: - /// - /// - /// null (for empty to-one relationships) - /// - /// - /// A single ResourceIdentifier object (for to-one relationships) - /// - /// - /// A collection of ResourceIdentifier objects (for to-many relationships) - /// - /// - /// - /// - /// Resource identifiers contain only the type and id of the related resources. - /// - /// [JsonPropertyName("data")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public object? Data { get; set; } @@ -47,22 +19,6 @@ public class Relationship /// /// Links related to this relationship. /// - /// - /// - /// Typically includes: - /// - /// - /// "self": Link to the relationship itself (for manipulation) - /// - /// - /// "related": Link to the related resource(s) - /// - /// - /// - /// - /// These links allow clients to navigate and manipulate relationships without constructing URLs manually. - /// - /// [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Links? Links { get; set; } diff --git a/JsonApiToolkit/Models/Resources/ResourceIdentifier.cs b/JsonApiToolkit/Models/Resources/ResourceIdentifier.cs index baad2bb..57df9b2 100644 --- a/JsonApiToolkit/Models/Resources/ResourceIdentifier.cs +++ b/JsonApiToolkit/Models/Resources/ResourceIdentifier.cs @@ -3,32 +3,19 @@ namespace JsonApiToolkit.Models.Resources; /// -/// Represents a resource pointer in JSON:API, containing only the resource type and ID. +/// Resource pointer with type and ID only (used in relationships). /// -/// -/// Resource identifiers are used in relationship linkage to reference related resources -/// without including their attributes. They provide the minimum information needed to -/// locate and identify a specific resource. -/// public class ResourceIdentifier { /// - /// The unique identifier of the resource within its type. + /// Unique resource identifier. /// - /// - /// Combined with the type, forms a globally unique identifier for the resource. - /// Must be a string, even if the underlying ID is numeric. - /// [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; /// - /// The JSON:API resource type. + /// Resource type. /// - /// - /// Identifies the type of resource being referenced. Typically corresponds to an entity - /// type in the system and follows the JSON:API naming convention (usually camelCase). - /// [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; } diff --git a/JsonApiToolkit/Models/Resources/ResourceObject.cs b/JsonApiToolkit/Models/Resources/ResourceObject.cs index 19c7ca2..0234d8d 100644 --- a/JsonApiToolkit/Models/Resources/ResourceObject.cs +++ b/JsonApiToolkit/Models/Resources/ResourceObject.cs @@ -5,73 +5,39 @@ namespace JsonApiToolkit.Models.Resources; /// -/// Represents a resource object in a JSON:API document, containing a resource's identity, attributes, and relationships. +/// Resource object with id, type, attributes, relationships, and links. /// -/// -/// -/// Resource objects are the primary data structures in JSON:API responses. They encapsulate -/// the identity, attributes, and relationships of domain entities in a standardized format. -/// -/// -/// Every resource object must contain at least a type and id. Attributes and relationships are optional. -/// -/// public class ResourceObject { /// - /// The unique identifier of the resource within its type. + /// Unique resource identifier. /// - /// - /// Combined with the type, forms a globally unique identifier for the resource. - /// Must be a string, even if the underlying ID is numeric. - /// [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; /// - /// The JSON:API resource type. + /// Resource type (e.g., "articles", "people"). /// - /// - /// Identifies the type of resource. Typically corresponds to an entity type in the system - /// and follows the JSON:API naming convention (usually camelCase). - /// [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; /// - /// A dictionary of resource attributes. + /// Resource attributes (properties). /// - /// - /// - /// Contains all the resource's attributes as key-value pairs. Keys are attribute names (camelCase) - /// and values are the attribute values, which may be of any JSON-compatible type. - /// - /// - /// Attributes represent information directly associated with the resource, rather than relationships - /// to other resources. - /// - /// [JsonPropertyName("attributes")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? Attributes { get; set; } /// - /// A dictionary of resource relationships. + /// Relationships to other resources. /// - /// - /// - /// Contains all the resource's relationships as key-value pairs. Keys are relationship names (camelCase) - /// and values are Relationship objects that define the linkage to other resources. - /// - /// - /// Relationships represent connections between resources and can be to-one or to-many. - /// - /// [JsonPropertyName("relationships")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? Relationships { get; set; } - /// + /// + /// Links related to this resource. + /// [JsonPropertyName("links")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Links? Links { get; set; } diff --git a/JsonApiToolkit/Models/Validation/IncludePattern.cs b/JsonApiToolkit/Models/Validation/IncludePattern.cs index 1c9abf7..dd67302 100644 --- a/JsonApiToolkit/Models/Validation/IncludePattern.cs +++ b/JsonApiToolkit/Models/Validation/IncludePattern.cs @@ -3,39 +3,38 @@ namespace JsonApiToolkit.Models.Validation; /// -/// Represents a compiled include pattern for efficient matching. +/// Compiled include pattern for efficient matching (supports wildcards). /// public class IncludePattern { /// - /// Gets the original pattern string. + /// The original pattern string. /// public string OriginalPattern { get; } /// - /// Gets whether this pattern contains wildcards. + /// Whether the pattern contains wildcards. /// public bool IsWildcard { get; } /// - /// Gets the type of pattern. + /// Type of pattern (exact, wildcard, etc.). /// public PatternType Type { get; } /// - /// Gets the compiled regex for wildcard patterns. + /// Compiled regex for wildcard matching. /// public Regex? CompiledRegex { get; } /// - /// Gets the pattern parts for non-wildcard patterns. + /// Pattern split into parts for exact matching. /// public string[]? PatternParts { get; } /// - /// Initializes a new instance of the class. + /// Initializes a new include pattern. /// - /// The include pattern string. public IncludePattern(string pattern) { OriginalPattern = pattern ?? throw new ArgumentNullException(nameof(pattern)); @@ -93,10 +92,8 @@ private Regex CompileWildcardPattern(string pattern) } /// - /// Checks if the given include matches this pattern. + /// Checks if an include path matches this pattern. /// - /// The include to check. - /// True if the include matches, false otherwise. public bool Matches(string include) { if (string.IsNullOrEmpty(include)) @@ -152,27 +149,16 @@ public bool Matches(string include) } /// -/// Specifies the type of include pattern. +/// Type of include pattern. /// public enum PatternType { - /// - /// Exact string match pattern. - /// + /// Exact match with no wildcards. Exact, - - /// - /// Top-level wildcard (*) that matches any single segment. - /// + /// Top-level wildcard (*). TopLevelWildcard, - - /// - /// Single-level wildcard (e.g., author.*) that matches one level deep. - /// + /// Single-level wildcard (e.g., author.*). SingleLevelWildcard, - - /// - /// Complex wildcard pattern (reserved for future use). - /// + /// Complex wildcard pattern. ComplexWildcard, } diff --git a/JsonApiToolkit/Parsing/JsonApiFilterParser.cs b/JsonApiToolkit/Parsing/JsonApiFilterParser.cs index c690f4c..f5185dc 100644 --- a/JsonApiToolkit/Parsing/JsonApiFilterParser.cs +++ b/JsonApiToolkit/Parsing/JsonApiFilterParser.cs @@ -4,24 +4,15 @@ namespace JsonApiToolkit.Parsing; /// -/// Parses JSON:API filter query string parameters into structured filter objects. +/// Parses filter query parameters with complex syntax, operators, and logical groups. /// -/// -/// Handles complex filtering syntax, logical operators, and nested filter groups. -/// Supports both simple filters and advanced filter syntax with operators, logical groups, and nested conditions. -/// public static class JsonApiFilterParser { /// - /// The separator used to split complex filter query parameters. + /// Separator used for parsing filter syntax. /// public static readonly string[] s_separator = ["]["]; - /// - /// Parses a filter operator string to the corresponding FilterOperator enum value. - /// - /// The operator string to parse - /// The corresponding FilterOperator enum value, defaults to Eq if not recognized private static FilterOperator ParseFilterOperator(string operatorStr) { return operatorStr.ToLowerInvariant() switch @@ -42,28 +33,8 @@ private static FilterOperator ParseFilterOperator(string operatorStr) } /// - /// Parses a complex filter query parameter into a structured filter parameter and adds it to a filter group. + /// Parses filter[field][operator]=value syntax. /// - /// The query parameter key (e.g., "filter[name][eq]") - /// The query parameter value - /// The filter group to add the parsed filter to - /// - /// - /// Handles filter syntax in the format "filter[field][operator]" where: - /// - /// - /// field is the property name to filter on - /// - /// - /// operator is one of: eq, ne, gt, ge, lt, le, like, in, nin, isnull, isnotnull - /// - /// - /// - /// - /// Extracts the field name and operator from the key and creates a FilterParameter with the - /// appropriate field, operator, and value, then adds it to the provided filter group. - /// - /// public static void ParseComplexFilter(string key, string value, FilterGroup group) { string[] keyParts = key.Substring(7, key.Length - 8).Split("]["); @@ -84,96 +55,8 @@ public static void ParseComplexFilter(string key, string value, FilterGroup grou } /// - /// Parses a logical grouping of filters (AND, OR, NOT) from query parameters. + /// Parses filter[or][0][field]=value or filter[not][0][field]=value syntax. /// - /// The HTTP request containing the query parameters. - /// - /// The name of the logical group. For example, "or" or "not". This name indicates how the filters within - /// this group should be combined logically. - /// - /// - /// The logical operator (AND, OR, NOT) to apply to the entire group of filters. - /// - /// - /// The parent filter group to which this new logical group will be added. - /// - /// - /// - /// This method handles special naming patterns in filter query parameters used to group multiple filter - /// conditions together. The syntax uses indices (e.g., [0], [1]) to differentiate individual conditions - /// within a logical group. - /// - /// - /// For example, consider the following query parameters: - /// - /// - /// - /// - /// filter[or][0][name]=Alice - /// - /// - /// - /// - /// filter[or][1][age][gt]=18 - /// - /// - /// - /// - /// In this example: - /// - /// - /// - /// The group name "or" indicates that the two conditions should be combined using a logical OR. - /// - /// - /// - /// - /// The index [0] identifies the first condition (name equals "Alice"), and [1] identifies the second condition - /// (age greater than 18). - /// - /// - /// - /// - /// - /// The indices are essential because they allow multiple filter conditions to be grouped under the same logical operator. - /// Without these indices, the parser would not know how many conditions belong to the group nor how to separate them. - /// - /// - /// You can also have multiple logical groups in the same request. For example: - /// - /// - /// - /// - /// filter[or][0][name]=Alice - /// - /// - /// - /// - /// filter[or][1][name]=Bob - /// - /// - /// - /// - /// filter[not][0][status]=inactive - /// - /// - /// - /// - /// In this example: - /// - /// - /// - /// The "or" group requires that either the name is "Alice" or "Bob". - /// - /// - /// - /// - /// The "not" group excludes resources where the status is "inactive". - /// - /// - /// - /// - /// public static void ParseLogicalGroup( HttpRequest request, string groupName, @@ -198,10 +81,11 @@ FilterGroup parentGroup foreach (var indexGroup in indexGroups) { - var condition = new FilterParameter(); - + // Create a new FilterParameter for each filter in the group foreach (var item in indexGroup) { + var condition = new FilterParameter(); + string restOfKey = item.Key.Substring( $"filter[{groupName}][{indexGroup.Key}][".Length ); @@ -219,9 +103,10 @@ FilterGroup parentGroup } condition.Value = request.Query[item.Key].ToString(); - } - newGroup.Filters.Add(condition); + // Add each condition to the group + newGroup.Filters.Add(condition); + } } if (newGroup.Filters.Count > 0) diff --git a/JsonApiToolkit/Parsing/JsonApiQueryParser.cs b/JsonApiToolkit/Parsing/JsonApiQueryParser.cs index 9fe91f1..aeec434 100644 --- a/JsonApiToolkit/Parsing/JsonApiQueryParser.cs +++ b/JsonApiToolkit/Parsing/JsonApiQueryParser.cs @@ -7,31 +7,8 @@ namespace JsonApiToolkit.Parsing; /// -/// Parses JSON:API compliant query parameters from HTTP requests into structured query objects. +/// Parses JSON:API query parameters: pagination, filtering, sorting, and includes. /// -/// -/// -/// Provides comprehensive parsing of all JSON:API query parameters including: -/// -/// -/// Pagination (page[number] and page[size]) -/// -/// -/// Filtering (filter[field] and complex filters) -/// -/// -/// Sorting (sort=field,-descendingField) -/// -/// -/// Inclusion (include=relationship1,relationship2) -/// -/// -/// -/// -/// The resulting object can be used with -/// to apply these parameters to Entity Framework queries. -/// -/// public static class JsonApiQueryParser { private const int DEFAULT_PAGE_SIZE = 10; @@ -39,77 +16,26 @@ public static class JsonApiQueryParser private const int MAX_PAGE_SIZE = 100; /// - /// Parses all JSON:API query parameters from an HTTP request into a structured QueryParameters object. + /// Parses JSON:API query parameters from an HTTP request. /// - /// The HTTP request containing the query parameters - /// A QueryParameters object containing all parsed query parameters - /// - /// - /// This method parses: - /// - /// - /// Pagination parameters: - /// - /// - /// page[number]: The page number (starting from 1) - /// - /// - /// page[size]: The page size (limited to 1-100) - /// - /// - /// - /// - /// Filter parameters: - /// - /// - /// Simple: filter[field]=value - /// - /// - /// Complex: filter[field][operator]=value - /// - /// - /// Logical groups: filter[or][0][field]=value - /// - /// - /// - /// - /// Sort parameters: sort=field1,-field2 (minus prefix for descending) - /// - /// - /// Include parameters: include=relationship1,relationship2 - /// - /// - /// - /// - /// The method applies reasonable defaults and constraints: - /// - /// - /// Page number defaults to 1 if invalid - /// - /// - /// Page size is clamped between 1-100 - /// - /// - /// Field names are properly normalized - /// - /// - /// - /// public static QueryParameters Parse(HttpRequest request) { var queryParams = new QueryParameters(); - if ( - request.Query.TryGetValue("page[number]", out StringValues pageNumber) - && request.Query.TryGetValue("page[size]", out StringValues pageSize) - ) + // Check for pagination parameters - allow either or both to be specified + bool hasPageNumber = request.Query.TryGetValue("page[number]", out StringValues pageNumber); + bool hasPageSize = request.Query.TryGetValue("page[size]", out StringValues pageSize); + + if (hasPageNumber || hasPageSize) { queryParams.Pagination = new PaginationParameters { - Number = int.TryParse(pageNumber, out int num) ? Math.Max(1, num) : 1, - Size = int.TryParse(pageSize, out int size) - ? Math.Clamp(size, MIN_PAGE_SIZE, MAX_PAGE_SIZE) - : DEFAULT_PAGE_SIZE, + Number = + hasPageNumber && int.TryParse(pageNumber, out int num) ? Math.Max(1, num) : 1, // Default to page 1 if not specified or invalid + Size = + hasPageSize && int.TryParse(pageSize, out int size) + ? Math.Clamp(size, MIN_PAGE_SIZE, MAX_PAGE_SIZE) + : DEFAULT_PAGE_SIZE, // Use default size if not specified or invalid }; } diff --git a/JsonApiToolkit/Services/IJsonApiQueryParser.cs b/JsonApiToolkit/Services/IJsonApiQueryParser.cs new file mode 100644 index 0000000..fb61865 --- /dev/null +++ b/JsonApiToolkit/Services/IJsonApiQueryParser.cs @@ -0,0 +1,15 @@ +using JsonApiToolkit.Models.Querying; +using Microsoft.AspNetCore.Http; + +namespace JsonApiToolkit.Services; + +/// +/// Service interface for parsing JSON:API query parameters. +/// +public interface IJsonApiQueryParser +{ + /// + /// Parses JSON:API query parameters from an HTTP request. + /// + QueryParameters Parse(HttpRequest request); +} diff --git a/JsonApiToolkit/Services/JsonApiQueryParserService.cs b/JsonApiToolkit/Services/JsonApiQueryParserService.cs new file mode 100644 index 0000000..327d53f --- /dev/null +++ b/JsonApiToolkit/Services/JsonApiQueryParserService.cs @@ -0,0 +1,62 @@ +using JsonApiToolkit.Models.Querying; +using JsonApiToolkit.Parsing; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace JsonApiToolkit.Services; + +/// +/// Service implementation for parsing JSON:API query parameters. +/// +public class JsonApiQueryParserService : IJsonApiQueryParser +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the query parser service. + /// + public JsonApiQueryParserService(ILogger logger) + { + _logger = logger; + } + + /// + /// Parses JSON:API query parameters from an HTTP request. + /// + public QueryParameters Parse(HttpRequest request) + { + var queryParams = JsonApiQueryParser.Parse(request); + + if ( + request.Query.Keys.Any(k => k.StartsWith("filter", StringComparison.OrdinalIgnoreCase)) + && (queryParams.Filter?.Filters?.Count ?? 0) == 0 + ) + { + _logger.LogWarning( + "Filter parameters detected but no valid filters parsed. Check syntax: filter[field][operator]=value" + ); + } + + if ( + request.Query.Keys.Any(k => k.StartsWith("sort", StringComparison.OrdinalIgnoreCase)) + && (queryParams.Sort?.Count ?? 0) == 0 + ) + { + _logger.LogWarning( + "Sort parameter detected but no valid sorts parsed. Check syntax: sort=field1,-field2" + ); + } + + if ( + request.Query.Keys.Any(k => k.StartsWith("page", StringComparison.OrdinalIgnoreCase)) + && queryParams.Pagination == null + ) + { + _logger.LogWarning( + "Page parameters detected but no pagination parsed. Use: page[number]=1&page[size]=10" + ); + } + + return queryParams; + } +} diff --git a/JsonApiToolkit/Validation/IncludePatternValidator.cs b/JsonApiToolkit/Validation/IncludePatternValidator.cs index cb54959..5cfa03b 100644 --- a/JsonApiToolkit/Validation/IncludePatternValidator.cs +++ b/JsonApiToolkit/Validation/IncludePatternValidator.cs @@ -6,30 +6,28 @@ namespace JsonApiToolkit.Validation; /// -/// Provides startup validation for include patterns in AllowedIncludesAttribute. +/// Validates include patterns in AllowedIncludesAttribute at startup. /// public class IncludePatternValidator : IApplicationModelProvider { private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the include pattern validator. /// - /// The logger instance. public IncludePatternValidator(ILogger logger) { _logger = logger; } /// - /// Gets the order in which providers are executed. + /// Gets the order in which the provider should be executed. /// public int Order => -1000; /// - /// Validates include patterns during application startup. + /// Validates include patterns when providers are executing. /// - /// The application model provider context. public void OnProvidersExecuting(ApplicationModelProviderContext context) { foreach (var controller in context.Result.Controllers) @@ -55,11 +53,7 @@ public void OnProvidersExecuting(ApplicationModelProviderContext context) /// /// Called after providers have executed. /// - /// The application model provider context. - public void OnProvidersExecuted(ApplicationModelProviderContext context) - { - // No action needed - } + public void OnProvidersExecuted(ApplicationModelProviderContext context) { } private void ValidatePatterns(string[] patterns, string controllerName, string actionName) { diff --git a/JsonApiToolkit/Validation/IncludeValidator.cs b/JsonApiToolkit/Validation/IncludeValidator.cs index 73db3ef..ee93514 100644 --- a/JsonApiToolkit/Validation/IncludeValidator.cs +++ b/JsonApiToolkit/Validation/IncludeValidator.cs @@ -3,16 +3,13 @@ namespace JsonApiToolkit.Validation; /// -/// Provides validation logic for JSON:API include parameters. +/// Validates include parameters against allowed patterns. /// public static class IncludeValidator { /// /// Validates requested includes against allowed patterns. /// - /// The includes requested by the client. - /// The allowed include patterns. - /// A validation result containing any forbidden includes. public static ValidationResult ValidateIncludes( IEnumerable requestedIncludes, IEnumerable allowedPatterns @@ -37,21 +34,16 @@ IEnumerable allowedPatterns } /// - /// Checks if a specific include is allowed by any of the patterns. + /// Checks if an include path matches any of the allowed patterns. /// - /// The include to check. - /// The allowed patterns. - /// True if the include is allowed, false otherwise. public static bool IsIncludeAllowed(string include, IEnumerable patterns) { return patterns.Any(pattern => pattern.Matches(include)); } /// - /// Compiles pattern strings into IncludePattern objects for efficient matching. + /// Compiles pattern strings into IncludePattern objects. /// - /// The pattern strings to compile. - /// A collection of compiled patterns. public static IEnumerable CompilePatterns(IEnumerable patternStrings) { return patternStrings.Select(p => new IncludePattern(p)); @@ -59,17 +51,17 @@ public static IEnumerable CompilePatterns(IEnumerable pa } /// -/// Represents the result of include validation. +/// Result of include validation. /// public class ValidationResult { /// - /// Gets or sets whether all requested includes are valid. + /// Whether all includes are valid. /// public bool IsValid { get; set; } /// - /// Gets or sets the list of forbidden includes. + /// List of forbidden includes. /// public List ForbiddenIncludes { get; set; } = new(); } diff --git a/docs/docs/debugging.md b/docs/docs/debugging.md new file mode 100644 index 0000000..bc44991 --- /dev/null +++ b/docs/docs/debugging.md @@ -0,0 +1,49 @@ +# Debugging Guide + +## Enable Logging + +```json +{ + "Serilog": { + "MinimumLevel": { + "Override": { + "JsonApiToolkit": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + } + } +} +``` + +Or for Microsoft.Extensions.Logging: + +```json +{ + "Logging": { + "LogLevel": { + "JsonApiToolkit": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + } +} +``` + +## What Gets Logged + +**Information:** +- Empty result explanations +- Complex queries (>20 filters) + +**Warning:** +- Invalid property/field names +- Parameter parsing issues +- Large unpaginated results (>1000) +- Include mapping problems + +**Debug:** +- Query summaries (filters/sorts/includes/pagination) +- Include strategy (SingleQuery/SplitQuery) +- Execution summaries (counts) + +**EF Core SQL (Information level):** +- Actual SQL queries executed diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml index c9afe62..00c61ac 100644 --- a/docs/docs/toc.yml +++ b/docs/docs/toc.yml @@ -10,5 +10,7 @@ href: security.md - name: API Controller Examples href: api-controller-examples.md +- name: Debugging + href: debugging.md - name: Integrations href: integrations/toc.yml