Skip to content

Commit b9d208b

Browse files
refactor(filters): split NestedPropertyNavigator into focused classes
1 parent 5704794 commit b9d208b

7 files changed

Lines changed: 330 additions & 303 deletions

File tree

JsonApiToolkit.Tests/Extensions/RecursionDepthGuardTests.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ public void BuildFilterExpression_WithShallowNesting_Succeeds()
6464
var parameter = Expression.Parameter(typeof(Level0), "x");
6565

6666
// This should not throw
67-
var expression = NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);
67+
var expression = PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);
6868

6969
Assert.NotNull(expression);
7070
}
@@ -83,7 +83,7 @@ public void BuildFilterExpression_WithDeeplyNestedCollections_ThrowsBadRequest()
8383
var parameter = Expression.Parameter(typeof(Level0), "x");
8484

8585
var exception = Assert.Throws<JsonApiBadRequestException>(() =>
86-
NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
86+
PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
8787
);
8888

8989
Assert.Contains("recursion depth", exception.Message.ToLower());
@@ -105,7 +105,7 @@ public void BuildFilterExpression_AtExactLimit_Succeeds()
105105
var parameter = Expression.Parameter(typeof(Level0), "x");
106106

107107
// This should not throw - exactly at limit
108-
var expression = NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);
108+
var expression = PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);
109109

110110
Assert.NotNull(expression);
111111
}
@@ -124,7 +124,7 @@ public void BuildFilterExpression_JustOverLimit_ThrowsBadRequest()
124124
var parameter = Expression.Parameter(typeof(Level0), "x");
125125

126126
var exception = Assert.Throws<JsonApiBadRequestException>(() =>
127-
NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
127+
PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
128128
);
129129

130130
Assert.Contains("recursion depth", exception.Message.ToLower());
@@ -143,7 +143,7 @@ public void BuildFilterExpression_ErrorMetadata_ContainsFieldInfo()
143143
var parameter = Expression.Parameter(typeof(Level0), "x");
144144

145145
var exception = Assert.Throws<JsonApiBadRequestException>(() =>
146-
NestedPropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
146+
PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
147147
);
148148

149149
Assert.NotNull(exception.ErrorSource);

JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.CollectionExpressions.cs renamed to JsonApiToolkit/Extensions/Querying/Filtering/CollectionFilterBuilder.cs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@
77

88
namespace JsonApiToolkit.Extensions.Querying;
99

10-
internal static partial class NestedPropertyNavigator
10+
/// <summary>
11+
/// Builds LINQ expressions for collection navigations: <c>Any(item =&gt; predicate)</c>
12+
/// for nested paths (e.g. <c>tags.name</c>) and <c>Contains(value)</c> for primitive
13+
/// collections used as filter targets (e.g. <c>filter[tags][in]=value</c>).
14+
/// </summary>
15+
internal static class CollectionFilterBuilder
1116
{
1217
/// <summary>
1318
/// Builds a filter expression for collection navigation using Any().
14-
/// e.g., collection.Any(item => item.Property == value)
19+
/// e.g., collection.Any(item =&gt; item.Property == value)
1520
/// </summary>
16-
private static Expression? BuildCollectionFilterExpression(
21+
internal static Expression? BuildCollectionFilterExpression(
1722
Expression collectionAccess,
1823
Type elementType,
1924
string[] remainingParts,
@@ -22,17 +27,17 @@ internal static partial class NestedPropertyNavigator
2227
int depth
2328
)
2429
{
25-
if (depth > MaxRecursionDepth)
30+
if (depth > PropertyNavigator.MaxRecursionDepth)
2631
{
2732
throw new JsonApiBadRequestException(
28-
$"Filter path recursion depth exceeds maximum of {MaxRecursionDepth}. "
33+
$"Filter path recursion depth exceeds maximum of {PropertyNavigator.MaxRecursionDepth}. "
2934
+ "Simplify the filter expression or reduce collection nesting.",
3035
JsonApiErrorCodes.QueryTooComplex,
3136
new ErrorSource { Parameter = $"filter[{filter.Field}]" },
3237
new Dictionary<string, object>
3338
{
3439
["field"] = filter.Field,
35-
["maxDepth"] = MaxRecursionDepth,
40+
["maxDepth"] = PropertyNavigator.MaxRecursionDepth,
3641
["actualDepth"] = depth,
3742
}
3843
);
@@ -65,12 +70,16 @@ int depth
6570
}
6671

6772
Expression propertyAccess = Expression.Property(itemParam, prop);
68-
innerExpression = BuildPropertyFilterExpression(propertyAccess, filter, logger);
73+
innerExpression = PropertyFilterBuilder.BuildPropertyFilterExpression(
74+
propertyAccess,
75+
filter,
76+
logger
77+
);
6978
}
7079
else
7180
{
7281
// Nested property access - recursively build
73-
innerExpression = BuildSafeNestedFilterExpression(
82+
innerExpression = PropertyNavigator.BuildSafeNestedFilterExpression(
7483
itemParam,
7584
innerFilter,
7685
logger,
@@ -95,7 +104,7 @@ int depth
95104
/// Builds a filter expression when the property itself is a collection.
96105
/// e.g., entity.Tags.Contains("value") for filter[tags][in]=value
97106
/// </summary>
98-
private static Expression? BuildCollectionPropertyFilterExpression(
107+
internal static Expression? BuildCollectionPropertyFilterExpression(
99108
Expression collectionAccess,
100109
Type elementType,
101110
FilterParameter filter,
@@ -146,7 +155,7 @@ int depth
146155
{
147156
logger?.LogWarning(
148157
"Failed to convert '{Value}' to {ElementType} for collection filter",
149-
SanitizeForLog(filter.Value),
158+
FilterLogSanitizer.SanitizeForLog(filter.Value),
150159
elementType.Name
151160
);
152161
return null;
@@ -172,7 +181,7 @@ int depth
172181
{
173182
logger?.LogWarning(
174183
"Failed to convert '{Value}' to {ElementType} for collection filter",
175-
SanitizeForLog(filter.Value),
184+
FilterLogSanitizer.SanitizeForLog(filter.Value),
176185
elementType.Name
177186
);
178187
return null;

JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionBuilder.cs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,7 @@ public static class FilterExpressionBuilder
123123
)
124124
{
125125
if (filter.Field.Contains('.'))
126-
return NestedPropertyNavigator.BuildSafeNestedFilterExpression(
127-
parameter,
128-
filter,
129-
logger
130-
);
126+
return PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter, logger);
131127

132128
PropertyInfo? property = QueryHelpers.GetPropertyByJsonName(parameter.Type, filter.Field);
133129
if (property == null)
@@ -141,10 +137,6 @@ public static class FilterExpressionBuilder
141137
}
142138

143139
Expression propertyAccess = Expression.Property(parameter, property);
144-
return NestedPropertyNavigator.BuildPropertyFilterExpression(
145-
propertyAccess,
146-
filter,
147-
logger
148-
);
140+
return PropertyFilterBuilder.BuildPropertyFilterExpression(propertyAccess, filter, logger);
149141
}
150142
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System.Text.RegularExpressions;
2+
3+
namespace JsonApiToolkit.Extensions.Querying;
4+
5+
internal static partial class FilterLogSanitizer
6+
{
7+
private const int MaxLogValueLength = 100;
8+
9+
/// <summary>
10+
/// Sanitizes user input for safe logging by removing control characters
11+
/// and truncating long values to prevent log forging attacks.
12+
/// </summary>
13+
internal static string SanitizeForLog(string? value)
14+
{
15+
if (string.IsNullOrEmpty(value))
16+
return "(empty)";
17+
18+
// Remove control characters (newlines, tabs, etc.) that could forge log entries
19+
string sanitized = ControlCharRegex().Replace(value, " ");
20+
21+
// Truncate long values
22+
if (sanitized.Length > MaxLogValueLength)
23+
return string.Concat(sanitized.AsSpan(0, MaxLogValueLength), "...(truncated)");
24+
25+
return sanitized;
26+
}
27+
28+
[GeneratedRegex(@"[\x00-\x1F\x7F]")]
29+
private static partial Regex ControlCharRegex();
30+
}

0 commit comments

Comments
 (0)