diff --git a/JsonApiToolkit.Tests/Parsing/StrictPaginationTests.cs b/JsonApiToolkit.Tests/Parsing/StrictPaginationTests.cs new file mode 100644 index 0000000..df72d69 --- /dev/null +++ b/JsonApiToolkit.Tests/Parsing/StrictPaginationTests.cs @@ -0,0 +1,168 @@ +using JsonApiToolkit.Configuration; +using JsonApiToolkit.Models.Errors; +using JsonApiToolkit.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace JsonApiToolkit.Tests.Parsing; + +public class StrictPaginationTests +{ + private static JsonApiQueryParserService CreateService(JsonApiOptions? options = null) + { + options ??= new JsonApiOptions(); + return new JsonApiQueryParserService( + NullLogger.Instance, + Options.Create(options) + ); + } + + private static HttpRequest CreateRequest(Dictionary query) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = new QueryCollection(query); + return httpContext.Request; + } + + // ───────────────────────────────────────────────────────────────────────── + // Default behavior (StrictPagination = false) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void Default_PageNumberZero_ClampedToOne() + { + var service = CreateService(); + var request = CreateRequest( + new Dictionary { ["page[number]"] = "0" } + ); + + var result = service.Parse(request); + + Assert.NotNull(result.Pagination); + Assert.Equal(1, result.Pagination.Number); + } + + [Fact] + public void Default_PageSizeExceedsMax_ClampedToMax() + { + var service = CreateService(new JsonApiOptions { MaxPageSize = 50 }); + var request = CreateRequest( + new Dictionary { ["page[size]"] = "200" } + ); + + var result = service.Parse(request); + + Assert.NotNull(result.Pagination); + Assert.Equal(50, result.Pagination.Size); + } + + // ───────────────────────────────────────────────────────────────────────── + // Strict mode (StrictPagination = true) + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void Strict_PageNumberZero_Throws400() + { + var service = CreateService(new JsonApiOptions { StrictPagination = true }); + var request = CreateRequest( + new Dictionary { ["page[number]"] = "0" } + ); + + var ex = Assert.Throws(() => service.Parse(request)); + Assert.Contains("Invalid page number", ex.Message); + } + + [Fact] + public void Strict_PageNumberNegative_Throws400() + { + var service = CreateService(new JsonApiOptions { StrictPagination = true }); + var request = CreateRequest( + new Dictionary { ["page[number]"] = "-5" } + ); + + var ex = Assert.Throws(() => service.Parse(request)); + Assert.Contains("Invalid page number", ex.Message); + } + + [Fact] + public void Strict_PageSizeZero_Throws400() + { + var service = CreateService(new JsonApiOptions { StrictPagination = true }); + var request = CreateRequest(new Dictionary { ["page[size]"] = "0" }); + + var ex = Assert.Throws(() => service.Parse(request)); + Assert.Contains("Invalid page size", ex.Message); + } + + [Fact] + public void Strict_PageSizeNegative_Throws400() + { + var service = CreateService(new JsonApiOptions { StrictPagination = true }); + var request = CreateRequest( + new Dictionary { ["page[size]"] = "-10" } + ); + + var ex = Assert.Throws(() => service.Parse(request)); + Assert.Contains("Invalid page size", ex.Message); + } + + [Fact] + public void Strict_PageSizeExceedsMax_Throws400() + { + var service = CreateService( + new JsonApiOptions { StrictPagination = true, MaxPageSize = 50 } + ); + var request = CreateRequest( + new Dictionary { ["page[size]"] = "100" } + ); + + var ex = Assert.Throws(() => service.Parse(request)); + Assert.Contains("exceeds maximum", ex.Message); + } + + [Fact] + public void Strict_ValidParameters_Works() + { + var service = CreateService(new JsonApiOptions { StrictPagination = true }); + var request = CreateRequest( + new Dictionary { ["page[number]"] = "3", ["page[size]"] = "25" } + ); + + var result = service.Parse(request); + + Assert.NotNull(result.Pagination); + Assert.Equal(3, result.Pagination.Number); + Assert.Equal(25, result.Pagination.Size); + } + + [Fact] + public void Strict_NonParseablePageNumber_DefaultsToOne() + { + var service = CreateService(new JsonApiOptions { StrictPagination = true }); + var request = CreateRequest( + new Dictionary { ["page[number]"] = "abc" } + ); + + // Non-parseable values are silently defaulted, not rejected + var result = service.Parse(request); + + Assert.NotNull(result.Pagination); + Assert.Equal(1, result.Pagination.Number); + } + + [Fact] + public void Strict_PageSizeAtMax_Works() + { + var service = CreateService( + new JsonApiOptions { StrictPagination = true, MaxPageSize = 50 } + ); + var request = CreateRequest(new Dictionary { ["page[size]"] = "50" }); + + var result = service.Parse(request); + + Assert.NotNull(result.Pagination); + Assert.Equal(50, result.Pagination.Size); + } +} diff --git a/JsonApiToolkit.Tests/Services/JsonApiQueryParserServiceTests.cs b/JsonApiToolkit.Tests/Services/JsonApiQueryParserServiceTests.cs index 7e29d99..ed8f589 100644 --- a/JsonApiToolkit.Tests/Services/JsonApiQueryParserServiceTests.cs +++ b/JsonApiToolkit.Tests/Services/JsonApiQueryParserServiceTests.cs @@ -239,9 +239,9 @@ public void Parse_WithMultipleLimitsExceeded_ThrowsOnFirstViolation() } ); - // Filters are checked first + // Include depth is validated during parsing (before post-parse filter count check) var exception = Assert.Throws(() => service.Parse(request)); - Assert.Contains("filters", exception.Message); + Assert.Contains("Include path", exception.Message); } // ───────────────────────────────────────────────────────────────────────── diff --git a/JsonApiToolkit/Configuration/JsonApiOptions.cs b/JsonApiToolkit/Configuration/JsonApiOptions.cs index 99a5ad0..871f4f9 100644 --- a/JsonApiToolkit/Configuration/JsonApiOptions.cs +++ b/JsonApiToolkit/Configuration/JsonApiOptions.cs @@ -47,6 +47,13 @@ public class JsonApiOptions /// public int DefaultPageSize { get; set; } = 10; + /// + /// When true, returns 400 Bad Request for invalid pagination values instead of + /// silently clamping. Invalid page numbers (less than 1) and page sizes (less than 1 or + /// exceeding MaxPageSize) will return errors. Default: false (clamp for backwards compatibility). + /// + public bool StrictPagination { get; set; } + /// /// When true, applies database-level column filtering via EF Core Select() projection /// when fields[type] is specified in the request. Only fetches requested columns from diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 227c8cc..9ef5f27 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -187,95 +187,24 @@ string resourceType where T : class { QueryParameters parameters = GetJsonApiQueryParameters(); - - Logger.LogDebug( - "Query for {EntityType}: Filters={FilterCount}, Sorts={SortCount}, Includes={IncludeCount}, Pagination={HasPagination}, Fields={FieldsCount}", - typeof(T).Name, - parameters.Filter?.Filters?.Count ?? 0, - parameters.Sort?.Count ?? 0, - parameters.Include?.Count ?? 0, - parameters.Pagination != null, - parameters.Fields?.Count ?? 0 - ); - - if (parameters.Filter?.Filters?.Count > 20) - { - Logger.LogInformation( - "Complex query with {Count} filters on {EntityType}", - parameters.Filter.Filters.Count, - typeof(T).Name - ); - } - string baseUrl = GetFullRequestUrl(); var mappedIncludes = EfIncludePathHelper.MapIncludePathsToClrProperties( parameters.Include ); - if (parameters.Include?.Count > 0 && mappedIncludes.Count == 0) - { - Logger.LogWarning( - "No valid includes for {EntityType}. Requested: {Includes}", - typeof(T).Name, - string.Join(", ", parameters.Include) - ); - } + LogQueryParameters(parameters, mappedIncludes); - var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( - parameters.Filter, - parameters.Include + IQueryable filteredQuery = ApplyFiltersAndIncludes( + queryable, + parameters, + mappedIncludes ); - IQueryable filteredQuery = queryable; - - if (mainFilters != null) - filteredQuery = filteredQuery.ApplyFilters(mainFilters, Logger); - - if (includeFilters.Count > 0) - { - Logger.LogDebug( - "Applying {FilterCount} filtered includes for {EntityType}", - includeFilters.Count, - typeof(T).Name - ); - filteredQuery = filteredQuery.ApplyFilteredIncludes( - mappedIncludes, - includeFilters, - Logger - ); - } - else if (mappedIncludes.Count > 0) - { - // Use single query with pagination to avoid EF Core split query issues - filteredQuery = - parameters.Pagination != null - ? filteredQuery.ApplyIncludesSingleQuery(mappedIncludes) - : filteredQuery.ApplyIncludes(mappedIncludes); - - Logger.LogDebug( - "Applied {IncludeCount} includes for {EntityType} using {QueryType}", - mappedIncludes.Count, - typeof(T).Name, - parameters.Pagination != null ? "SingleQuery" : "SplitQuery" - ); - } - if (parameters.Sort?.Count > 0) filteredQuery = filteredQuery.ApplySorting(parameters.Sort, Logger); int totalCount = await filteredQuery.CountAsync().ConfigureAwait(false); - - if (totalCount == 0 && parameters.Filter?.Filters?.Count > 0) - { - Logger.LogInformation("Query returned 0 results for {EntityType}", typeof(T).Name); - } - else if (totalCount > 1000 && parameters.Pagination == null) - { - Logger.LogWarning( - "Large result set ({TotalCount}) without pagination. Consider adding pagination to improve performance", - totalCount - ); - } + LogCountResults(parameters, totalCount); if (parameters.Pagination != null) filteredQuery = filteredQuery.ApplyPagination(parameters.Pagination, totalCount); @@ -292,89 +221,16 @@ string resourceType parameters.Pagination?.Size ?? totalCount ); - if (Options.EnableDatabaseProjection && parameters.Fields != null) - { - if (mappedIncludes.Count > 0) - { - // EF Core silently drops all .Include() hints when a .Select() projection - // is applied to the same query, so navigation properties would be null. - Logger.LogDebug( - "Database projection skipped for {EntityType}: includes are not compatible with Select() projection", - typeof(T).Name - ); - } - else if ( - parameters.Fields.TryGetValue(resourceType, out List? requestedFields) - && requestedFields.Count > 0 - ) - { - try - { - var projectionProperties = ProjectionPropertySelector.Determine( - typeof(T), - requestedFields - ); - - var (projectionType, projectionExpression) = ProjectionTypeCache.GetOrCreate( - typeof(T), - projectionProperties - ); - - IQueryable projectedQuery = DatabaseProjectionApplicator.ApplySelect( - filteredQuery, - projectionType, - projectionExpression - ); - - List projectedResults = await DatabaseProjectionApplicator - .MaterializeAsync( - projectedQuery, - projectionType, - HttpContext.RequestAborted - ) - .ConfigureAwait(false); - - Logger.LogDebug( - "Database projection applied for {EntityType}: {FieldCount} fields projected", - typeof(T).Name, - requestedFields.Count - ); - - JsonApiCollectionDocument projectedDocument = - JsonApiMapper.ToCollectionDocument( - projectedResults, - resourceType, - baseUrl, - paginationMeta, - mappedIncludes, - Logger, - parameters.Fields - ); - - return Ok(projectedDocument); - } - catch (Exception ex) - { - Logger.LogWarning( - ex, - "Database projection failed for {EntityType}, falling back to full entity load", - typeof(T).Name - ); - // Fall through to the standard full-entity path below. - // Note: CountAsync already executed above, so a projection failure - // costs three DB round-trips (count + failed projection + full load). - } - } - else if (parameters.Fields.Count > 0) - { - Logger.LogDebug( - "Database projection skipped for {EntityType}: fields[] present but no key matches resourceType '{ResourceType}'. Keys: {Keys}", - typeof(T).Name, - resourceType, - string.Join(", ", parameters.Fields.Keys) - ); - } - } + IActionResult? projectionResult = await TryApplyDatabaseProjection( + filteredQuery, + resourceType, + baseUrl, + paginationMeta, + mappedIncludes, + parameters + ); + if (projectionResult != null) + return projectionResult; List results = await filteredQuery.ToListAsync().ConfigureAwait(false); @@ -555,4 +411,190 @@ protected IActionResult JsonApiBadRequest(string detail) /// protected string GetFullRequestUrl() => $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}"; + + private void LogQueryParameters(QueryParameters parameters, List mappedIncludes) + { + Logger.LogDebug( + "Query for {EntityType}: Filters={FilterCount}, Sorts={SortCount}, Includes={IncludeCount}, Pagination={HasPagination}, Fields={FieldsCount}", + typeof(T).Name, + parameters.Filter?.Filters?.Count ?? 0, + parameters.Sort?.Count ?? 0, + parameters.Include?.Count ?? 0, + parameters.Pagination != null, + parameters.Fields?.Count ?? 0 + ); + + if (parameters.Filter?.Filters?.Count > 20) + { + Logger.LogInformation( + "Complex query with {Count} filters on {EntityType}", + parameters.Filter.Filters.Count, + typeof(T).Name + ); + } + + if (parameters.Include?.Count > 0 && mappedIncludes.Count == 0) + { + Logger.LogWarning( + "No valid includes for {EntityType}. Requested: {Includes}", + typeof(T).Name, + string.Join(", ", parameters.Include) + ); + } + } + + private IQueryable ApplyFiltersAndIncludes( + IQueryable queryable, + QueryParameters parameters, + List mappedIncludes + ) + where T : class + { + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + parameters.Filter, + parameters.Include + ); + + IQueryable filteredQuery = queryable; + + if (mainFilters != null) + filteredQuery = filteredQuery.ApplyFilters(mainFilters, Logger); + + if (includeFilters.Count > 0) + { + Logger.LogDebug( + "Applying {FilterCount} filtered includes for {EntityType}", + includeFilters.Count, + typeof(T).Name + ); + filteredQuery = filteredQuery.ApplyFilteredIncludes( + mappedIncludes, + includeFilters, + Logger + ); + } + else if (mappedIncludes.Count > 0) + { + filteredQuery = + parameters.Pagination != null + ? filteredQuery.ApplyIncludesSingleQuery(mappedIncludes) + : filteredQuery.ApplyIncludes(mappedIncludes); + + Logger.LogDebug( + "Applied {IncludeCount} includes for {EntityType} using {QueryType}", + mappedIncludes.Count, + typeof(T).Name, + parameters.Pagination != null ? "SingleQuery" : "SplitQuery" + ); + } + + return filteredQuery; + } + + private void LogCountResults(QueryParameters parameters, int totalCount) + { + if (totalCount == 0 && parameters.Filter?.Filters?.Count > 0) + { + Logger.LogInformation("Query returned 0 results for {EntityType}", typeof(T).Name); + } + else if (totalCount > 1000 && parameters.Pagination == null) + { + Logger.LogWarning( + "Large result set ({TotalCount}) without pagination. Consider adding pagination to improve performance", + totalCount + ); + } + } + + private async Task TryApplyDatabaseProjection( + IQueryable filteredQuery, + string resourceType, + string baseUrl, + PaginationMeta? paginationMeta, + List mappedIncludes, + QueryParameters parameters + ) + where T : class + { + if (!Options.EnableDatabaseProjection || parameters.Fields == null) + return null; + + if (mappedIncludes.Count > 0) + { + Logger.LogDebug( + "Database projection skipped for {EntityType}: includes are not compatible with Select() projection", + typeof(T).Name + ); + return null; + } + + if ( + parameters.Fields.TryGetValue(resourceType, out List? requestedFields) + && requestedFields.Count > 0 + ) + { + try + { + var projectionProperties = ProjectionPropertySelector.Determine( + typeof(T), + requestedFields + ); + + var (projectionType, projectionExpression) = ProjectionTypeCache.GetOrCreate( + typeof(T), + projectionProperties + ); + + IQueryable projectedQuery = DatabaseProjectionApplicator.ApplySelect( + filteredQuery, + projectionType, + projectionExpression + ); + + List projectedResults = await DatabaseProjectionApplicator + .MaterializeAsync(projectedQuery, projectionType, HttpContext.RequestAborted) + .ConfigureAwait(false); + + Logger.LogDebug( + "Database projection applied for {EntityType}: {FieldCount} fields projected", + typeof(T).Name, + requestedFields.Count + ); + + JsonApiCollectionDocument projectedDocument = + JsonApiMapper.ToCollectionDocument( + projectedResults, + resourceType, + baseUrl, + paginationMeta, + mappedIncludes, + Logger, + parameters.Fields + ); + + return Ok(projectedDocument); + } + catch (Exception ex) + { + Logger.LogWarning( + ex, + "Database projection failed for {EntityType}, falling back to full entity load", + typeof(T).Name + ); + return null; + } + } + + if (parameters.Fields.Count > 0) + { + Logger.LogDebug( + "Database projection skipped for {EntityType}: fields[] present but no key matches resourceType '{ResourceType}'. Keys: {Keys}", + typeof(T).Name, + resourceType, + string.Join(", ", parameters.Fields.Keys) + ); + } + + return null; + } } diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.CollectionExpressions.cs b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.CollectionExpressions.cs new file mode 100644 index 0000000..220645e --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.CollectionExpressions.cs @@ -0,0 +1,207 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiToolkit.Helpers; +using JsonApiToolkit.Models.Errors; +using JsonApiToolkit.Models.Querying.Filtering; +using Microsoft.Extensions.Logging; + +namespace JsonApiToolkit.Extensions.Querying; + +internal static partial class NestedPropertyNavigator +{ + /// + /// Builds a filter expression for collection navigation using Any(). + /// e.g., collection.Any(item => item.Property == value) + /// + private static Expression? BuildCollectionFilterExpression( + Expression collectionAccess, + Type elementType, + string[] remainingParts, + FilterParameter filter, + ILogger? logger, + int depth + ) + { + if (depth > MaxRecursionDepth) + { + throw new JsonApiBadRequestException( + $"Filter path recursion depth exceeds maximum of {MaxRecursionDepth}. " + + "Simplify the filter expression or reduce collection nesting.", + JsonApiErrorCodes.QueryTooComplex, + new ErrorSource { Parameter = $"filter[{filter.Field}]" }, + new Dictionary + { + ["field"] = filter.Field, + ["maxDepth"] = MaxRecursionDepth, + ["actualDepth"] = depth, + } + ); + } + // Create parameter for the lambda: item => + ParameterExpression itemParam = Expression.Parameter(elementType, "item"); + + // Build the inner filter expression for the remaining path + FilterParameter innerFilter = new FilterParameter + { + Field = string.Join(".", remainingParts), + Value = filter.Value, + Operator = filter.Operator, + IsIncludeFilter = filter.IsIncludeFilter, + }; + + Expression? innerExpression; + if (remainingParts.Length == 1) + { + // Simple property access on the element + PropertyInfo? prop = QueryHelpers.GetPropertyByJsonName(elementType, remainingParts[0]); + if (prop == null) + { + logger?.LogWarning( + "Property '{PropertyName}' not found on {Type}", + remainingParts[0], + elementType.Name + ); + return null; + } + + Expression propertyAccess = Expression.Property(itemParam, prop); + innerExpression = BuildPropertyFilterExpression(propertyAccess, filter, logger); + } + else + { + // Nested property access - recursively build + innerExpression = BuildSafeNestedFilterExpression( + itemParam, + innerFilter, + logger, + depth + ); + } + + if (innerExpression == null) + return null; + + // Create lambda: item => innerExpression + LambdaExpression predicate = Expression.Lambda(innerExpression, itemParam); + + // Get the Enumerable.Any(IEnumerable, Func) method + MethodInfo anyMethod = ReflectionMethodCache.GetEnumerableAnyWithPredicate(elementType); + + // Build: collection.Any(item => predicate) + return Expression.Call(anyMethod, collectionAccess, predicate); + } + + /// + /// Builds a filter expression when the property itself is a collection. + /// e.g., entity.Tags.Contains("value") for filter[tags][in]=value + /// + private static Expression? BuildCollectionPropertyFilterExpression( + Expression collectionAccess, + Type elementType, + FilterParameter filter, + ILogger? logger + ) + { + // For In/Eq operators: check if collection contains the value + // e.g., tags.Contains("important") + if ( + filter.Operator == FilterOperator.In + || filter.Operator == FilterOperator.Eq + || filter.Operator == FilterOperator.Like + ) + { + // For Like operator on collection, use Any() with Contains + if (filter.Operator == FilterOperator.Like) + { + // collection.Any(item => item.Contains(value)) + ParameterExpression itemParam = Expression.Parameter(elementType, "item"); + + // Only strip % if value has both leading AND trailing % + string cleanValue = + filter.Value.StartsWith('%') + && filter.Value.EndsWith('%') + && filter.Value.Length > 2 + ? filter.Value[1..^1] + : filter.Value; + + MethodInfo? containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]); + Expression containsCall = Expression.Call( + itemParam, + containsMethod!, + Expression.Constant(cleanValue) + ); + + LambdaExpression predicate = Expression.Lambda(containsCall, itemParam); + + MethodInfo anyMethod = ReflectionMethodCache.GetEnumerableAnyWithPredicate( + elementType + ); + + return Expression.Call(anyMethod, collectionAccess, predicate); + } + + // For In/Eq: collection.Contains(value) + object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, elementType); + if (filterValue == null) + { + logger?.LogWarning( + "Failed to convert '{Value}' to {ElementType} for collection filter", + SanitizeForLog(filter.Value), + elementType.Name + ); + return null; + } + + // Get Contains method on IEnumerable (via Enumerable.Contains) + MethodInfo containsMethodInfo = ReflectionMethodCache.GetEnumerableContains( + elementType + ); + + return Expression.Call( + containsMethodInfo, + collectionAccess, + Expression.Constant(filterValue, elementType) + ); + } + + // For Nin/Ne operators: check if collection does NOT contain the value + if (filter.Operator == FilterOperator.Nin || filter.Operator == FilterOperator.Ne) + { + object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, elementType); + if (filterValue == null) + { + logger?.LogWarning( + "Failed to convert '{Value}' to {ElementType} for collection filter", + SanitizeForLog(filter.Value), + elementType.Name + ); + return null; + } + + MethodInfo containsMethodInfo = ReflectionMethodCache.GetEnumerableContains( + elementType + ); + + return Expression.Not( + Expression.Call( + containsMethodInfo, + collectionAccess, + Expression.Constant(filterValue, elementType) + ) + ); + } + + // For IsNull/IsNotNull: check if collection is null + if (filter.Operator == FilterOperator.IsNull) + return Expression.Equal(collectionAccess, Expression.Constant(null)); + + if (filter.Operator == FilterOperator.IsNotNull) + return Expression.NotEqual(collectionAccess, Expression.Constant(null)); + + logger?.LogWarning( + "Operator '{Operator}' is not supported for collection properties", + filter.Operator + ); + return null; + } +} diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs index ec4c159..eabfb03 100644 --- a/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs @@ -83,7 +83,7 @@ private static string SanitizeForLog(string? value) current = Expression.Property(current, prop); // Check if this property is a collection (but not a string) - Type? elementType = GetCollectionElementType(prop.PropertyType); + Type? elementType = TypeHelpers.GetCollectionElementType(prop.PropertyType); if (elementType != null) { // Build collection filter using Any() for remaining path @@ -168,224 +168,6 @@ private static string SanitizeForLog(string? value) return result2; } - /// - /// Gets the element type if the type is a collection (but not string). - /// Returns null if not a collection. - /// - private static Type? GetCollectionElementType(Type type) - { - if (type == typeof(string)) - return null; - - // Check for IEnumerable - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - return type.GetGenericArguments()[0]; - - // Check interfaces for IEnumerable - Type? enumerableInterface = type.GetInterfaces() - .FirstOrDefault(i => - i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) - ); - - return enumerableInterface?.GetGenericArguments()[0]; - } - - /// - /// Builds a filter expression for collection navigation using Any(). - /// e.g., collection.Any(item => item.Property == value) - /// - private static Expression? BuildCollectionFilterExpression( - Expression collectionAccess, - Type elementType, - string[] remainingParts, - FilterParameter filter, - ILogger? logger, - int depth - ) - { - if (depth > MaxRecursionDepth) - { - throw new JsonApiBadRequestException( - $"Filter path recursion depth exceeds maximum of {MaxRecursionDepth}. " - + "Simplify the filter expression or reduce collection nesting.", - JsonApiErrorCodes.QueryTooComplex, - new ErrorSource { Parameter = $"filter[{filter.Field}]" }, - new Dictionary - { - ["field"] = filter.Field, - ["maxDepth"] = MaxRecursionDepth, - ["actualDepth"] = depth, - } - ); - } - // Create parameter for the lambda: item => - ParameterExpression itemParam = Expression.Parameter(elementType, "item"); - - // Build the inner filter expression for the remaining path - FilterParameter innerFilter = new FilterParameter - { - Field = string.Join(".", remainingParts), - Value = filter.Value, - Operator = filter.Operator, - IsIncludeFilter = filter.IsIncludeFilter, - }; - - Expression? innerExpression; - if (remainingParts.Length == 1) - { - // Simple property access on the element - PropertyInfo? prop = QueryHelpers.GetPropertyByJsonName(elementType, remainingParts[0]); - if (prop == null) - { - logger?.LogWarning( - "Property '{PropertyName}' not found on {Type}", - remainingParts[0], - elementType.Name - ); - return null; - } - - Expression propertyAccess = Expression.Property(itemParam, prop); - innerExpression = BuildPropertyFilterExpression(propertyAccess, filter, logger); - } - else - { - // Nested property access - recursively build - innerExpression = BuildSafeNestedFilterExpression( - itemParam, - innerFilter, - logger, - depth - ); - } - - if (innerExpression == null) - return null; - - // Create lambda: item => innerExpression - LambdaExpression predicate = Expression.Lambda(innerExpression, itemParam); - - // Get the Enumerable.Any(IEnumerable, Func) method - MethodInfo anyMethod = ReflectionMethodCache.GetEnumerableAnyWithPredicate(elementType); - - // Build: collection.Any(item => predicate) - return Expression.Call(anyMethod, collectionAccess, predicate); - } - - /// - /// Builds a filter expression when the property itself is a collection. - /// e.g., entity.Tags.Contains("value") for filter[tags][in]=value - /// - private static Expression? BuildCollectionPropertyFilterExpression( - Expression collectionAccess, - Type elementType, - FilterParameter filter, - ILogger? logger - ) - { - // For In/Eq operators: check if collection contains the value - // e.g., tags.Contains("important") - if ( - filter.Operator == FilterOperator.In - || filter.Operator == FilterOperator.Eq - || filter.Operator == FilterOperator.Like - ) - { - // For Like operator on collection, use Any() with Contains - if (filter.Operator == FilterOperator.Like) - { - // collection.Any(item => item.Contains(value)) - ParameterExpression itemParam = Expression.Parameter(elementType, "item"); - - // Only strip % if value has both leading AND trailing % - string cleanValue = - filter.Value.StartsWith('%') - && filter.Value.EndsWith('%') - && filter.Value.Length > 2 - ? filter.Value[1..^1] - : filter.Value; - - MethodInfo? containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]); - Expression containsCall = Expression.Call( - itemParam, - containsMethod!, - Expression.Constant(cleanValue) - ); - - LambdaExpression predicate = Expression.Lambda(containsCall, itemParam); - - MethodInfo anyMethod = ReflectionMethodCache.GetEnumerableAnyWithPredicate( - elementType - ); - - return Expression.Call(anyMethod, collectionAccess, predicate); - } - - // For In/Eq: collection.Contains(value) - object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, elementType); - if (filterValue == null) - { - logger?.LogWarning( - "Failed to convert '{Value}' to {ElementType} for collection filter", - SanitizeForLog(filter.Value), - elementType.Name - ); - return null; - } - - // Get Contains method on IEnumerable (via Enumerable.Contains) - MethodInfo containsMethodInfo = ReflectionMethodCache.GetEnumerableContains( - elementType - ); - - return Expression.Call( - containsMethodInfo, - collectionAccess, - Expression.Constant(filterValue, elementType) - ); - } - - // For Nin/Ne operators: check if collection does NOT contain the value - if (filter.Operator == FilterOperator.Nin || filter.Operator == FilterOperator.Ne) - { - object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, elementType); - if (filterValue == null) - { - logger?.LogWarning( - "Failed to convert '{Value}' to {ElementType} for collection filter", - SanitizeForLog(filter.Value), - elementType.Name - ); - return null; - } - - MethodInfo containsMethodInfo = ReflectionMethodCache.GetEnumerableContains( - elementType - ); - - return Expression.Not( - Expression.Call( - containsMethodInfo, - collectionAccess, - Expression.Constant(filterValue, elementType) - ) - ); - } - - // For IsNull/IsNotNull: check if collection is null - if (filter.Operator == FilterOperator.IsNull) - return Expression.Equal(collectionAccess, Expression.Constant(null)); - - if (filter.Operator == FilterOperator.IsNotNull) - return Expression.NotEqual(collectionAccess, Expression.Constant(null)); - - logger?.LogWarning( - "Operator '{Operator}' is not supported for collection properties", - filter.Operator - ); - return null; - } - internal static Expression? BuildPropertyFilterExpression( Expression propertyAccess, FilterParameter filter, @@ -395,7 +177,7 @@ int depth Type targetType = propertyAccess.Type; // Check if the property itself is a collection (e.g., List for CVEs/Tags) - Type? collectionElementType = GetCollectionElementType(targetType); + Type? collectionElementType = TypeHelpers.GetCollectionElementType(targetType); if (collectionElementType != null) { return BuildCollectionPropertyFilterExpression( diff --git a/JsonApiToolkit/Extensions/Querying/Helpers/TypeHelpers.cs b/JsonApiToolkit/Extensions/Querying/Helpers/TypeHelpers.cs index a76cfb7..cb04732 100644 --- a/JsonApiToolkit/Extensions/Querying/Helpers/TypeHelpers.cs +++ b/JsonApiToolkit/Extensions/Querying/Helpers/TypeHelpers.cs @@ -22,11 +22,17 @@ internal static bool IsCollectionType(Type type) internal static Type? GetCollectionElementType(Type collectionType) { + if (collectionType == typeof(string)) + return null; + if (collectionType.IsArray) return collectionType.GetElementType(); - if (collectionType.IsGenericType) - return collectionType.GetGenericArguments().FirstOrDefault(); + if ( + collectionType.IsGenericType + && collectionType.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ) + return collectionType.GetGenericArguments()[0]; var enumerableInterface = collectionType .GetInterfaces() diff --git a/JsonApiToolkit/Mapping/EntityMapper.cs b/JsonApiToolkit/Mapping/EntityMapper.cs index 7141623..abda718 100644 --- a/JsonApiToolkit/Mapping/EntityMapper.cs +++ b/JsonApiToolkit/Mapping/EntityMapper.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Text.Json.Serialization; using JsonApiToolkit.Extensions; +using JsonApiToolkit.Extensions.Querying; namespace JsonApiToolkit.Mapping; @@ -92,7 +93,7 @@ public static List GetRelationshipProperties(Type type) private static bool IsCollectionRelationship(PropertyInfo p) => typeof(IEnumerable).IsAssignableFrom(p.PropertyType) && p.PropertyType != typeof(string) - && HasIdProperty(GetCollectionElementType(p.PropertyType)); + && HasIdProperty(TypeHelpers.GetCollectionElementType(p.PropertyType)); private static bool IsSingleObjectRelationship(PropertyInfo p) => !p.PropertyType.IsPrimitive @@ -143,43 +144,4 @@ private static bool HasJsonIgnoreAttribute(PropertyInfo property) { return property.GetCustomAttribute() != null; } - - /// - /// Gets the element type of a collection. - /// - /// The collection type - /// The element type, or null if not a collection - private static Type? GetCollectionElementType(Type collectionType) - { - // String is not considered a collection for our purposes - if (collectionType == typeof(string)) - { - return null; - } - - // Check if it's a generic collection - if (collectionType.IsGenericType) - { - Type[] genericArgs = collectionType.GetGenericArguments(); - if (genericArgs.Length == 1) - { - return genericArgs[0]; - } - } - - // Check if it implements IEnumerable - Type? enumerable = collectionType - .GetInterfaces() - .FirstOrDefault(i => - i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) - ); - - if (enumerable != null) - { - return enumerable.GetGenericArguments()[0]; - } - - // For non-generic collections, we can't determine the element type - return null; - } } diff --git a/JsonApiToolkit/Mapping/InclusionMapper.cs b/JsonApiToolkit/Mapping/InclusionMapper.cs index 9923e03..470ce33 100644 --- a/JsonApiToolkit/Mapping/InclusionMapper.cs +++ b/JsonApiToolkit/Mapping/InclusionMapper.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Reflection; using JsonApiToolkit.Extensions; +using JsonApiToolkit.Extensions.Querying; using JsonApiToolkit.Models.Resources; using Microsoft.Extensions.Logging; @@ -84,10 +85,7 @@ private static void AddIncludedForEntity( Type type = entity.GetType(); - PropertyInfo? relProp = type.GetProperties() - .FirstOrDefault(p => - string.Equals(p.Name, relationshipName, StringComparison.OrdinalIgnoreCase) - ); + PropertyInfo? relProp = QueryHelpers.GetPropertyByJsonName(type, relationshipName); if (relProp == null) { logger?.LogWarning( diff --git a/JsonApiToolkit/Parsing/JsonApiQueryParser.cs b/JsonApiToolkit/Parsing/JsonApiQueryParser.cs index 7e3ed5f..d169b41 100644 --- a/JsonApiToolkit/Parsing/JsonApiQueryParser.cs +++ b/JsonApiToolkit/Parsing/JsonApiQueryParser.cs @@ -1,4 +1,5 @@ using JsonApiToolkit.Configuration; +using JsonApiToolkit.Models.Errors; using JsonApiToolkit.Models.Querying; using JsonApiToolkit.Models.Querying.Filtering; using Microsoft.AspNetCore.Http; @@ -48,6 +49,48 @@ public static QueryParameters Parse( if (hasPageNumber || hasPageSize) { + if (options.StrictPagination) + { + if (hasPageNumber && int.TryParse(pageNumber, out int rawNum) && rawNum < 1) + { + throw new JsonApiBadRequestException( + $"Invalid page number '{rawNum}'. Page numbers must be 1 or greater.", + JsonApiErrorCodes.InvalidPageNumber, + new ErrorSource { Parameter = "page[number]" }, + new Dictionary { ["value"] = rawNum } + ); + } + + if (hasPageSize && int.TryParse(pageSize, out int rawSize)) + { + if (rawSize < 1) + { + throw new JsonApiBadRequestException( + $"Invalid page size '{rawSize}'. Page size must be 1 or greater.", + JsonApiErrorCodes.InvalidPageSize, + new ErrorSource { Parameter = "page[size]" }, + new Dictionary { ["value"] = rawSize } + ); + } + + if (rawSize > options.MaxPageSize) + { + throw new JsonApiBadRequestException( + $"Page size '{rawSize}' exceeds maximum allowed size of {options.MaxPageSize}. " + + "Reduce page size or configure a higher limit via JsonApiOptions.MaxPageSize.", + JsonApiErrorCodes.PageSizeExceeded, + new ErrorSource { Parameter = "page[size]" }, + new Dictionary + { + ["value"] = rawSize, + ["max"] = options.MaxPageSize, + ["configKey"] = "JsonApiOptions.MaxPageSize", + } + ); + } + } + } + queryParams.Pagination = new PaginationParameters { Number = @@ -145,6 +188,27 @@ string field in sortValue .Select(i => i.Trim()) .ToList(); + foreach (var include in includes) + { + int depth = include.Count(c => c == '.') + 1; + if (depth > options.MaxIncludeDepth) + { + throw new JsonApiBadRequestException( + $"Include path '{include}' has depth {depth}, but maximum allowed is {options.MaxIncludeDepth}. " + + "Reduce nesting or configure a higher limit via JsonApiOptions.MaxIncludeDepth.", + JsonApiErrorCodes.IncludeDepthExceeded, + new ErrorSource { Parameter = "include" }, + new Dictionary + { + ["includePath"] = include, + ["depth"] = depth, + ["limit"] = options.MaxIncludeDepth, + ["configKey"] = "JsonApiOptions.MaxIncludeDepth", + } + ); + } + } + if (includes.Count > 0) { queryParams.Include = includes;