From 0fa5628e71e1c9712550ab2f4cf714136ca8146d Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Mon, 29 Sep 2025 19:32:02 +0200 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=F0=9F=9A=91=EF=B8=8F=20Initial=20wo?= =?UTF-8?q?rking=20fix.=20Needs=20further=20testing=20and=20validation.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEBUG_LOGGING.md | 189 +++++++++ .../Controllers/JsonApiControllerTests.cs | 15 +- .../AllowedIncludesIntegrationTests.cs | 8 + .../Controllers/JsonApiController.cs | 115 +++++- .../Querying/FilterExpressionBuilder.cs | 374 ++++++++++++++++-- .../Extensions/Querying/FilterHandler.cs | 26 +- .../Extensions/Querying/QueryHelpers.cs | 10 +- .../Extensions/Querying/SortingHandler.cs | 12 +- .../Extensions/ServiceCollectionExtensions.cs | 4 + JsonApiToolkit/JsonApiToolkit.csproj | 2 +- JsonApiToolkit/Mapping/JsonApiMapper.cs | 46 ++- JsonApiToolkit/Parsing/JsonApiFilterParser.cs | 10 +- JsonApiToolkit/Parsing/JsonApiQueryParser.cs | 19 +- .../Services/IJsonApiQueryParser.cs | 17 + .../Services/JsonApiQueryParserService.cs | 80 ++++ 15 files changed, 846 insertions(+), 81 deletions(-) create mode 100644 DEBUG_LOGGING.md create mode 100644 JsonApiToolkit/Services/IJsonApiQueryParser.cs create mode 100644 JsonApiToolkit/Services/JsonApiQueryParserService.cs diff --git a/DEBUG_LOGGING.md b/DEBUG_LOGGING.md new file mode 100644 index 0000000..f434f69 --- /dev/null +++ b/DEBUG_LOGGING.md @@ -0,0 +1,189 @@ +# Debug Logging Guide for JsonApiToolkit + +JsonApiToolkit now includes comprehensive debug logging throughout the query processing pipeline. This guide explains how to activate and configure debug logging in applications using JsonApiToolkit. + +## Logging Framework + +JsonApiToolkit uses the standard Microsoft.Extensions.Logging framework with the `Intility.Logging.AspNetCore` package for enhanced logging capabilities. + +## Configuration + +### 1. Basic Setup + +To enable debug logging for JsonApiToolkit, configure your logging in `Program.cs` or `appsettings.json`: + +#### Option A: Configure in appsettings.json + +Add the following to your `appsettings.json` or `appsettings.Development.json`: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "JsonApiToolkit": "Debug" + } + } +} +``` + +#### Option B: Configure in Program.cs + +```csharp +builder.Logging.AddFilter("JsonApiToolkit", LogLevel.Debug); +``` + +### 2. Specific Component Logging + +For more granular control, you can configure logging for specific JsonApiToolkit components: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "JsonApiToolkit.Controllers.JsonApiController": "Debug", + "JsonApiToolkit.Services.JsonApiQueryParserService": "Debug", + "JsonApiToolkit.Extensions.Querying.FilterExpressionBuilder": "Debug", + "JsonApiToolkit.Extensions.Querying.FilterHandler": "Debug", + "JsonApiToolkit.Extensions.Querying.SortingHandler": "Debug", + "JsonApiToolkit.Mapping.JsonApiMapper": "Debug" + } + } +} +``` + +### 3. Production Safety + +For production environments, set JsonApiToolkit logging to `Warning` or `Error` to avoid performance impact: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "JsonApiToolkit": "Warning" + } + } +} +``` + +## What Gets Logged + +When debug logging is enabled, JsonApiToolkit logs detailed information about: + +### Query Processing Pipeline +- **Request parsing**: Query parameters parsed from HTTP requests +- **Filter processing**: Filter criteria application and expression building +- **Sorting**: Sort parameter application +- **Pagination**: Page calculation and application +- **Includes**: Relationship loading and mapping + +### Filter Expression Building +- **Filter separation**: Main entity vs. included resource filters +- **Expression construction**: LINQ expression building process +- **Property mapping**: Field name to CLR property mapping +- **Operator handling**: Different filter operators (eq, ne, gt, lt, in, etc.) +- **Nested navigation**: Dot notation property access + +### Entity Mapping +- **Resource object creation**: Entity to JSON:API resource mapping +- **Attribute extraction**: Property mapping to JSON:API attributes +- **Relationship processing**: Related entity handling +- **Document structure**: JSON:API document assembly + +### Performance Insights +- **Query execution**: Database query execution timing +- **Result counts**: Number of entities processed +- **Include processing**: Relationship loading details + +## Example Log Output + +With debug logging enabled, you'll see detailed logs like: + +``` +[DBG] Starting JSON:API query processing for resource type 'books' +[DBG] Parsed query parameters: Filters=2, Sorts=1, Includes=1, Pagination=True +[DBG] Mapped 1 include paths to CLR properties: Author +[DBG] Separated filters: MainFilters=1, IncludeFilters=1 +[DBG] Applying 1 main entity filters +[DBG] Building filter expression for 1 filters and 0 nested groups with logical operator And +[DBG] Processing filter: Field='title', Operator=Like, Value='API' +[DBG] Successfully built filter expression for field 'title' +[DBG] Using regular includes strategy - applying includes before sorting +[DBG] Applying 1 regular includes +[DBG] Applying 1 sort parameters after includes +[DBG] Executing count query to get total resource count +[DBG] Total count after filtering: 5 +[DBG] Applying pagination: Page=1, Size=10 +[DBG] Executing final query to retrieve results +[DBG] Retrieved 5 results from database +[DBG] Mapping results to JSON:API document structure +[DBG] Creating JSON:API collection document for entities of type Book with resource type 'books' +[DBG] Successfully completed JSON:API query processing for resource type 'books' with 5 resources and 5 included resources +``` + +## Performance Considerations + +Debug logging adds overhead to request processing. Consider: + +1. **Development**: Enable debug logging for troubleshooting +2. **Staging**: Use `Information` or `Warning` level +3. **Production**: Use `Warning` or `Error` level only +4. **Performance testing**: Disable debug logging to get accurate metrics + +## Troubleshooting Common Issues + +### Filter Problems +Look for logs containing: +- "Property 'fieldName' not found" - Field name doesn't match entity property +- "Failed to convert filter value" - Type conversion issues +- "Filter expression builder returned null" - Invalid filter configuration + +### Include Problems +Look for logs containing: +- "Property 'relationship' not found during nested navigation" - Invalid include path +- "Mapped X include paths" - Verify expected relationships are included + +### Performance Issues +Look for: +- High result counts without pagination +- Complex filter expressions with many nested groups +- Multiple database queries for includes + +## Integration with Intility.Logging.AspNetCore + +JsonApiToolkit integrates seamlessly with `Intility.Logging.AspNetCore`. The structured logging provides: + +- **Request correlation**: All logs for a request are correlated +- **Structured data**: Filter counts, entity types, and processing steps are logged as structured data +- **Performance metrics**: Query execution timing and result counts +- **Error context**: Detailed context when errors occur + +## Best Practices + +1. **Use environment-specific configuration** to avoid debug logging in production +2. **Monitor log volume** as debug logging can be verbose +3. **Use structured logging filters** to focus on specific components +4. **Combine with application monitoring** tools for comprehensive observability +5. **Review logs regularly** during development to optimize query patterns + +## Disable Logging + +To completely disable JsonApiToolkit logging: + +```json +{ + "Logging": { + "LogLevel": { + "JsonApiToolkit": "None" + } + } +} +``` + +Or in code: + +```csharp +builder.Logging.AddFilter("JsonApiToolkit", LogLevel.None); +``` diff --git a/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs b/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs index 414cced..37a2c85 100644 --- a/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs +++ b/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs @@ -2,14 +2,20 @@ 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.Logging; +using Moq; namespace JsonApiToolkit.Tests.Controllers; public class TestJsonApiController : JsonApiController { + public TestJsonApiController(ILogger logger, IJsonApiQueryParser queryParser) + : base(logger, queryParser) { } + public IActionResult TestJsonApiOk(TestEntity entity) { return JsonApiOk(entity, "testEntities"); @@ -42,7 +48,14 @@ public class JsonApiControllerTests public JsonApiControllerTests() { - _controller = new TestJsonApiController(); + var logger = new Mock>(); + var queryParser = new Mock(); + queryParser + .Setup(x => x.Parse(It.IsAny())) + .Returns( + new JsonApiToolkit.Models.Querying.QueryParameters { Include = new List() } + ); + _controller = new TestJsonApiController(logger.Object, queryParser.Object); var httpContext = new DefaultHttpContext(); httpContext.Request.Scheme = "https"; diff --git a/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs b/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs index ee70064..b863bf6 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; @@ -155,6 +157,12 @@ public void Dispose() [Route("api/test")] public class TestIntegrationController : JsonApiController { + public TestIntegrationController( + ILogger logger, + IJsonApiQueryParser queryParser + ) + : base(logger, queryParser) { } + [HttpGet("with-allowed")] [AllowedIncludes("author", "posts")] public IActionResult GetWithAllowed() diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 8fb9ae3..f7206d2 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -8,9 +8,10 @@ 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.Logging; namespace JsonApiToolkit.Controllers; @@ -27,6 +28,18 @@ namespace JsonApiToolkit.Controllers; [ServiceFilter(typeof(JsonApiExceptionFilter))] public abstract class JsonApiController : ControllerBase { + private readonly ILogger _logger; + private readonly IJsonApiQueryParser _queryParser; + + /// + /// Initializes a new instance of the class. + /// + protected JsonApiController(ILogger logger, IJsonApiQueryParser queryParser) + { + _logger = logger; + _queryParser = queryParser; + } + /// /// Extracts and parses JSON:API query parameters from the current HTTP request. /// @@ -50,7 +63,7 @@ public abstract class JsonApiController : ControllerBase /// protected QueryParameters GetJsonApiQueryParameters() { - return JsonApiQueryParser.Parse(Request); + return _queryParser.Parse(Request); } /// @@ -77,7 +90,8 @@ protected IActionResult JsonApiOk(T entity, string resourceType) entity, resourceType, baseUrl, - mappedIncludes + mappedIncludes, + _logger ); return Ok(document); } @@ -112,7 +126,8 @@ protected IActionResult JsonApiOk( resourceType, baseUrl, paginationMeta, - mappedIncludes + mappedIncludes, + _logger ); return Ok(document); } @@ -148,48 +163,93 @@ string resourceType ) where T : class { + _logger.LogDebug( + "Starting JSON:API query processing for resource type '{ResourceType}'", + resourceType + ); + QueryParameters parameters = GetJsonApiQueryParameters(); + _logger.LogDebug( + "Parsed query parameters: Filters={FilterCount}, Sorts={SortCount}, Includes={IncludeCount}, Pagination={HasPagination}", + parameters.Filter?.Filters?.Count ?? 0, + parameters.Sort?.Count ?? 0, + parameters.Include?.Count ?? 0, + parameters.Pagination != null + ); + string baseUrl = GetFullRequestUrl(); var mappedIncludes = EfIncludePathHelper.MapIncludePathsToClrProperties( parameters.Include ); + _logger.LogDebug( + "Mapped {IncludeCount} include paths to CLR properties: {MappedIncludes}", + mappedIncludes.Count, + string.Join(", ", mappedIncludes) + ); + // Separate include filters from main filters var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( parameters.Filter, parameters.Include ); + _logger.LogDebug( + "Separated filters: MainFilters={MainFilterCount}, IncludeFilters={IncludeFilterCount}", + mainFilters?.Filters?.Count ?? 0, + includeFilters.Count + ); + IQueryable filteredQuery = queryable; // Apply main entity filters first if (mainFilters != null) - filteredQuery = filteredQuery.ApplyFilters(mainFilters); + { + _logger.LogDebug( + "Applying {FilterCount} main entity filters", + mainFilters.Filters.Count + ); + filteredQuery = filteredQuery.ApplyFilters(mainFilters, _logger); + } + + // Standardized order: Filters -> Includes -> Sorting + // This ensures consistent behavior regardless of include type - // Different ordering strategy based on whether we have filtered includes + // Apply includes (filtered or regular) 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 {FilteredIncludeCount} filtered includes", + includeFilters.Count + ); filteredQuery = filteredQuery.ApplyFilteredIncludes(mappedIncludes, includeFilters); } - else + else if (mappedIncludes.Count > 0) { - // For regular includes: Apply includes first for better compatibility + _logger.LogDebug("Applying {IncludeCount} regular includes", mappedIncludes.Count); filteredQuery = filteredQuery.ApplyIncludes(mappedIncludes); + } - // Then apply sorting after includes - if (parameters.Sort?.Count > 0) - filteredQuery = filteredQuery.ApplySorting(parameters.Sort); + // Apply sorting after includes for consistency + if (parameters.Sort?.Count > 0) + { + _logger.LogDebug("Applying {SortCount} sort parameters", parameters.Sort.Count); + filteredQuery = filteredQuery.ApplySorting(parameters.Sort, _logger); } + _logger.LogDebug("Executing count query to get total resource count"); int totalCount = await filteredQuery.CountAsync().ConfigureAwait(false); + _logger.LogDebug("Total count after filtering: {TotalCount}", totalCount); if (parameters.Pagination != null) + { + _logger.LogDebug( + "Applying pagination: Page={PageNumber}, Size={PageSize}", + parameters.Pagination.Number, + parameters.Pagination.Size + ); filteredQuery = filteredQuery.ApplyPagination(parameters.Pagination); + } PaginationMeta? paginationMeta = null; if (parameters.Pagination != null) @@ -201,16 +261,34 @@ string resourceType CurrentPage = parameters.Pagination.Number, PageSize = parameters.Pagination.Size, }; + + _logger.LogDebug( + "Created pagination metadata: TotalPages={TotalPages}, CurrentPage={CurrentPage}, PageSize={PageSize}", + paginationMeta.TotalPages, + paginationMeta.CurrentPage, + paginationMeta.PageSize + ); } + _logger.LogDebug("Executing final query to retrieve results"); List results = await filteredQuery.ToListAsync().ConfigureAwait(false); + _logger.LogDebug("Retrieved {ResultCount} results from database", results.Count); + _logger.LogDebug("Mapping results to JSON:API document structure"); JsonApiCollectionDocument document = JsonApiMapper.ToCollectionDocument( results, resourceType, baseUrl, paginationMeta, - mappedIncludes + mappedIncludes, + _logger + ); + + _logger.LogDebug( + "Successfully completed JSON:API query processing for resource type '{ResourceType}' with {ResourceCount} resources and {IncludedCount} included resources", + resourceType, + document.Data?.Count() ?? 0, + document.Included?.Count() ?? 0 ); return Ok(document); @@ -243,7 +321,8 @@ protected IActionResult JsonApiCreated(T entity, string resourceType, string entity, resourceType, selfUrl, - mappedIncludes + mappedIncludes, + _logger ); return Created(selfUrl, document); } diff --git a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs index 2b9e194..43ff8dd 100644 --- a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs +++ b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs @@ -2,6 +2,7 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiToolkit.Models.Querying.Filtering; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Extensions.Querying; @@ -20,6 +21,7 @@ public static class FilterExpressionBuilder /// The entity type being filtered /// The filter group containing conditions and nested groups /// The parameter expression representing the entity in the LINQ expression + /// Optional logger for debugging and tracing /// /// A composite Expression that can be used in a LINQ Where clause, or null if no valid filters exist /// @@ -29,17 +31,36 @@ public static class FilterExpressionBuilder /// public static Expression? BuildFilterExpression( FilterGroup group, - ParameterExpression parameter + ParameterExpression parameter, + ILogger? logger = null ) { + logger?.LogDebug( + "Building filter expression for {FilterCount} filters and {GroupCount} nested groups with logical operator {LogicalOperator}", + group.Filters.Count, + group.Groups.Count, + group.LogicalOperator + ); + var expressions = new List(); foreach (FilterParameter filter in group.Filters) { + logger?.LogDebug( + "Processing filter: Field='{Field}', Operator={Operator}, Value='{Value}'", + filter.Field, + filter.Operator, + filter.Value + ); + Expression? expr; if (filter.Field.Contains('.')) { - expr = BuildSingleFilterExpression(parameter, filter); + logger?.LogDebug( + "Building nested property filter expression for field '{Field}'", + filter.Field + ); + expr = BuildSingleFilterExpression(parameter, filter, logger); } else { @@ -48,59 +69,129 @@ ParameterExpression parameter filter.Field ); if (property == null) + { + logger?.LogWarning( + "Property '{Field}' not found on type {Type}, skipping filter", + filter.Field, + typeof(T).Name + ); continue; - expr = BuildSingleFilterExpression(parameter, filter); + } + logger?.LogDebug( + "Building simple property filter expression for field '{Field}' -> property '{PropertyName}'", + filter.Field, + property.Name + ); + expr = BuildSingleFilterExpression(parameter, filter, logger); } if (expr != null) + { + logger?.LogDebug( + "Successfully built filter expression for field '{Field}'", + filter.Field + ); expressions.Add(expr); + } + else + { + logger?.LogWarning( + "Failed to build filter expression for field '{Field}'", + filter.Field + ); + } } foreach (FilterGroup nestedGroup in group.Groups) { - Expression? nestedExpr = BuildFilterExpression(nestedGroup, parameter); + logger?.LogDebug( + "Processing nested filter group with {NestedFilterCount} filters and logical operator {NestedLogicalOperator}", + nestedGroup.Filters.Count, + nestedGroup.LogicalOperator + ); + Expression? nestedExpr = BuildFilterExpression(nestedGroup, parameter, logger); if (nestedExpr != null) + { + logger?.LogDebug("Successfully built nested group expression"); expressions.Add(nestedExpr); + } + else + { + logger?.LogDebug("Nested group expression resulted in null"); + } } if (expressions.Count == 0) + { + logger?.LogDebug("No valid filter expressions found, returning null"); return null; + } if (expressions.Count == 1) { Expression singleExpression = expressions[0]; if (group.LogicalOperator == LogicalOperator.Not) { + logger?.LogDebug("Applying NOT operator to single expression"); return Expression.Not(singleExpression); } + logger?.LogDebug("Returning single filter expression without logical combination"); return singleExpression; } + logger?.LogDebug( + "Combining {ExpressionCount} expressions with logical operator {LogicalOperator}", + expressions.Count, + group.LogicalOperator + ); + Expression? combinedExpression = null; - foreach (Expression expr in expressions) + // For NOT operator, we need to apply De Morgan's law: + // NOT(A AND B) = NOT(A) OR NOT(B) + // NOT(A OR B) = NOT(A) AND NOT(B) + // Since the filters in a group are combined with AND by default, + // NOT group means NOT(A AND B AND C...) = NOT(A) OR NOT(B) OR NOT(C)... + if (group.LogicalOperator == LogicalOperator.Not) { - if (combinedExpression == null) + // Apply NOT to each expression individually and combine with OR + foreach (Expression expr in expressions) { - combinedExpression = expr; - } - else - { - combinedExpression = group.LogicalOperator switch + var notExpr = Expression.Not(expr); + if (combinedExpression == null) + { + combinedExpression = notExpr; + } + else { - LogicalOperator.And => Expression.AndAlso(combinedExpression, expr), - LogicalOperator.Or => Expression.OrElse(combinedExpression, expr), - LogicalOperator.Not => Expression.AndAlso(combinedExpression, expr), - _ => Expression.AndAlso(combinedExpression, expr), - }; + // Use OR for NOT group (De Morgan's law) + combinedExpression = Expression.OrElse(combinedExpression, notExpr); + } } + logger?.LogDebug("Applied NOT operator using De Morgan's law (combined with OR)"); } - - if (group.LogicalOperator == LogicalOperator.Not && combinedExpression != null) + else { - combinedExpression = Expression.Not(combinedExpression); + // Normal AND/OR combination + foreach (Expression expr in expressions) + { + if (combinedExpression == null) + { + combinedExpression = expr; + } + else + { + combinedExpression = group.LogicalOperator switch + { + LogicalOperator.And => Expression.AndAlso(combinedExpression, expr), + LogicalOperator.Or => Expression.OrElse(combinedExpression, expr), + _ => Expression.AndAlso(combinedExpression, expr), + }; + } + } } + logger?.LogDebug("Successfully built combined filter expression"); return combinedExpression; } @@ -109,15 +200,24 @@ ParameterExpression parameter /// /// The parameter expression representing the entity /// The filter parameter to build an expression for + /// Optional logger for debugging and tracing /// An expression representing the filter condition, or null if the filter cannot be applied public static Expression? BuildSingleFilterExpression( ParameterExpression parameter, - FilterParameter filter + FilterParameter filter, + ILogger? logger = null ) { + logger?.LogDebug( + "Building single filter expression for field '{Field}' with operator {Operator}", + filter.Field, + filter.Operator + ); + if (filter.Field.Contains('.')) { - return BuildSafeNestedFilterExpression(parameter, filter); + logger?.LogDebug("Field contains dot notation, building safe nested filter expression"); + return BuildSafeNestedFilterExpression(parameter, filter, logger); } else { @@ -126,9 +226,24 @@ FilterParameter filter filter.Field ); if (property == null) + { + logger?.LogWarning( + "Property '{Field}' not found on type {Type}", + filter.Field, + parameter.Type.Name + ); return null; + } + + logger?.LogDebug( + "Found property '{PropertyName}' of type {PropertyType} for field '{Field}'", + property.Name, + property.PropertyType.Name, + filter.Field + ); + Expression propertyAccess = Expression.Property(parameter, property); - return BuildPropertyFilterExpression(propertyAccess, filter); + return BuildPropertyFilterExpression(propertyAccess, filter, logger); } } @@ -136,14 +251,53 @@ 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 +306,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 +356,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 +375,44 @@ Type propertyType private static Expression? BuildSafeNestedFilterExpression( ParameterExpression parameter, - FilterParameter filter + FilterParameter filter, + ILogger? logger = null ) { + logger?.LogDebug( + "Building safe nested filter expression for field path '{Field}'", + filter.Field + ); + string[] parts = filter.Field.Split('.'); Expression current = parameter; var nullChecks = new List(); + logger?.LogDebug( + "Navigating through {PartCount} property parts: {Parts}", + parts.Length, + string.Join(" -> ", parts) + ); + // Build null-safe navigation for 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 {Type} during nested navigation", + parts[i], + current.Type.Name + ); return null; + } + + logger?.LogDebug( + "Navigating to property '{PropertyName}' of type {PropertyType}", + prop.Name, + prop.PropertyType.Name + ); current = Expression.Property(current, prop); @@ -218,6 +422,10 @@ FilterParameter filter || Nullable.GetUnderlyingType(prop.PropertyType) != null ) { + logger?.LogDebug( + "Adding null check for reference type property '{PropertyName}'", + prop.Name + ); nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null))); } } @@ -225,46 +433,116 @@ FilterParameter filter // Get the final property PropertyInfo? finalProp = QueryHelpers.GetPropertyByJsonName(current.Type, parts[^1]); if (finalProp == null) + { + logger?.LogWarning( + "Final property '{PropertyName}' not found on type {Type}", + parts[^1], + current.Type.Name + ); return null; + } + + logger?.LogDebug( + "Found final property '{PropertyName}' of type {PropertyType}", + finalProp.Name, + finalProp.PropertyType.Name + ); 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) + { + logger?.LogWarning("Failed to build property filter expression for final property"); return null; + } + + logger?.LogDebug( + "Built filter expression, applying {NullCheckCount} null checks", + nullChecks.Count + ); - // Combine null checks with the filter expression - Expression result = filterExpression; - foreach (Expression nullCheck in nullChecks) + // For inequality operators (Ne, Nin), null values should be treated differently + // null != value should be true, not filtered out + Expression result; + if (filter.Operator == FilterOperator.Ne || filter.Operator == FilterOperator.Nin) { - result = Expression.AndAlso(nullCheck, result); + // For inequality: if any property in the chain is null, return true (not equal) + // Otherwise, apply the filter expression + if (nullChecks.Count > 0) + { + // Create an OR condition: (any property is null) OR (all not null AND filter matches) + Expression allNotNull = nullChecks[0]; + for (int i = 1; i < nullChecks.Count; i++) + { + allNotNull = Expression.AndAlso(allNotNull, nullChecks[i]); + } + + // Any property is null + Expression anyNull = Expression.Not(allNotNull); + + // All not null AND filter matches + Expression notNullAndFilter = Expression.AndAlso(allNotNull, filterExpression); + + // Return true if any null OR filter matches + result = Expression.OrElse(anyNull, notNullAndFilter); + } + else + { + result = filterExpression; + } + } + else + { + // For equality and other operators: all properties must be non-null AND filter matches + result = filterExpression; + foreach (Expression nullCheck in nullChecks) + { + result = Expression.AndAlso(nullCheck, result); + } } + logger?.LogDebug( + "Successfully built safe nested filter expression with proper null handling for operator {Operator}", + filter.Operator + ); return result; } private static Expression? BuildPropertyFilterExpression( Expression propertyAccess, - FilterParameter filter + FilterParameter filter, + ILogger? logger = null ) { Type targetType = propertyAccess.Type; + logger?.LogDebug( + "Building property filter expression for operator {Operator} on type {PropertyType} with value '{Value}'", + filter.Operator, + targetType.Name, + filter.Value + ); + if (filter.Operator == FilterOperator.IsNull) { + logger?.LogDebug("Building IsNull expression"); return Expression.Equal(propertyAccess, Expression.Constant(null)); } if (filter.Operator == FilterOperator.IsNotNull) { + logger?.LogDebug("Building IsNotNull expression"); return Expression.NotEqual(propertyAccess, Expression.Constant(null)); } if (filter.Operator == FilterOperator.In) { + logger?.LogDebug("Building In expression for values: {Values}", filter.Value); Type? underlying = Nullable.GetUnderlyingType(targetType); if (underlying != null) { + logger?.LogDebug("Property is nullable, building null-safe In expression"); BinaryExpression notNullExpr = Expression.NotEqual( propertyAccess, Expression.Constant(null, propertyAccess.Type) @@ -278,6 +556,7 @@ FilterParameter filter } else { + logger?.LogDebug("Property is not nullable, building direct In expression"); return BuildInExpression(propertyAccess, filter.Value, targetType); } } @@ -303,6 +582,12 @@ FilterParameter filter } } + logger?.LogDebug( + "Converting filter value '{Value}' to property type {PropertyType}", + filter.Value, + targetType.Name + ); + object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, targetType); if ( filterValue == null @@ -310,8 +595,19 @@ FilterParameter filter && filter.Operator != FilterOperator.Ne ) { + logger?.LogWarning( + "Failed to convert filter value '{Value}' to type {PropertyType} for operator {Operator}", + filter.Value, + targetType.Name, + filter.Operator + ); return null; } + + logger?.LogDebug( + "Successfully converted filter value, building {Operator} expression", + filter.Operator + ); 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..b30dd39 100644 --- a/JsonApiToolkit/Extensions/Querying/FilterHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/FilterHandler.cs @@ -1,10 +1,11 @@ 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. +/// /// Provides extension methods to apply JSON:API filter conditions to IQueryable sources. /// /// /// This class serves as the bridge between JSON:API filter parameters and queryable data sources, @@ -18,32 +19,51 @@ public static class FilterHandler /// The entity type of the queryable /// The source IQueryable to filter /// The filter group defining the conditions to apply + /// Optional logger for debugging and tracing /// 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 + ) { + logger?.LogDebug( + "Applying filters to query for type {EntityType}: {FilterCount} direct filters, {GroupCount} nested groups", + typeof(T).Name, + filterGroup?.Filters.Count ?? 0, + filterGroup?.Groups.Count ?? 0 + ); + if ( filterGroup == null || (filterGroup.Filters.Count == 0 && filterGroup.Groups.Count == 0) ) { + logger?.LogDebug("No filters to apply, returning original query"); return query; } ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); Expression? expression = FilterExpressionBuilder.BuildFilterExpression( filterGroup, - parameter + parameter, + logger ); if (expression != null) { + logger?.LogDebug("Successfully built filter expression, applying to query"); var lambda = Expression.Lambda>(expression, parameter); query = query.Where(lambda); } + else + { + logger?.LogWarning("Filter expression builder returned null, no filters applied"); + } return query; } diff --git a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs b/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs index a0ce994..5dc87fb 100644 --- a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs +++ b/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs @@ -57,7 +57,7 @@ public static class QueryHelpers /// The string value from the query parameter /// The target property type to convert to /// - /// /// The converted value, or throws an exception if conversion fails or is not supported + /// The converted value, or throws an exception if conversion fails or is not supported /// /// /// Handles common primitive types (int, long, decimal, bool, DateTime, Guid, Uri, TimeSpan, @@ -109,7 +109,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); diff --git a/JsonApiToolkit/Extensions/Querying/SortingHandler.cs b/JsonApiToolkit/Extensions/Querying/SortingHandler.cs index 4e35f91..032297d 100644 --- a/JsonApiToolkit/Extensions/Querying/SortingHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/SortingHandler.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiToolkit.Models.Querying; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Extensions.Querying; @@ -19,6 +20,7 @@ public static class SortingHandler /// The entity type of the queryable /// The source IQueryable to sort /// The list of sort parameters specifying fields and directions + /// Optional logger for debugging and tracing /// A new IQueryable with the specified sorting applied /// /// @@ -31,11 +33,19 @@ public static class SortingHandler /// public static IQueryable ApplySorting( this IQueryable query, - List sortParameters + List sortParameters, + ILogger? logger = null ) { + logger?.LogDebug( + "Applying sorting to query for type {EntityType} with {SortParameterCount} sort parameters", + typeof(T).Name, + sortParameters?.Count ?? 0 + ); + if (sortParameters == null || sortParameters.Count == 0) { + logger?.LogDebug("No sort parameters provided, returning original query"); return query; } diff --git a/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs b/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs index 9da380a..b88a7ca 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; @@ -66,6 +67,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/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index a47987f..bc430bc 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 1.1.3-local + 1.1.11-local Intility Intility A toolkit for implementing JSON:API specification in .NET applications diff --git a/JsonApiToolkit/Mapping/JsonApiMapper.cs b/JsonApiToolkit/Mapping/JsonApiMapper.cs index 7b0345d..f0da1ea 100644 --- a/JsonApiToolkit/Mapping/JsonApiMapper.cs +++ b/JsonApiToolkit/Mapping/JsonApiMapper.cs @@ -4,6 +4,7 @@ using JsonApiToolkit.Models.Documents; using JsonApiToolkit.Models.Metadata; using JsonApiToolkit.Models.Resources; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Mapping; @@ -29,6 +30,7 @@ public static class JsonApiMapper /// The entity to map /// The JSON:API resource type identifier /// Optional list of relationships to include in the resource object + /// Optional logger for debugging and tracing /// A fully populated ResourceObject representing the entity /// /// Maps the entity to a JSON:API resource object by: @@ -56,13 +58,21 @@ public static class JsonApiMapper public static ResourceObject ToResourceObject( object entity, string resourceType, - List? includedRelationships = null + List? includedRelationships = null, + ILogger? logger = null ) { ArgumentNullException.ThrowIfNull(entity); Type type = entity.GetType(); + logger?.LogDebug( + "Mapping entity of type {EntityType} to resource object with type '{ResourceType}' and {IncludeCount} included relationships", + type.Name, + resourceType, + includedRelationships?.Count ?? 0 + ); + PropertyInfo? idProperty = EntityMapper.GetIdProperty(type); var idValue = (idProperty?.GetValue(entity)) @@ -188,6 +198,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 +221,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 @@ -245,22 +268,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; }) diff --git a/JsonApiToolkit/Parsing/JsonApiFilterParser.cs b/JsonApiToolkit/Parsing/JsonApiFilterParser.cs index c690f4c..68b7f0c 100644 --- a/JsonApiToolkit/Parsing/JsonApiFilterParser.cs +++ b/JsonApiToolkit/Parsing/JsonApiFilterParser.cs @@ -198,10 +198,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 +220,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..c0b125b 100644 --- a/JsonApiToolkit/Parsing/JsonApiQueryParser.cs +++ b/JsonApiToolkit/Parsing/JsonApiQueryParser.cs @@ -99,17 +99,20 @@ 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..0b6bd45 --- /dev/null +++ b/JsonApiToolkit/Services/IJsonApiQueryParser.cs @@ -0,0 +1,17 @@ +using JsonApiToolkit.Models.Querying; +using Microsoft.AspNetCore.Http; + +namespace JsonApiToolkit.Services; + +/// +/// Service interface for parsing JSON:API query parameters with logging support. +/// +public interface IJsonApiQueryParser +{ + /// + /// Parses all JSON:API query parameters from an HTTP request into a structured QueryParameters object. + /// + /// The HTTP request containing the query parameters + /// A QueryParameters object containing all parsed query parameters + QueryParameters Parse(HttpRequest request); +} diff --git a/JsonApiToolkit/Services/JsonApiQueryParserService.cs b/JsonApiToolkit/Services/JsonApiQueryParserService.cs new file mode 100644 index 0000000..bcef8e2 --- /dev/null +++ b/JsonApiToolkit/Services/JsonApiQueryParserService.cs @@ -0,0 +1,80 @@ +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 with comprehensive debug logging. +/// +public class JsonApiQueryParserService : IJsonApiQueryParser +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance for logging parsing operations + public JsonApiQueryParserService(ILogger logger) + { + _logger = logger; + } + + /// + public QueryParameters Parse(HttpRequest request) + { + _logger.LogDebug( + "Starting to parse JSON:API query parameters from request: {RequestPath}?{QueryString}", + request.Path, + request.QueryString + ); + + var queryParams = JsonApiQueryParser.Parse(request); + + _logger.LogDebug( + "Successfully parsed query parameters: Filters={FilterCount}, Sorts={SortCount}, Includes={IncludeCount}, HasPagination={HasPagination}", + queryParams.Filter?.Filters?.Count ?? 0, + queryParams.Sort?.Count ?? 0, + queryParams.Include?.Count ?? 0, + queryParams.Pagination != null + ); + + if (queryParams.Pagination != null) + { + _logger.LogDebug( + "Pagination parameters: Page={PageNumber}, Size={PageSize}", + queryParams.Pagination.Number, + queryParams.Pagination.Size + ); + } + + if (queryParams.Filter != null) + { + _logger.LogDebug( + "Filter details: DirectFilters={DirectFilterCount}, Groups={GroupCount}", + queryParams.Filter.Filters?.Count ?? 0, + queryParams.Filter.Groups?.Count ?? 0 + ); + } + + if (queryParams.Sort?.Count > 0) + { + var sortFields = string.Join( + ", ", + queryParams.Sort.Select(s => $"{s.Field}({(s.IsDescending ? "desc" : "asc")})") + ); + _logger.LogDebug("Sort fields: {SortFields}", sortFields); + } + + if (queryParams.Include?.Count > 0) + { + _logger.LogDebug( + "Include relationships: {IncludeFields}", + string.Join(", ", queryParams.Include) + ); + } + + return queryParams; + } +} From 0ecc0cfe4b669b1eff7ed17311cbaab050a91673 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Mon, 29 Sep 2025 20:15:21 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=F0=9F=93=9A=20add=20comprehensive?= =?UTF-8?q?=20debugging=20guide=20and=20enhance=20logging=20for=20better?= =?UTF-8?q?=20troubleshooting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEBUG_LOGGING.md | 189 ------------------ .../Controllers/JsonApiControllerTests.cs | 33 +-- .../AllowedIncludesIntegrationTests.cs | 6 - .../Controllers/JsonApiController.cs | 122 ++++++++--- .../Querying/FilterExpressionBuilder.cs | 5 +- .../Extensions/Querying/QueryHelpers.cs | 6 +- JsonApiToolkit/JsonApiToolkit.csproj | 2 +- .../Services/JsonApiQueryParserService.cs | 31 +++ docs/docs/debugging.md | 103 ++++++++++ docs/docs/toc.yml | 2 + 10 files changed, 256 insertions(+), 243 deletions(-) delete mode 100644 DEBUG_LOGGING.md create mode 100644 docs/docs/debugging.md diff --git a/DEBUG_LOGGING.md b/DEBUG_LOGGING.md deleted file mode 100644 index f434f69..0000000 --- a/DEBUG_LOGGING.md +++ /dev/null @@ -1,189 +0,0 @@ -# Debug Logging Guide for JsonApiToolkit - -JsonApiToolkit now includes comprehensive debug logging throughout the query processing pipeline. This guide explains how to activate and configure debug logging in applications using JsonApiToolkit. - -## Logging Framework - -JsonApiToolkit uses the standard Microsoft.Extensions.Logging framework with the `Intility.Logging.AspNetCore` package for enhanced logging capabilities. - -## Configuration - -### 1. Basic Setup - -To enable debug logging for JsonApiToolkit, configure your logging in `Program.cs` or `appsettings.json`: - -#### Option A: Configure in appsettings.json - -Add the following to your `appsettings.json` or `appsettings.Development.json`: - -```json -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "JsonApiToolkit": "Debug" - } - } -} -``` - -#### Option B: Configure in Program.cs - -```csharp -builder.Logging.AddFilter("JsonApiToolkit", LogLevel.Debug); -``` - -### 2. Specific Component Logging - -For more granular control, you can configure logging for specific JsonApiToolkit components: - -```json -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "JsonApiToolkit.Controllers.JsonApiController": "Debug", - "JsonApiToolkit.Services.JsonApiQueryParserService": "Debug", - "JsonApiToolkit.Extensions.Querying.FilterExpressionBuilder": "Debug", - "JsonApiToolkit.Extensions.Querying.FilterHandler": "Debug", - "JsonApiToolkit.Extensions.Querying.SortingHandler": "Debug", - "JsonApiToolkit.Mapping.JsonApiMapper": "Debug" - } - } -} -``` - -### 3. Production Safety - -For production environments, set JsonApiToolkit logging to `Warning` or `Error` to avoid performance impact: - -```json -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "JsonApiToolkit": "Warning" - } - } -} -``` - -## What Gets Logged - -When debug logging is enabled, JsonApiToolkit logs detailed information about: - -### Query Processing Pipeline -- **Request parsing**: Query parameters parsed from HTTP requests -- **Filter processing**: Filter criteria application and expression building -- **Sorting**: Sort parameter application -- **Pagination**: Page calculation and application -- **Includes**: Relationship loading and mapping - -### Filter Expression Building -- **Filter separation**: Main entity vs. included resource filters -- **Expression construction**: LINQ expression building process -- **Property mapping**: Field name to CLR property mapping -- **Operator handling**: Different filter operators (eq, ne, gt, lt, in, etc.) -- **Nested navigation**: Dot notation property access - -### Entity Mapping -- **Resource object creation**: Entity to JSON:API resource mapping -- **Attribute extraction**: Property mapping to JSON:API attributes -- **Relationship processing**: Related entity handling -- **Document structure**: JSON:API document assembly - -### Performance Insights -- **Query execution**: Database query execution timing -- **Result counts**: Number of entities processed -- **Include processing**: Relationship loading details - -## Example Log Output - -With debug logging enabled, you'll see detailed logs like: - -``` -[DBG] Starting JSON:API query processing for resource type 'books' -[DBG] Parsed query parameters: Filters=2, Sorts=1, Includes=1, Pagination=True -[DBG] Mapped 1 include paths to CLR properties: Author -[DBG] Separated filters: MainFilters=1, IncludeFilters=1 -[DBG] Applying 1 main entity filters -[DBG] Building filter expression for 1 filters and 0 nested groups with logical operator And -[DBG] Processing filter: Field='title', Operator=Like, Value='API' -[DBG] Successfully built filter expression for field 'title' -[DBG] Using regular includes strategy - applying includes before sorting -[DBG] Applying 1 regular includes -[DBG] Applying 1 sort parameters after includes -[DBG] Executing count query to get total resource count -[DBG] Total count after filtering: 5 -[DBG] Applying pagination: Page=1, Size=10 -[DBG] Executing final query to retrieve results -[DBG] Retrieved 5 results from database -[DBG] Mapping results to JSON:API document structure -[DBG] Creating JSON:API collection document for entities of type Book with resource type 'books' -[DBG] Successfully completed JSON:API query processing for resource type 'books' with 5 resources and 5 included resources -``` - -## Performance Considerations - -Debug logging adds overhead to request processing. Consider: - -1. **Development**: Enable debug logging for troubleshooting -2. **Staging**: Use `Information` or `Warning` level -3. **Production**: Use `Warning` or `Error` level only -4. **Performance testing**: Disable debug logging to get accurate metrics - -## Troubleshooting Common Issues - -### Filter Problems -Look for logs containing: -- "Property 'fieldName' not found" - Field name doesn't match entity property -- "Failed to convert filter value" - Type conversion issues -- "Filter expression builder returned null" - Invalid filter configuration - -### Include Problems -Look for logs containing: -- "Property 'relationship' not found during nested navigation" - Invalid include path -- "Mapped X include paths" - Verify expected relationships are included - -### Performance Issues -Look for: -- High result counts without pagination -- Complex filter expressions with many nested groups -- Multiple database queries for includes - -## Integration with Intility.Logging.AspNetCore - -JsonApiToolkit integrates seamlessly with `Intility.Logging.AspNetCore`. The structured logging provides: - -- **Request correlation**: All logs for a request are correlated -- **Structured data**: Filter counts, entity types, and processing steps are logged as structured data -- **Performance metrics**: Query execution timing and result counts -- **Error context**: Detailed context when errors occur - -## Best Practices - -1. **Use environment-specific configuration** to avoid debug logging in production -2. **Monitor log volume** as debug logging can be verbose -3. **Use structured logging filters** to focus on specific components -4. **Combine with application monitoring** tools for comprehensive observability -5. **Review logs regularly** during development to optimize query patterns - -## Disable Logging - -To completely disable JsonApiToolkit logging: - -```json -{ - "Logging": { - "LogLevel": { - "JsonApiToolkit": "None" - } - } -} -``` - -Or in code: - -```csharp -builder.Logging.AddFilter("JsonApiToolkit", LogLevel.None); -``` diff --git a/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs b/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs index 37a2c85..b79f5c6 100644 --- a/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs +++ b/JsonApiToolkit.Tests/Controllers/JsonApiControllerTests.cs @@ -6,6 +6,7 @@ using JsonApiToolkit.Tests.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Moq; @@ -13,9 +14,6 @@ namespace JsonApiToolkit.Tests.Controllers; public class TestJsonApiController : JsonApiController { - public TestJsonApiController(ILogger logger, IJsonApiQueryParser queryParser) - : base(logger, queryParser) { } - public IActionResult TestJsonApiOk(TestEntity entity) { return JsonApiOk(entity, "testEntities"); @@ -48,16 +46,25 @@ public class JsonApiControllerTests public JsonApiControllerTests() { - var logger = new Mock>(); - var queryParser = new Mock(); - queryParser - .Setup(x => x.Parse(It.IsAny())) - .Returns( - new JsonApiToolkit.Models.Querying.QueryParameters { Include = new List() } - ); - _controller = new TestJsonApiController(logger.Object, queryParser.Object); - - var httpContext = new DefaultHttpContext(); + 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 { RequestServices = serviceProvider }; httpContext.Request.Scheme = "https"; httpContext.Request.Host = new HostString("api.example.com"); httpContext.Request.Path = "/test-entities"; diff --git a/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs b/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs index b863bf6..4a44c19 100644 --- a/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs +++ b/JsonApiToolkit.Tests/Integration/AllowedIncludesIntegrationTests.cs @@ -157,12 +157,6 @@ public void Dispose() [Route("api/test")] public class TestIntegrationController : JsonApiController { - public TestIntegrationController( - ILogger logger, - IJsonApiQueryParser queryParser - ) - : base(logger, queryParser) { } - [HttpGet("with-allowed")] [AllowedIncludes("author", "posts")] public IActionResult GetWithAllowed() diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index f7206d2..31e1b7c 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -11,6 +11,7 @@ using JsonApiToolkit.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Controllers; @@ -28,17 +29,20 @@ namespace JsonApiToolkit.Controllers; [ServiceFilter(typeof(JsonApiExceptionFilter))] public abstract class JsonApiController : ControllerBase { - private readonly ILogger _logger; - private readonly IJsonApiQueryParser _queryParser; + private ILogger? _logger; + private IJsonApiQueryParser? _queryParser; /// - /// Initializes a new instance of the class. + /// Gets the logger instance from dependency injection. /// - protected JsonApiController(ILogger logger, IJsonApiQueryParser queryParser) - { - _logger = logger; - _queryParser = queryParser; - } + protected ILogger Logger => + _logger ??= HttpContext.RequestServices.GetRequiredService>(); + + /// + /// Gets the query parser instance from dependency injection. + /// + protected IJsonApiQueryParser QueryParser => + _queryParser ??= HttpContext.RequestServices.GetRequiredService(); /// /// Extracts and parses JSON:API query parameters from the current HTTP request. @@ -63,7 +67,7 @@ protected JsonApiController(ILogger logger, IJsonApiQueryPars /// protected QueryParameters GetJsonApiQueryParameters() { - return _queryParser.Parse(Request); + return QueryParser.Parse(Request); } /// @@ -91,7 +95,7 @@ protected IActionResult JsonApiOk(T entity, string resourceType) resourceType, baseUrl, mappedIncludes, - _logger + Logger ); return Ok(document); } @@ -127,7 +131,7 @@ protected IActionResult JsonApiOk( baseUrl, paginationMeta, mappedIncludes, - _logger + Logger ); return Ok(document); } @@ -163,13 +167,13 @@ string resourceType ) where T : class { - _logger.LogDebug( + Logger.LogDebug( "Starting JSON:API query processing for resource type '{ResourceType}'", resourceType ); QueryParameters parameters = GetJsonApiQueryParameters(); - _logger.LogDebug( + Logger.LogDebug( "Parsed query parameters: Filters={FilterCount}, Sorts={SortCount}, Includes={IncludeCount}, Pagination={HasPagination}", parameters.Filter?.Filters?.Count ?? 0, parameters.Sort?.Count ?? 0, @@ -177,24 +181,64 @@ string resourceType parameters.Pagination != null ); + // User-friendly warnings for common issues + if (parameters.Include?.Count > 0) + { + Logger.LogInformation( + "Processing includes: {Includes}. If you get errors, ensure these relationships exist on {EntityType}", + string.Join(", ", parameters.Include), + typeof(T).Name + ); + } + + if (parameters.Filter?.Filters?.Count > 10) + { + Logger.LogWarning( + "Large number of filters detected ({FilterCount}). This may impact performance. Consider simplifying the query", + parameters.Filter.Filters.Count + ); + } + string baseUrl = GetFullRequestUrl(); var mappedIncludes = EfIncludePathHelper.MapIncludePathsToClrProperties( parameters.Include ); - _logger.LogDebug( + Logger.LogDebug( "Mapped {IncludeCount} include paths to CLR properties: {MappedIncludes}", mappedIncludes.Count, string.Join(", ", mappedIncludes) ); + // User-friendly warnings for include mapping issues + if (parameters.Include?.Count > 0 && mappedIncludes.Count == 0) + { + Logger.LogWarning( + "No valid include paths found for {EntityType}. Requested: {RequestedIncludes}. Check that these properties exist and are navigation properties", + typeof(T).Name, + string.Join(", ", parameters.Include) + ); + } + else if (parameters.Include?.Count > mappedIncludes.Count) + { + var unmapped = parameters.Include.Except(mappedIncludes.Select(m => m.Split('.')[0])).ToList(); + if (unmapped.Count > 0) + { + Logger.LogWarning( + "Some includes could not be mapped for {EntityType}: {UnmappedIncludes}. Check property names and navigation relationships", + typeof(T).Name, + string.Join(", ", unmapped) + ); + } + } + // Separate include filters from main filters var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( parameters.Filter, parameters.Include ); - _logger.LogDebug( + Logger.LogDebug( "Separated filters: MainFilters={MainFilterCount}, IncludeFilters={IncludeFilterCount}", mainFilters?.Filters?.Count ?? 0, includeFilters.Count @@ -205,11 +249,11 @@ string resourceType // Apply main entity filters first if (mainFilters != null) { - _logger.LogDebug( + Logger.LogDebug( "Applying {FilterCount} main entity filters", mainFilters.Filters.Count ); - filteredQuery = filteredQuery.ApplyFilters(mainFilters, _logger); + filteredQuery = filteredQuery.ApplyFilters(mainFilters, Logger); } // Standardized order: Filters -> Includes -> Sorting @@ -218,7 +262,7 @@ string resourceType // Apply includes (filtered or regular) if (includeFilters.Count > 0) { - _logger.LogDebug( + Logger.LogDebug( "Applying {FilteredIncludeCount} filtered includes", includeFilters.Count ); @@ -226,24 +270,40 @@ string resourceType } else if (mappedIncludes.Count > 0) { - _logger.LogDebug("Applying {IncludeCount} regular includes", mappedIncludes.Count); + Logger.LogDebug("Applying {IncludeCount} regular includes", mappedIncludes.Count); filteredQuery = filteredQuery.ApplyIncludes(mappedIncludes); } // Apply sorting after includes for consistency if (parameters.Sort?.Count > 0) { - _logger.LogDebug("Applying {SortCount} sort parameters", parameters.Sort.Count); - filteredQuery = filteredQuery.ApplySorting(parameters.Sort, _logger); + Logger.LogDebug("Applying {SortCount} sort parameters", parameters.Sort.Count); + filteredQuery = filteredQuery.ApplySorting(parameters.Sort, Logger); } - _logger.LogDebug("Executing count query to get total resource count"); + Logger.LogDebug("Executing count query to get total resource count"); int totalCount = await filteredQuery.CountAsync().ConfigureAwait(false); - _logger.LogDebug("Total count after filtering: {TotalCount}", totalCount); + Logger.LogDebug("Total count after filtering: {TotalCount}", totalCount); + + // User-friendly info about results + if (totalCount == 0 && (parameters.Filter?.Filters?.Count > 0 || parameters.Include?.Count > 0)) + { + Logger.LogInformation( + "Query returned 0 results for {EntityType}. This might be due to filters or include conditions. Check your filter values and relationship data", + typeof(T).Name + ); + } + else if (totalCount > 1000) + { + Logger.LogWarning( + "Large result set detected ({TotalCount} records). Consider adding pagination or more specific filters for better performance", + totalCount + ); + } if (parameters.Pagination != null) { - _logger.LogDebug( + Logger.LogDebug( "Applying pagination: Page={PageNumber}, Size={PageSize}", parameters.Pagination.Number, parameters.Pagination.Size @@ -262,7 +322,7 @@ string resourceType PageSize = parameters.Pagination.Size, }; - _logger.LogDebug( + Logger.LogDebug( "Created pagination metadata: TotalPages={TotalPages}, CurrentPage={CurrentPage}, PageSize={PageSize}", paginationMeta.TotalPages, paginationMeta.CurrentPage, @@ -270,21 +330,21 @@ string resourceType ); } - _logger.LogDebug("Executing final query to retrieve results"); + Logger.LogDebug("Executing final query to retrieve results"); List results = await filteredQuery.ToListAsync().ConfigureAwait(false); - _logger.LogDebug("Retrieved {ResultCount} results from database", results.Count); + Logger.LogDebug("Retrieved {ResultCount} results from database", results.Count); - _logger.LogDebug("Mapping results to JSON:API document structure"); + Logger.LogDebug("Mapping results to JSON:API document structure"); JsonApiCollectionDocument document = JsonApiMapper.ToCollectionDocument( results, resourceType, baseUrl, paginationMeta, mappedIncludes, - _logger + Logger ); - _logger.LogDebug( + Logger.LogDebug( "Successfully completed JSON:API query processing for resource type '{ResourceType}' with {ResourceCount} resources and {IncludedCount} included resources", resourceType, document.Data?.Count() ?? 0, @@ -322,7 +382,7 @@ protected IActionResult JsonApiCreated(T entity, string resourceType, string resourceType, selfUrl, mappedIncludes, - _logger + Logger ); return Created(selfUrl, document); } diff --git a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs index 43ff8dd..5514fe5 100644 --- a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs +++ b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs @@ -228,9 +228,10 @@ public static class FilterExpressionBuilder if (property == null) { logger?.LogWarning( - "Property '{Field}' not found on type {Type}", + "Property '{Field}' not found on entity type '{EntityType}'. Available properties: {Properties}. Check your filter field names", filter.Field, - parameter.Type.Name + parameter.Type.Name, + string.Join(", ", parameter.Type.GetProperties().Select(p => p.Name)) ); return null; } diff --git a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs b/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs index 5dc87fb..40d695d 100644 --- a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs +++ b/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs @@ -146,7 +146,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/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index bc430bc..a94020c 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 1.1.11-local + 1.1.13-local Intility Intility A toolkit for implementing JSON:API specification in .NET applications diff --git a/JsonApiToolkit/Services/JsonApiQueryParserService.cs b/JsonApiToolkit/Services/JsonApiQueryParserService.cs index bcef8e2..a99c149 100644 --- a/JsonApiToolkit/Services/JsonApiQueryParserService.cs +++ b/JsonApiToolkit/Services/JsonApiQueryParserService.cs @@ -40,6 +40,37 @@ public QueryParameters Parse(HttpRequest request) queryParams.Pagination != null ); + // User-friendly warnings for common parameter issues + if ( + request.Query.Keys.Any(k => k.StartsWith("filter", StringComparison.OrdinalIgnoreCase)) + && (queryParams.Filter?.Filters?.Count ?? 0) == 0 + ) + { + _logger.LogWarning( + "Filter parameters detected in query string but no valid filters parsed. Check filter syntax: filter[fieldName][operator]=value. Example: filter[name][like]=John" + ); + } + + 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 sort syntax: sort=field1,-field2. Example: sort=name,-createdAt" + ); + } + + 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" + ); + } + if (queryParams.Pagination != null) { _logger.LogDebug( diff --git a/docs/docs/debugging.md b/docs/docs/debugging.md new file mode 100644 index 0000000..b3510a7 --- /dev/null +++ b/docs/docs/debugging.md @@ -0,0 +1,103 @@ +# Debugging Guide + +This guide explains how to enable debug logging for JsonApiToolkit to troubleshoot query processing, filtering, and EF Core expression generation. + +## Enable Debug Logging + +Add the following configuration to your `appsettings.json` or `appsettings.Development.json`: + +```json +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "JsonApiToolkit": "Debug" + } + } + } +} +``` + +For Microsoft.Extensions.Logging, use: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "JsonApiToolkit": "Debug" + } + } +} +``` + +## User-Friendly Logging + +JsonApiToolkit provides helpful **Information** and **Warning** level logs that guide you when things go wrong: + +### Common Issues and Log Messages + +**Property Not Found in Filters:** +``` +WARN: Property 'userName' not found on entity type 'User'. Available properties: Id, Name, Email. Check your filter field names +``` + +**Invalid Include Paths:** +``` +WARN: Some includes could not be mapped for User: profile, invalidRelation. Check property names and navigation relationships +``` + +**Query Parameter Syntax Issues:** +``` +WARN: Filter parameters detected but no valid filters parsed. Check filter syntax: filter[fieldName][operator]=value +WARN: Sort parameter detected but no valid sorts parsed. Check sort syntax: sort=field1,-field2 +``` + +**Type Conversion Errors:** +``` +ERROR: Failed to convert filter value 'invalid-date' to type 'DateTime'. Expected format examples: DateTime: '2023-12-25T10:30:00Z' +``` + +**Performance Warnings:** +``` +WARN: Large number of filters detected (15). This may impact performance. Consider simplifying the query +WARN: Large result set detected (5000 records). Consider adding pagination or more specific filters +``` + +**Empty Results:** +``` +INFO: Query returned 0 results for User. This might be due to filters or include conditions. Check your filter values and relationship data +``` + +## What Gets Logged + +**Information Level:** +- Include processing status and helpful hints +- Empty result explanations +- Query result summaries + +**Warning Level:** +- Invalid property names with suggestions +- Parameter parsing issues with examples +- Performance concerns +- Include mapping problems + +**Debug Level (detailed):** +- Query parameter parsing details +- Filter expression building steps +- Include path mapping +- EF Core query execution +- Pagination calculations + +## Log Categories + +- `JsonApiToolkit.Controllers.JsonApiController` - Main query processing and user-friendly messages +- `JsonApiToolkit.Services.JsonApiQueryParserService` - Parameter parsing warnings +- `JsonApiToolkit.Extensions.Querying` - Filter processing and property resolution +- `JsonApiToolkit.Mapping.JsonApiMapper` - Entity-to-JSON mapping + +## Performance Impact + +- **Information/Warning logs**: Minimal overhead, safe for production +- **Debug logs**: More detailed, recommended for development only \ No newline at end of file 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 From 7824da922c905bee096a759ff2813b1437392845 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Mon, 29 Sep 2025 21:28:25 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=F0=9F=9A=80=20enhance=20query=20pr?= =?UTF-8?q?ocessing=20with=20AsSingleQuery=20for=20pagination=20and=20add?= =?UTF-8?q?=20detailed=20logging=20for=20inclusion=20processing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 27 +- .../Extensions/IncludesWithPaginationTests.cs | 258 ++++++++++++++++++ .../Extensions/QueryHelpersTests.cs | 2 +- .../Controllers/JsonApiController.cs | 15 +- .../Extensions/QueryableExtensions.cs | 31 +++ JsonApiToolkit/JsonApiToolkit.csproj | 3 +- JsonApiToolkit/Mapping/InclusionMapper.cs | 157 ++++++++++- JsonApiToolkit/Mapping/JsonApiMapper.cs | 41 ++- 8 files changed, 510 insertions(+), 24 deletions(-) create mode 100644 JsonApiToolkit.Tests/Extensions/IncludesWithPaginationTests.cs 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/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/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 31e1b7c..3505907 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -271,7 +271,20 @@ string resourceType else if (mappedIncludes.Count > 0) { Logger.LogDebug("Applying {IncludeCount} regular includes", mappedIncludes.Count); - filteredQuery = filteredQuery.ApplyIncludes(mappedIncludes); + + // Use AsSingleQuery when pagination is present to avoid EF Core split query issues + // that cause includes to fail with large datasets + small page sizes + if (parameters.Pagination != null) + { + Logger.LogDebug( + "Using AsSingleQuery for includes due to pagination to prevent split query issues" + ); + filteredQuery = filteredQuery.ApplyIncludesSingleQuery(mappedIncludes); + } + else + { + filteredQuery = filteredQuery.ApplyIncludes(mappedIncludes); + } } // Apply sorting after includes for consistency diff --git a/JsonApiToolkit/Extensions/QueryableExtensions.cs b/JsonApiToolkit/Extensions/QueryableExtensions.cs index 5558db0..decbb1e 100644 --- a/JsonApiToolkit/Extensions/QueryableExtensions.cs +++ b/JsonApiToolkit/Extensions/QueryableExtensions.cs @@ -79,4 +79,35 @@ public static IQueryable ApplyIncludes( } return query; } + + /// + /// Dynamically applies EF Core Include() calls using AsSingleQuery() to prevent split query issues. + /// + /// The entity type. + /// The source queryable. + /// A list of include paths (e.g. "todo", "todo.category"). + /// The queryable with all includes applied using single query mode. + /// + /// Use this method when pagination is present to avoid EF Core split query optimization issues + /// that can cause includes to load data for wrong entities or no entities at all. + /// Forces EF Core to use a 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; + + // Force single query to prevent EF Core split query issues with pagination + query = query.AsSingleQuery(); + + foreach (string path in includePaths) + { + query = query.Include(path.Trim()); + } + return query; + } } diff --git a/JsonApiToolkit/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index a94020c..89606b6 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 1.1.13-local + 1.1.17-local Intility Intility A toolkit for implementing JSON:API specification in .NET applications @@ -24,6 +24,7 @@ + diff --git a/JsonApiToolkit/Mapping/InclusionMapper.cs b/JsonApiToolkit/Mapping/InclusionMapper.cs index 2c78b05..b3553cb 100644 --- a/JsonApiToolkit/Mapping/InclusionMapper.cs +++ b/JsonApiToolkit/Mapping/InclusionMapper.cs @@ -2,6 +2,7 @@ using System.Reflection; using JsonApiToolkit.Extensions; using JsonApiToolkit.Models.Resources; +using Microsoft.Extensions.Logging; namespace JsonApiToolkit.Mapping; @@ -21,6 +22,7 @@ public static class InclusionMapper /// 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 logger for debugging and tracing /// Optional set tracking already processed entities to prevent duplicates /// /// @@ -35,14 +37,26 @@ public static void AddIncludedResources( object entityOrCollection, List includePaths, List included, + ILogger? logger = null, HashSet? processedEntities = null ) { if (entityOrCollection == null || includePaths == null || includePaths.Count == 0) + { + logger?.LogDebug( + "AddIncludedResources: Skipping - null entity/paths or empty include paths" + ); return; + } processedEntities ??= []; + logger?.LogDebug( + "AddIncludedResources: Processing {PathCount} include paths for entity/collection of type {EntityType}", + includePaths.Count, + entityOrCollection.GetType().Name + ); + // Group include paths by their first segment IEnumerable> grouped = includePaths .Select(path => path.Split('.', 2)) @@ -53,27 +67,52 @@ public static void AddIncludedResources( string relationshipName = group.Key; var nestedPaths = group.Where(x => x != null).Select(x => x!).ToList(); + logger?.LogDebug( + "Processing relationship '{RelationshipName}' with {NestedPathCount} nested paths", + relationshipName, + nestedPaths.Count + ); + if (entityOrCollection is IEnumerable enumerable and not string) { + int entityCount = 0; foreach (object? entity in enumerable) { + entityCount++; + logger?.LogDebug( + "Processing entity {EntityIndex} for relationship '{RelationshipName}'", + entityCount, + relationshipName + ); + AddIncludedForEntity( entity, relationshipName, nestedPaths, included, - processedEntities + processedEntities, + logger ); } + logger?.LogDebug( + "Processed {EntityCount} entities for relationship '{RelationshipName}'", + entityCount, + relationshipName + ); } else { + logger?.LogDebug( + "Processing single entity for relationship '{RelationshipName}'", + relationshipName + ); AddIncludedForEntity( entityOrCollection, relationshipName, nestedPaths, included, - processedEntities + processedEntities, + logger ); } } @@ -84,34 +123,81 @@ private static void AddIncludedForEntity( string relationshipName, List nestedPaths, List included, - HashSet processedEntities + HashSet processedEntities, + ILogger? logger = null ) { if (entity == null) + { + logger?.LogDebug("AddIncludedForEntity: Skipping null entity"); return; + } Type type = entity.GetType(); + logger?.LogDebug( + "AddIncludedForEntity: Looking for relationship '{RelationshipName}' on entity type '{EntityType}'", + relationshipName, + type.Name + ); + PropertyInfo? relProp = type.GetProperties() .FirstOrDefault(p => string.Equals(p.Name, relationshipName, StringComparison.OrdinalIgnoreCase) ); if (relProp == null) + { + logger?.LogWarning( + "AddIncludedForEntity: Relationship '{RelationshipName}' not found on entity type '{EntityType}'. Available properties: {PropertyNames}", + relationshipName, + type.Name, + string.Join(", ", type.GetProperties().Select(p => p.Name)) + ); return; + } + + logger?.LogDebug( + "AddIncludedForEntity: Found relationship property '{PropertyName}' of type '{PropertyType}'", + relProp.Name, + relProp.PropertyType.Name + ); object? relValue = relProp.GetValue(entity); if (relValue == null) + { + logger?.LogDebug( + "AddIncludedForEntity: Relationship '{RelationshipName}' has null value on entity", + relationshipName + ); return; + } if (relValue is IEnumerable relCollection && relValue.GetType() != typeof(string)) { + int collectionCount = 0; + foreach (object? relEntity in relCollection) + { + collectionCount++; + } + + logger?.LogDebug( + "AddIncludedForEntity: Processing to-many relationship '{RelationshipName}' with {CollectionCount} items", + relationshipName, + collectionCount + ); + foreach (object? relEntity in relCollection) { - AddSingleIncluded(relEntity, included, processedEntities, nestedPaths); + AddSingleIncluded(relEntity, included, processedEntities, nestedPaths, logger); } } else { - AddSingleIncluded(relValue, included, processedEntities, nestedPaths); + logger?.LogDebug( + "AddIncludedForEntity: Processing to-one relationship '{RelationshipName}' with value type '{ValueType}'", + relationshipName, + relValue.GetType().Name + ); + AddSingleIncluded(relValue, included, processedEntities, nestedPaths, logger); } } @@ -119,37 +205,82 @@ private static void AddSingleIncluded( object relEntity, List included, HashSet processedEntities, - List nestedPaths + List nestedPaths, + ILogger? logger = null ) { if (relEntity == null) + { + logger?.LogDebug("AddSingleIncluded: Skipping null related entity"); return; + } Type type = relEntity.GetType(); PropertyInfo? idProp = EntityMapper.GetIdProperty(type); if (idProp == null) + { + logger?.LogWarning( + "AddSingleIncluded: No ID property found on entity type '{EntityType}', cannot include", + type.Name + ); return; + } + object? idValue = idProp.GetValue(relEntity); if (idValue == null) + { + logger?.LogDebug( + "AddSingleIncluded: Entity of type '{EntityType}' has null ID, skipping", + type.Name + ); return; // <-- Defensive: skip if no ID + } string id = idValue.ToString()!; - string key = $"{EntityMapper.GetResourceType(type)}:{id}"; + string resourceType = EntityMapper.GetResourceType(type); + string key = $"{resourceType}:{id}"; + + logger?.LogDebug( + "AddSingleIncluded: Processing entity '{ResourceType}' with ID '{EntityId}' (key: '{Key}')", + resourceType, + id, + key + ); + if (!processedEntities.Add(key)) + { + logger?.LogDebug( + "AddSingleIncluded: Entity '{Key}' already processed, skipping duplicate", + key + ); return; // Already processed + } - // Map the related entity to a ResourceObject (attributes + relationships) - var resourceObject = JsonApiMapper.ToResourceObject( - relEntity, - EntityMapper.GetResourceType(type), - nestedPaths + logger?.LogDebug( + "AddSingleIncluded: Mapping entity '{Key}' to ResourceObject with {NestedPathCount} nested paths", + key, + nestedPaths?.Count ?? 0 ); + + // Map the related entity to a ResourceObject (attributes + relationships) + var resourceObject = JsonApiMapper.ToResourceObject(relEntity, resourceType, nestedPaths); included.Add(resourceObject); + logger?.LogDebug( + "AddSingleIncluded: Successfully added entity '{Key}' to included resources (total included: {IncludedCount})", + key, + included.Count + ); + // Recursively process nested include paths if (nestedPaths?.Count > 0) { - AddIncludedResources(relEntity, nestedPaths, included, processedEntities); + logger?.LogDebug( + "AddSingleIncluded: Recursively processing {NestedPathCount} nested paths for entity '{Key}'", + nestedPaths.Count, + key + ); + AddIncludedResources(relEntity, nestedPaths, included, logger, processedEntities); } } diff --git a/JsonApiToolkit/Mapping/JsonApiMapper.cs b/JsonApiToolkit/Mapping/JsonApiMapper.cs index f0da1ea..a853501 100644 --- a/JsonApiToolkit/Mapping/JsonApiMapper.cs +++ b/JsonApiToolkit/Mapping/JsonApiMapper.cs @@ -248,12 +248,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; @@ -342,12 +360,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; From 8c14bc0f97b42e0488e9768fe9a5b4bd06bf8a5f Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Mon, 29 Sep 2025 22:31:45 +0200 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=F0=9F=94=A8=20optimize=20loggi?= =?UTF-8?q?ng=20and=20add=20XML=20documentation=20-=20Remove=20excessive?= =?UTF-8?q?=20debug=20logging=20across=20all=20components=20for=20better?= =?UTF-8?q?=20performance=20-=20Add=20minimal=20XML=20documentation=20comm?= =?UTF-8?q?ents=20for=20all=20public=20APIs=20(resolves=20CS1591=20warning?= =?UTF-8?q?s)=20-=20Streamline=20logging=20to=20essential=20information=20?= =?UTF-8?q?only=20(errors,=20warnings,=20key=20operations)=20-=20Improve?= =?UTF-8?q?=20code=20clarity=20by=20reducing=20log=20noise=20while=20maint?= =?UTF-8?q?aining=20debuggability=20-=20Clean=20up=20debugging=20guide=20t?= =?UTF-8?q?o=20focus=20on=20practical=20troubleshooting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Attributes/AllowedIncludesAttribute.cs | 16 +- .../Controllers/JsonApiController.cs | 270 ++++-------------- .../Extensions/QueryableExtensions.cs | 45 +-- .../Querying/FilterExpressionBuilder.cs | 259 +++-------------- .../Extensions/Querying/FilterHandler.cs | 35 +-- .../Querying/FilteredIncludeBuilder.cs | 10 +- .../Querying/IncludeFilterParser.cs | 13 +- .../Extensions/Querying/PaginationHandler.cs | 35 +-- .../Extensions/Querying/QueryHelpers.cs | 74 ++--- .../Extensions/Querying/SortingHandler.cs | 32 +-- .../Extensions/ServiceCollectionExtensions.cs | 7 +- JsonApiToolkit/Extensions/StringExtensions.cs | 21 +- .../Filters/JsonApiContentTypeFilter.cs | 8 +- .../Filters/JsonApiExceptionFilter.cs | 16 +- JsonApiToolkit/Helpers/EfIncludePathHelper.cs | 83 +++--- JsonApiToolkit/JsonApiToolkit.csproj | 2 +- JsonApiToolkit/Mapping/EntityMapper.cs | 79 +---- JsonApiToolkit/Mapping/InclusionMapper.cs | 192 +------------ JsonApiToolkit/Mapping/JsonApiMapper.cs | 52 +--- .../Documents/JsonApiCollectionDocument.cs | 19 +- .../Models/Documents/JsonApiDocument.cs | 37 +-- JsonApiToolkit/Models/Errors/ErrorSource.cs | 34 +-- JsonApiToolkit/Models/Errors/JsonApiError.cs | 45 +-- .../Models/Errors/JsonApiErrorResponse.cs | 17 +- .../Models/Errors/JsonApiErrorTypes.cs | 92 ++---- JsonApiToolkit/Models/Metadata/Links.cs | 51 +--- .../Models/Metadata/PaginationMeta.cs | 31 +- .../Models/Querying/Filtering/FilterGroup.cs | 32 +-- .../Querying/Filtering/FilterOperator.cs | 62 +--- .../Querying/Filtering/FilterParameter.cs | 31 +- .../Querying/Filtering/IncludeFilter.cs | 26 +- .../Querying/Filtering/LogicalOperator.cs | 21 +- .../Models/Querying/PaginationParameters.cs | 22 +- .../Models/Querying/QueryParameters.cs | 37 +-- .../Models/Querying/SortParameter.cs | 29 +- .../Models/Resources/Relationship.cs | 48 +--- .../Models/Resources/ResourceIdentifier.cs | 19 +- .../Models/Resources/ResourceObject.cs | 50 +--- .../Models/Validation/IncludePattern.cs | 40 +-- JsonApiToolkit/Parsing/JsonApiFilterParser.cs | 125 +------- JsonApiToolkit/Parsing/JsonApiQueryParser.cs | 81 +----- .../Services/IJsonApiQueryParser.cs | 6 +- .../Services/JsonApiQueryParserService.cs | 63 +--- .../Validation/IncludePatternValidator.cs | 16 +- JsonApiToolkit/Validation/IncludeValidator.cs | 20 +- docs/docs/debugging.md | 90 ++---- 46 files changed, 398 insertions(+), 1995 deletions(-) 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 3505907..5139f83 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -17,13 +17,9 @@ 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))] @@ -33,54 +29,28 @@ public abstract class JsonApiController : ControllerBase private IJsonApiQueryParser? _queryParser; /// - /// Gets the logger instance from dependency injection. + /// Gets the logger instance. /// - protected ILogger Logger => + protected ILogger Logger => _logger ??= HttpContext.RequestServices.GetRequiredService>(); /// - /// Gets the query parser instance from dependency injection. + /// Gets the query parser service. /// - protected IJsonApiQueryParser QueryParser => + protected IJsonApiQueryParser QueryParser => _queryParser ??= HttpContext.RequestServices.GetRequiredService(); /// - /// Extracts and parses JSON:API query parameters from the current HTTP request. + /// 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 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 { @@ -101,17 +71,8 @@ protected IActionResult JsonApiOk(T entity, string resourceType) } /// - /// 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, @@ -137,192 +98,104 @@ protected IActionResult JsonApiOk( } /// - /// 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 ) where T : class { - Logger.LogDebug( - "Starting JSON:API query processing for resource type '{ResourceType}'", - resourceType - ); - QueryParameters parameters = GetJsonApiQueryParameters(); + Logger.LogDebug( - "Parsed query parameters: Filters={FilterCount}, Sorts={SortCount}, Includes={IncludeCount}, Pagination={HasPagination}", + "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 ); - // User-friendly warnings for common issues - if (parameters.Include?.Count > 0) + if (parameters.Filter?.Filters?.Count > 20) { Logger.LogInformation( - "Processing includes: {Includes}. If you get errors, ensure these relationships exist on {EntityType}", - string.Join(", ", parameters.Include), + "Complex query with {Count} filters on {EntityType}", + parameters.Filter.Filters.Count, typeof(T).Name ); } - if (parameters.Filter?.Filters?.Count > 10) - { - Logger.LogWarning( - "Large number of filters detected ({FilterCount}). This may impact performance. Consider simplifying the query", - parameters.Filter.Filters.Count - ); - } - string baseUrl = GetFullRequestUrl(); var mappedIncludes = EfIncludePathHelper.MapIncludePathsToClrProperties( parameters.Include ); - Logger.LogDebug( - "Mapped {IncludeCount} include paths to CLR properties: {MappedIncludes}", - mappedIncludes.Count, - string.Join(", ", mappedIncludes) - ); - - // User-friendly warnings for include mapping issues if (parameters.Include?.Count > 0 && mappedIncludes.Count == 0) { Logger.LogWarning( - "No valid include paths found for {EntityType}. Requested: {RequestedIncludes}. Check that these properties exist and are navigation properties", + "No valid includes for {EntityType}. Requested: {Includes}", typeof(T).Name, string.Join(", ", parameters.Include) ); } - else if (parameters.Include?.Count > mappedIncludes.Count) - { - var unmapped = parameters.Include.Except(mappedIncludes.Select(m => m.Split('.')[0])).ToList(); - if (unmapped.Count > 0) - { - Logger.LogWarning( - "Some includes could not be mapped for {EntityType}: {UnmappedIncludes}. Check property names and navigation relationships", - typeof(T).Name, - string.Join(", ", unmapped) - ); - } - } - // Separate include filters from main filters var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( parameters.Filter, parameters.Include ); - Logger.LogDebug( - "Separated filters: MainFilters={MainFilterCount}, IncludeFilters={IncludeFilterCount}", - mainFilters?.Filters?.Count ?? 0, - includeFilters.Count - ); - IQueryable filteredQuery = queryable; - // Apply main entity filters first if (mainFilters != null) - { - Logger.LogDebug( - "Applying {FilterCount} main entity filters", - mainFilters.Filters.Count - ); filteredQuery = filteredQuery.ApplyFilters(mainFilters, Logger); - } - - // Standardized order: Filters -> Includes -> Sorting - // This ensures consistent behavior regardless of include type - // Apply includes (filtered or regular) if (includeFilters.Count > 0) { Logger.LogDebug( - "Applying {FilteredIncludeCount} filtered includes", - includeFilters.Count + "Applying {FilterCount} filtered includes for {EntityType}", + includeFilters.Count, + typeof(T).Name ); filteredQuery = filteredQuery.ApplyFilteredIncludes(mappedIncludes, includeFilters); } else if (mappedIncludes.Count > 0) { - Logger.LogDebug("Applying {IncludeCount} regular includes", mappedIncludes.Count); + // Use single query with pagination to avoid EF Core split query issues + filteredQuery = parameters.Pagination != null + ? filteredQuery.ApplyIncludesSingleQuery(mappedIncludes) + : filteredQuery.ApplyIncludes(mappedIncludes); - // Use AsSingleQuery when pagination is present to avoid EF Core split query issues - // that cause includes to fail with large datasets + small page sizes - if (parameters.Pagination != null) - { - Logger.LogDebug( - "Using AsSingleQuery for includes due to pagination to prevent split query issues" - ); - filteredQuery = filteredQuery.ApplyIncludesSingleQuery(mappedIncludes); - } - else - { - filteredQuery = filteredQuery.ApplyIncludes(mappedIncludes); - } + Logger.LogDebug( + "Applied {IncludeCount} includes for {EntityType} using {QueryType}", + mappedIncludes.Count, + typeof(T).Name, + parameters.Pagination != null ? "SingleQuery" : "SplitQuery" + ); } - // Apply sorting after includes for consistency if (parameters.Sort?.Count > 0) - { - Logger.LogDebug("Applying {SortCount} sort parameters", parameters.Sort.Count); filteredQuery = filteredQuery.ApplySorting(parameters.Sort, Logger); - } - Logger.LogDebug("Executing count query to get total resource count"); int totalCount = await filteredQuery.CountAsync().ConfigureAwait(false); - Logger.LogDebug("Total count after filtering: {TotalCount}", totalCount); - // User-friendly info about results - if (totalCount == 0 && (parameters.Filter?.Filters?.Count > 0 || parameters.Include?.Count > 0)) + if (totalCount == 0 && parameters.Filter?.Filters?.Count > 0) { Logger.LogInformation( - "Query returned 0 results for {EntityType}. This might be due to filters or include conditions. Check your filter values and relationship data", + "Query returned 0 results for {EntityType}", typeof(T).Name ); } - else if (totalCount > 1000) + else if (totalCount > 1000 && parameters.Pagination == null) { Logger.LogWarning( - "Large result set detected ({TotalCount} records). Consider adding pagination or more specific filters for better performance", + "Large result set ({TotalCount}) without pagination. Consider adding pagination to improve performance", totalCount ); } if (parameters.Pagination != null) - { - Logger.LogDebug( - "Applying pagination: Page={PageNumber}, Size={PageSize}", - parameters.Pagination.Number, - parameters.Pagination.Size - ); filteredQuery = filteredQuery.ApplyPagination(parameters.Pagination); - } PaginationMeta? paginationMeta = null; if (parameters.Pagination != null) @@ -334,20 +207,17 @@ string resourceType CurrentPage = parameters.Pagination.Number, PageSize = parameters.Pagination.Size, }; - - Logger.LogDebug( - "Created pagination metadata: TotalPages={TotalPages}, CurrentPage={CurrentPage}, PageSize={PageSize}", - paginationMeta.TotalPages, - paginationMeta.CurrentPage, - paginationMeta.PageSize - ); } - Logger.LogDebug("Executing final query to retrieve results"); + 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); - Logger.LogDebug("Retrieved {ResultCount} results from database", results.Count); - Logger.LogDebug("Mapping results to JSON:API document structure"); JsonApiCollectionDocument document = JsonApiMapper.ToCollectionDocument( results, resourceType, @@ -357,29 +227,12 @@ string resourceType Logger ); - Logger.LogDebug( - "Successfully completed JSON:API query processing for resource type '{ResourceType}' with {ResourceCount} resources and {IncludedCount} included resources", - resourceType, - document.Data?.Count() ?? 0, - document.Included?.Count() ?? 0 - ); - 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 { @@ -401,25 +254,13 @@ protected IActionResult JsonApiCreated(T entity, string resourceType, string } /// - /// 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 @@ -428,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 @@ -448,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 decbb1e..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 @@ -81,17 +55,9 @@ public static IQueryable ApplyIncludes( } /// - /// Dynamically applies EF Core Include() calls using AsSingleQuery() to prevent split query issues. + /// Applies EF Core Include() using AsSingleQuery() to prevent split query issues with pagination. + /// Forces single query with JOINs instead of separate queries. /// - /// The entity type. - /// The source queryable. - /// A list of include paths (e.g. "todo", "todo.category"). - /// The queryable with all includes applied using single query mode. - /// - /// Use this method when pagination is present to avoid EF Core split query optimization issues - /// that can cause includes to load data for wrong entities or no entities at all. - /// Forces EF Core to use a single query with JOINs instead of separate queries. - /// public static IQueryable ApplyIncludesSingleQuery( this IQueryable query, List? includePaths @@ -101,7 +67,6 @@ public static IQueryable ApplyIncludesSingleQuery( if (includePaths == null || includePaths.Count == 0) return query; - // Force single query to prevent EF Core split query issues with pagination query = query.AsSingleQuery(); foreach (string path in includePaths) diff --git a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs index 5514fe5..c7ae788 100644 --- a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs +++ b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs @@ -7,59 +7,32 @@ 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 - /// Optional logger for debugging and tracing - /// - /// 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, ILogger? logger = null ) { - logger?.LogDebug( - "Building filter expression for {FilterCount} filters and {GroupCount} nested groups with logical operator {LogicalOperator}", - group.Filters.Count, - group.Groups.Count, - group.LogicalOperator - ); - var expressions = new List(); foreach (FilterParameter filter in group.Filters) { - logger?.LogDebug( - "Processing filter: Field='{Field}', Operator={Operator}, Value='{Value}'", - filter.Field, - filter.Operator, - filter.Value - ); - Expression? expr; if (filter.Field.Contains('.')) { - logger?.LogDebug( - "Building nested property filter expression for field '{Field}'", - filter.Field - ); expr = BuildSingleFilterExpression(parameter, filter, logger); } else @@ -71,108 +44,64 @@ public static class FilterExpressionBuilder if (property == null) { logger?.LogWarning( - "Property '{Field}' not found on type {Type}, skipping filter", + "Property '{Field}' not found on {Type}, skipping filter", filter.Field, typeof(T).Name ); continue; } - logger?.LogDebug( - "Building simple property filter expression for field '{Field}' -> property '{PropertyName}'", - filter.Field, - property.Name - ); expr = BuildSingleFilterExpression(parameter, filter, logger); } if (expr != null) { - logger?.LogDebug( - "Successfully built filter expression for field '{Field}'", - filter.Field - ); expressions.Add(expr); } else { - logger?.LogWarning( - "Failed to build filter expression for field '{Field}'", - filter.Field - ); + logger?.LogWarning("Failed to build filter for '{Field}'", filter.Field); } } foreach (FilterGroup nestedGroup in group.Groups) { - logger?.LogDebug( - "Processing nested filter group with {NestedFilterCount} filters and logical operator {NestedLogicalOperator}", - nestedGroup.Filters.Count, - nestedGroup.LogicalOperator - ); Expression? nestedExpr = BuildFilterExpression(nestedGroup, parameter, logger); if (nestedExpr != null) { - logger?.LogDebug("Successfully built nested group expression"); expressions.Add(nestedExpr); } - else - { - logger?.LogDebug("Nested group expression resulted in null"); - } } if (expressions.Count == 0) - { - logger?.LogDebug("No valid filter expressions found, returning null"); return null; - } if (expressions.Count == 1) { Expression singleExpression = expressions[0]; if (group.LogicalOperator == LogicalOperator.Not) { - logger?.LogDebug("Applying NOT operator to single expression"); return Expression.Not(singleExpression); } - logger?.LogDebug("Returning single filter expression without logical combination"); return singleExpression; } - logger?.LogDebug( - "Combining {ExpressionCount} expressions with logical operator {LogicalOperator}", - expressions.Count, - group.LogicalOperator - ); - Expression? combinedExpression = null; - // For NOT operator, we need to apply De Morgan's law: + // For NOT: apply De Morgan's law // NOT(A AND B) = NOT(A) OR NOT(B) - // NOT(A OR B) = NOT(A) AND NOT(B) - // Since the filters in a group are combined with AND by default, - // NOT group means NOT(A AND B AND C...) = NOT(A) OR NOT(B) OR NOT(C)... if (group.LogicalOperator == LogicalOperator.Not) { - // Apply NOT to each expression individually and combine with OR foreach (Expression expr in expressions) { var notExpr = Expression.Not(expr); - if (combinedExpression == null) - { - combinedExpression = notExpr; - } - else - { - // Use OR for NOT group (De Morgan's law) - combinedExpression = Expression.OrElse(combinedExpression, notExpr); - } + combinedExpression = + combinedExpression == null + ? notExpr + : Expression.OrElse(combinedExpression, notExpr); } - logger?.LogDebug("Applied NOT operator using De Morgan's law (combined with OR)"); } else { - // Normal AND/OR combination foreach (Expression expr in expressions) { if (combinedExpression == null) @@ -191,61 +120,40 @@ public static class FilterExpressionBuilder } } - logger?.LogDebug("Successfully built combined filter expression"); return combinedExpression; } /// /// Builds a filter expression for a single FilterParameter. /// - /// The parameter expression representing the entity - /// The filter parameter to build an expression for - /// Optional logger for debugging and tracing - /// 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, ILogger? logger = null ) { - logger?.LogDebug( - "Building single filter expression for field '{Field}' with operator {Operator}", - filter.Field, - filter.Operator - ); - if (filter.Field.Contains('.')) { - logger?.LogDebug("Field contains dot notation, building safe nested filter expression"); return BuildSafeNestedFilterExpression(parameter, filter, logger); } - else - { - PropertyInfo? property = QueryHelpers.GetPropertyByJsonName( - parameter.Type, - filter.Field - ); - if (property == null) - { - logger?.LogWarning( - "Property '{Field}' not found on entity type '{EntityType}'. Available properties: {Properties}. Check your filter field names", - filter.Field, - parameter.Type.Name, - string.Join(", ", parameter.Type.GetProperties().Select(p => p.Name)) - ); - return null; - } - logger?.LogDebug( - "Found property '{PropertyName}' of type {PropertyType} for field '{Field}'", - property.Name, - property.PropertyType.Name, - filter.Field + PropertyInfo? property = QueryHelpers.GetPropertyByJsonName(parameter.Type, filter.Field); + if (property == null) + { + logger?.LogWarning( + "Property '{Field}' not found on {EntityType}", + filter.Field, + parameter.Type.Name ); - - Expression propertyAccess = Expression.Property(parameter, property); - return BuildPropertyFilterExpression(propertyAccess, filter, logger); + return null; } + + Expression propertyAccess = Expression.Property(parameter, property); + return BuildPropertyFilterExpression(propertyAccess, filter, logger); } private static Expression BuildLikeExpression(Expression property, string value) @@ -380,41 +288,24 @@ Type propertyType ILogger? logger = null ) { - logger?.LogDebug( - "Building safe nested filter expression for field path '{Field}'", - filter.Field - ); - string[] parts = filter.Field.Split('.'); Expression current = parameter; var nullChecks = new List(); - logger?.LogDebug( - "Navigating through {PartCount} property parts: {Parts}", - parts.Length, - string.Join(" -> ", parts) - ); - - // 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 {Type} during nested navigation", + "Property '{PropertyName}' not found on {Type} during navigation", parts[i], current.Type.Name ); return null; } - logger?.LogDebug( - "Navigating to property '{PropertyName}' of type {PropertyType}", - prop.Name, - prop.PropertyType.Name - ); - current = Expression.Property(current, prop); // Add null check for reference types @@ -423,10 +314,6 @@ Type propertyType || Nullable.GetUnderlyingType(prop.PropertyType) != null ) { - logger?.LogDebug( - "Adding null check for reference type property '{PropertyName}'", - prop.Name - ); nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null))); } } @@ -436,57 +323,31 @@ Type propertyType if (finalProp == null) { logger?.LogWarning( - "Final property '{PropertyName}' not found on type {Type}", + "Property '{PropertyName}' not found on {Type}", parts[^1], current.Type.Name ); return null; } - logger?.LogDebug( - "Found final property '{PropertyName}' of type {PropertyType}", - finalProp.Name, - finalProp.PropertyType.Name - ); - Expression finalProperty = Expression.Property(current, finalProp); - - // Build the actual filter expression Expression? filterExpression = BuildPropertyFilterExpression(finalProperty, filter, logger); if (filterExpression == null) - { - logger?.LogWarning("Failed to build property filter expression for final property"); return null; - } - logger?.LogDebug( - "Built filter expression, applying {NullCheckCount} null checks", - nullChecks.Count - ); - - // For inequality operators (Ne, Nin), null values should be treated differently - // null != value should be true, not filtered out + // 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) { - // For inequality: if any property in the chain is null, return true (not equal) - // Otherwise, apply the filter expression if (nullChecks.Count > 0) { - // Create an OR condition: (any property is null) OR (all not null AND filter matches) Expression allNotNull = nullChecks[0]; for (int i = 1; i < nullChecks.Count; i++) - { allNotNull = Expression.AndAlso(allNotNull, nullChecks[i]); - } - // Any property is null Expression anyNull = Expression.Not(allNotNull); - - // All not null AND filter matches Expression notNullAndFilter = Expression.AndAlso(allNotNull, filterExpression); - - // Return true if any null OR filter matches result = Expression.OrElse(anyNull, notNullAndFilter); } else @@ -496,18 +357,11 @@ Type propertyType } else { - // For equality and other operators: all properties must be non-null AND filter matches result = filterExpression; foreach (Expression nullCheck in nullChecks) - { result = Expression.AndAlso(nullCheck, result); - } } - logger?.LogDebug( - "Successfully built safe nested filter expression with proper null handling for operator {Operator}", - filter.Operator - ); return result; } @@ -519,31 +373,17 @@ Type propertyType { Type targetType = propertyAccess.Type; - logger?.LogDebug( - "Building property filter expression for operator {Operator} on type {PropertyType} with value '{Value}'", - filter.Operator, - targetType.Name, - filter.Value - ); - if (filter.Operator == FilterOperator.IsNull) - { - logger?.LogDebug("Building IsNull expression"); return Expression.Equal(propertyAccess, Expression.Constant(null)); - } + if (filter.Operator == FilterOperator.IsNotNull) - { - logger?.LogDebug("Building IsNotNull expression"); return Expression.NotEqual(propertyAccess, Expression.Constant(null)); - } if (filter.Operator == FilterOperator.In) { - logger?.LogDebug("Building In expression for values: {Values}", filter.Value); Type? underlying = Nullable.GetUnderlyingType(targetType); if (underlying != null) { - logger?.LogDebug("Property is nullable, building null-safe In expression"); BinaryExpression notNullExpr = Expression.NotEqual( propertyAccess, Expression.Constant(null, propertyAccess.Type) @@ -555,12 +395,9 @@ Type propertyType ); return Expression.AndAlso(notNullExpr, containsExpr); } - else - { - logger?.LogDebug("Property is not nullable, building direct In expression"); - return BuildInExpression(propertyAccess, filter.Value, targetType); - } + return BuildInExpression(propertyAccess, filter.Value, targetType); } + if (filter.Operator == FilterOperator.Nin) { Type? underlying = Nullable.GetUnderlyingType(targetType); @@ -577,18 +414,9 @@ Type propertyType ); return Expression.OrElse(isNullExpr, Expression.Not(containsExpr)); } - else - { - return Expression.Not(BuildInExpression(propertyAccess, filter.Value, targetType)); - } + return Expression.Not(BuildInExpression(propertyAccess, filter.Value, targetType)); } - logger?.LogDebug( - "Converting filter value '{Value}' to property type {PropertyType}", - filter.Value, - targetType.Name - ); - object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, targetType); if ( filterValue == null @@ -597,18 +425,13 @@ Type propertyType ) { logger?.LogWarning( - "Failed to convert filter value '{Value}' to type {PropertyType} for operator {Operator}", + "Failed to convert '{Value}' to {PropertyType}", filter.Value, - targetType.Name, - filter.Operator + targetType.Name ); return null; } - logger?.LogDebug( - "Successfully converted filter value, building {Operator} expression", - filter.Operator - ); 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 b30dd39..2540549 100644 --- a/JsonApiToolkit/Extensions/Querying/FilterHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/FilterHandler.cs @@ -5,47 +5,24 @@ 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 - /// Optional logger for debugging and tracing - /// 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, ILogger? logger = null ) { - logger?.LogDebug( - "Applying filters to query for type {EntityType}: {FilterCount} direct filters, {GroupCount} nested groups", - typeof(T).Name, - filterGroup?.Filters.Count ?? 0, - filterGroup?.Groups.Count ?? 0 - ); - if ( filterGroup == null || (filterGroup.Filters.Count == 0 && filterGroup.Groups.Count == 0) ) - { - logger?.LogDebug("No filters to apply, returning original query"); return query; - } ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); Expression? expression = FilterExpressionBuilder.BuildFilterExpression( @@ -56,15 +33,11 @@ public static IQueryable ApplyFilters( if (expression != null) { - logger?.LogDebug("Successfully built filter expression, applying to query"); var lambda = Expression.Lambda>(expression, parameter); - query = query.Where(lambda); - } - else - { - logger?.LogWarning("Filter expression builder returned null, no filters applied"); + 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 40d695d..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 diff --git a/JsonApiToolkit/Extensions/Querying/SortingHandler.cs b/JsonApiToolkit/Extensions/Querying/SortingHandler.cs index 032297d..7da919a 100644 --- a/JsonApiToolkit/Extensions/Querying/SortingHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/SortingHandler.cs @@ -6,48 +6,22 @@ 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 - /// Optional logger for debugging and tracing - /// 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, ILogger? logger = null ) { - logger?.LogDebug( - "Applying sorting to query for type {EntityType} with {SortParameterCount} sort parameters", - typeof(T).Name, - sortParameters?.Count ?? 0 - ); - if (sortParameters == null || sortParameters.Count == 0) - { - logger?.LogDebug("No sort parameters provided, returning original query"); return query; - } IOrderedQueryable? orderedQuery = null; Type entityType = typeof(T); diff --git a/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs b/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs index b88a7ca..b172c6f 100644 --- a/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs +++ b/JsonApiToolkit/Extensions/ServiceCollectionExtensions.cs @@ -12,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 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 89606b6..6bcc640 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 1.1.17-local + 1.1.21-local Intility Intility A toolkit for implementing JSON:API specification in .NET applications 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 b3553cb..fa3fd48 100644 --- a/JsonApiToolkit/Mapping/InclusionMapper.cs +++ b/JsonApiToolkit/Mapping/InclusionMapper.cs @@ -7,32 +7,15 @@ 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 logger for debugging and tracing - /// 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, @@ -42,22 +25,10 @@ public static void AddIncludedResources( ) { if (entityOrCollection == null || includePaths == null || includePaths.Count == 0) - { - logger?.LogDebug( - "AddIncludedResources: Skipping - null entity/paths or empty include paths" - ); return; - } processedEntities ??= []; - logger?.LogDebug( - "AddIncludedResources: Processing {PathCount} include paths for entity/collection of type {EntityType}", - includePaths.Count, - entityOrCollection.GetType().Name - ); - - // 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); @@ -67,24 +38,10 @@ public static void AddIncludedResources( string relationshipName = group.Key; var nestedPaths = group.Where(x => x != null).Select(x => x!).ToList(); - logger?.LogDebug( - "Processing relationship '{RelationshipName}' with {NestedPathCount} nested paths", - relationshipName, - nestedPaths.Count - ); - if (entityOrCollection is IEnumerable enumerable and not string) { - int entityCount = 0; foreach (object? entity in enumerable) { - entityCount++; - logger?.LogDebug( - "Processing entity {EntityIndex} for relationship '{RelationshipName}'", - entityCount, - relationshipName - ); - AddIncludedForEntity( entity, relationshipName, @@ -94,18 +51,9 @@ public static void AddIncludedResources( logger ); } - logger?.LogDebug( - "Processed {EntityCount} entities for relationship '{RelationshipName}'", - entityCount, - relationshipName - ); } else { - logger?.LogDebug( - "Processing single entity for relationship '{RelationshipName}'", - relationshipName - ); AddIncludedForEntity( entityOrCollection, relationshipName, @@ -128,17 +76,9 @@ private static void AddIncludedForEntity( ) { if (entity == null) - { - logger?.LogDebug("AddIncludedForEntity: Skipping null entity"); return; - } Type type = entity.GetType(); - logger?.LogDebug( - "AddIncludedForEntity: Looking for relationship '{RelationshipName}' on entity type '{EntityType}'", - relationshipName, - type.Name - ); PropertyInfo? relProp = type.GetProperties() .FirstOrDefault(p => @@ -147,44 +87,19 @@ private static void AddIncludedForEntity( if (relProp == null) { logger?.LogWarning( - "AddIncludedForEntity: Relationship '{RelationshipName}' not found on entity type '{EntityType}'. Available properties: {PropertyNames}", + "Relationship '{Relationship}' not found on {Type}", relationshipName, - type.Name, - string.Join(", ", type.GetProperties().Select(p => p.Name)) + type.Name ); return; } - logger?.LogDebug( - "AddIncludedForEntity: Found relationship property '{PropertyName}' of type '{PropertyType}'", - relProp.Name, - relProp.PropertyType.Name - ); - object? relValue = relProp.GetValue(entity); if (relValue == null) - { - logger?.LogDebug( - "AddIncludedForEntity: Relationship '{RelationshipName}' has null value on entity", - relationshipName - ); return; - } if (relValue is IEnumerable relCollection && relValue.GetType() != typeof(string)) { - int collectionCount = 0; - foreach (object? relEntity in relCollection) - { - collectionCount++; - } - - logger?.LogDebug( - "AddIncludedForEntity: Processing to-many relationship '{RelationshipName}' with {CollectionCount} items", - relationshipName, - collectionCount - ); - foreach (object? relEntity in relCollection) { AddSingleIncluded(relEntity, included, processedEntities, nestedPaths, logger); @@ -192,11 +107,6 @@ private static void AddIncludedForEntity( } else { - logger?.LogDebug( - "AddIncludedForEntity: Processing to-one relationship '{RelationshipName}' with value type '{ValueType}'", - relationshipName, - relValue.GetType().Name - ); AddSingleIncluded(relValue, included, processedEntities, nestedPaths, logger); } } @@ -210,122 +120,40 @@ private static void AddSingleIncluded( ) { if (relEntity == null) - { - logger?.LogDebug("AddSingleIncluded: Skipping null related entity"); return; - } Type type = relEntity.GetType(); PropertyInfo? idProp = EntityMapper.GetIdProperty(type); if (idProp == null) { - logger?.LogWarning( - "AddSingleIncluded: No ID property found on entity type '{EntityType}', cannot include", - type.Name - ); + logger?.LogWarning("No ID property on {Type}", type.Name); return; } object? idValue = idProp.GetValue(relEntity); if (idValue == null) - { - logger?.LogDebug( - "AddSingleIncluded: Entity of type '{EntityType}' has null ID, skipping", - type.Name - ); - return; // <-- Defensive: skip if no ID - } + return; string id = idValue.ToString()!; string resourceType = EntityMapper.GetResourceType(type); string key = $"{resourceType}:{id}"; - logger?.LogDebug( - "AddSingleIncluded: Processing entity '{ResourceType}' with ID '{EntityId}' (key: '{Key}')", - resourceType, - id, - key - ); - if (!processedEntities.Add(key)) - { - logger?.LogDebug( - "AddSingleIncluded: Entity '{Key}' already processed, skipping duplicate", - key - ); return; // Already processed - } - - logger?.LogDebug( - "AddSingleIncluded: Mapping entity '{Key}' to ResourceObject with {NestedPathCount} nested paths", - key, - nestedPaths?.Count ?? 0 - ); - // Map the related entity to a ResourceObject (attributes + relationships) var resourceObject = JsonApiMapper.ToResourceObject(relEntity, resourceType, nestedPaths); included.Add(resourceObject); - logger?.LogDebug( - "AddSingleIncluded: Successfully added entity '{Key}' to included resources (total included: {IncludedCount})", - key, - included.Count - ); - - // Recursively process nested include paths if (nestedPaths?.Count > 0) { - logger?.LogDebug( - "AddSingleIncluded: Recursively processing {NestedPathCount} nested paths for entity '{Key}'", - nestedPaths.Count, - key - ); 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 a853501..2dbb64c 100644 --- a/JsonApiToolkit/Mapping/JsonApiMapper.cs +++ b/JsonApiToolkit/Mapping/JsonApiMapper.cs @@ -9,52 +9,15 @@ 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 - /// Optional logger for debugging and tracing - /// 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, @@ -66,13 +29,6 @@ public static ResourceObject ToResourceObject( Type type = entity.GetType(); - logger?.LogDebug( - "Mapping entity of type {EntityType} to resource object with type '{ResourceType}' and {IncludeCount} included relationships", - type.Name, - resourceType, - includedRelationships?.Count ?? 0 - ); - PropertyInfo? idProperty = EntityMapper.GetIdProperty(type); var idValue = (idProperty?.GetValue(entity)) 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 68b7f0c..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, diff --git a/JsonApiToolkit/Parsing/JsonApiQueryParser.cs b/JsonApiToolkit/Parsing/JsonApiQueryParser.cs index c0b125b..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,62 +16,8 @@ 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(); diff --git a/JsonApiToolkit/Services/IJsonApiQueryParser.cs b/JsonApiToolkit/Services/IJsonApiQueryParser.cs index 0b6bd45..fb61865 100644 --- a/JsonApiToolkit/Services/IJsonApiQueryParser.cs +++ b/JsonApiToolkit/Services/IJsonApiQueryParser.cs @@ -4,14 +4,12 @@ namespace JsonApiToolkit.Services; /// -/// Service interface for parsing JSON:API query parameters with logging support. +/// Service interface for parsing JSON:API query parameters. /// public interface IJsonApiQueryParser { /// - /// 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 QueryParameters Parse(HttpRequest request); } diff --git a/JsonApiToolkit/Services/JsonApiQueryParserService.cs b/JsonApiToolkit/Services/JsonApiQueryParserService.cs index a99c149..327d53f 100644 --- a/JsonApiToolkit/Services/JsonApiQueryParserService.cs +++ b/JsonApiToolkit/Services/JsonApiQueryParserService.cs @@ -6,48 +6,34 @@ namespace JsonApiToolkit.Services; /// -/// Service implementation for parsing JSON:API query parameters with comprehensive debug logging. +/// Service implementation for parsing JSON:API query parameters. /// public class JsonApiQueryParserService : IJsonApiQueryParser { private readonly ILogger _logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the query parser service. /// - /// The logger instance for logging parsing operations public JsonApiQueryParserService(ILogger logger) { _logger = logger; } - /// + /// + /// Parses JSON:API query parameters from an HTTP request. + /// public QueryParameters Parse(HttpRequest request) { - _logger.LogDebug( - "Starting to parse JSON:API query parameters from request: {RequestPath}?{QueryString}", - request.Path, - request.QueryString - ); - var queryParams = JsonApiQueryParser.Parse(request); - _logger.LogDebug( - "Successfully parsed query parameters: Filters={FilterCount}, Sorts={SortCount}, Includes={IncludeCount}, HasPagination={HasPagination}", - queryParams.Filter?.Filters?.Count ?? 0, - queryParams.Sort?.Count ?? 0, - queryParams.Include?.Count ?? 0, - queryParams.Pagination != null - ); - - // User-friendly warnings for common parameter issues if ( request.Query.Keys.Any(k => k.StartsWith("filter", StringComparison.OrdinalIgnoreCase)) && (queryParams.Filter?.Filters?.Count ?? 0) == 0 ) { _logger.LogWarning( - "Filter parameters detected in query string but no valid filters parsed. Check filter syntax: filter[fieldName][operator]=value. Example: filter[name][like]=John" + "Filter parameters detected but no valid filters parsed. Check syntax: filter[field][operator]=value" ); } @@ -57,7 +43,7 @@ public QueryParameters Parse(HttpRequest request) ) { _logger.LogWarning( - "Sort parameter detected but no valid sorts parsed. Check sort syntax: sort=field1,-field2. Example: sort=name,-createdAt" + "Sort parameter detected but no valid sorts parsed. Check syntax: sort=field1,-field2" ); } @@ -71,41 +57,6 @@ public QueryParameters Parse(HttpRequest request) ); } - if (queryParams.Pagination != null) - { - _logger.LogDebug( - "Pagination parameters: Page={PageNumber}, Size={PageSize}", - queryParams.Pagination.Number, - queryParams.Pagination.Size - ); - } - - if (queryParams.Filter != null) - { - _logger.LogDebug( - "Filter details: DirectFilters={DirectFilterCount}, Groups={GroupCount}", - queryParams.Filter.Filters?.Count ?? 0, - queryParams.Filter.Groups?.Count ?? 0 - ); - } - - if (queryParams.Sort?.Count > 0) - { - var sortFields = string.Join( - ", ", - queryParams.Sort.Select(s => $"{s.Field}({(s.IsDescending ? "desc" : "asc")})") - ); - _logger.LogDebug("Sort fields: {SortFields}", sortFields); - } - - if (queryParams.Include?.Count > 0) - { - _logger.LogDebug( - "Include relationships: {IncludeFields}", - string.Join(", ", queryParams.Include) - ); - } - 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 index b3510a7..bc44991 100644 --- a/docs/docs/debugging.md +++ b/docs/docs/debugging.md @@ -1,103 +1,49 @@ # Debugging Guide -This guide explains how to enable debug logging for JsonApiToolkit to troubleshoot query processing, filtering, and EF Core expression generation. - -## Enable Debug Logging - -Add the following configuration to your `appsettings.json` or `appsettings.Development.json`: +## Enable Logging ```json { "Serilog": { "MinimumLevel": { - "Default": "Information", "Override": { - "JsonApiToolkit": "Debug" + "JsonApiToolkit": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } } } } ``` -For Microsoft.Extensions.Logging, use: +Or for Microsoft.Extensions.Logging: ```json { "Logging": { "LogLevel": { - "Default": "Information", - "JsonApiToolkit": "Debug" + "JsonApiToolkit": "Debug", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } } } ``` -## User-Friendly Logging - -JsonApiToolkit provides helpful **Information** and **Warning** level logs that guide you when things go wrong: - -### Common Issues and Log Messages - -**Property Not Found in Filters:** -``` -WARN: Property 'userName' not found on entity type 'User'. Available properties: Id, Name, Email. Check your filter field names -``` - -**Invalid Include Paths:** -``` -WARN: Some includes could not be mapped for User: profile, invalidRelation. Check property names and navigation relationships -``` - -**Query Parameter Syntax Issues:** -``` -WARN: Filter parameters detected but no valid filters parsed. Check filter syntax: filter[fieldName][operator]=value -WARN: Sort parameter detected but no valid sorts parsed. Check sort syntax: sort=field1,-field2 -``` - -**Type Conversion Errors:** -``` -ERROR: Failed to convert filter value 'invalid-date' to type 'DateTime'. Expected format examples: DateTime: '2023-12-25T10:30:00Z' -``` - -**Performance Warnings:** -``` -WARN: Large number of filters detected (15). This may impact performance. Consider simplifying the query -WARN: Large result set detected (5000 records). Consider adding pagination or more specific filters -``` - -**Empty Results:** -``` -INFO: Query returned 0 results for User. This might be due to filters or include conditions. Check your filter values and relationship data -``` - ## What Gets Logged -**Information Level:** -- Include processing status and helpful hints +**Information:** - Empty result explanations -- Query result summaries +- Complex queries (>20 filters) -**Warning Level:** -- Invalid property names with suggestions -- Parameter parsing issues with examples -- Performance concerns +**Warning:** +- Invalid property/field names +- Parameter parsing issues +- Large unpaginated results (>1000) - Include mapping problems -**Debug Level (detailed):** -- Query parameter parsing details -- Filter expression building steps -- Include path mapping -- EF Core query execution -- Pagination calculations - -## Log Categories - -- `JsonApiToolkit.Controllers.JsonApiController` - Main query processing and user-friendly messages -- `JsonApiToolkit.Services.JsonApiQueryParserService` - Parameter parsing warnings -- `JsonApiToolkit.Extensions.Querying` - Filter processing and property resolution -- `JsonApiToolkit.Mapping.JsonApiMapper` - Entity-to-JSON mapping - -## Performance Impact +**Debug:** +- Query summaries (filters/sorts/includes/pagination) +- Include strategy (SingleQuery/SplitQuery) +- Execution summaries (counts) -- **Information/Warning logs**: Minimal overhead, safe for production -- **Debug logs**: More detailed, recommended for development only \ No newline at end of file +**EF Core SQL (Information level):** +- Actual SQL queries executed From a0a51ddaf865e8411b9cb1be6deddf4645aa82e8 Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Mon, 29 Sep 2025 22:39:05 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=F0=9F=9A=91=EF=B8=8F=20correct=20ve?= =?UTF-8?q?rsion=20number=20in=20project=20file=20to=20match=20release=20v?= =?UTF-8?q?ersion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- JsonApiToolkit/JsonApiToolkit.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JsonApiToolkit/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index 6bcc640..75117f3 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 1.1.21-local + 1.1.4 Intility Intility A toolkit for implementing JSON:API specification in .NET applications