diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs index 4218c2e3dc..b057f0b01e 100644 --- a/benchmarks/QueryString/QueryStringParserBenchmarks.cs +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -38,7 +38,7 @@ public QueryStringParserBenchmarks() var resourceFactory = new ResourceFactory(new ServiceContainer()); var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); - var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options); + var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options, Enumerable.Empty()); var sortReader = new SortQueryStringParameterReader(request, resourceGraph); var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(request, resourceGraph); var paginationReader = new PaginationQueryStringParameterReader(request, resourceGraph, options); diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs index 541b50a220..a46e2d567e 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Resources.Internal; @@ -13,15 +14,16 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; public class FilterParser : QueryExpressionParser { private readonly IResourceFactory _resourceFactory; - private readonly Action? _validateSingleFieldCallback; + private readonly IEnumerable _filterValueConverters; private ResourceType? _resourceTypeInScope; - public FilterParser(IResourceFactory resourceFactory, Action? validateSingleFieldCallback = null) + public FilterParser(IResourceFactory resourceFactory, IEnumerable filterValueConverters) { ArgumentGuard.NotNull(resourceFactory); + ArgumentGuard.NotNull(filterValueConverters); _resourceFactory = resourceFactory; - _validateSingleFieldCallback = validateSingleFieldCallback; + _filterValueConverters = filterValueConverters; } public FilterExpression Parse(string source, ResourceType resourceTypeInScope) @@ -135,40 +137,34 @@ protected ComparisonExpression ParseComparison(string operatorName) EatText(operatorName); EatSingleCharacterToken(TokenKind.OpenParen); - // Allow equality comparison of a HasOne relationship with null. + // Allow equality comparison of a to-one relationship with null. FieldChainRequirements leftChainRequirements = comparisonOperator == ComparisonOperator.Equals ? FieldChainRequirements.EndsInAttribute | FieldChainRequirements.EndsInToOne : FieldChainRequirements.EndsInAttribute; QueryExpression leftTerm = ParseCountOrField(leftChainRequirements); - Converter rightConstantValueConverter; + + EatSingleCharacterToken(TokenKind.Comma); + + QueryExpression rightTerm; if (leftTerm is CountExpression) { - rightConstantValueConverter = GetConstantValueConverterForCount(); + Converter rightConstantValueConverter = GetConstantValueConverterForCount(); + rightTerm = ParseCountOrConstantOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter); } else if (leftTerm is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute) { - rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute); + Converter rightConstantValueConverter = GetConstantValueConverterForAttribute(attribute, typeof(ComparisonExpression)); + rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter); } else { - // This temporary value never survives; it gets discarded during the second pass below. - rightConstantValueConverter = _ => 0; + rightTerm = ParseNull(); } - EatSingleCharacterToken(TokenKind.Comma); - - QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute, rightConstantValueConverter); - EatSingleCharacterToken(TokenKind.CloseParen); - if (leftTerm is ResourceFieldChainExpression leftChain && leftChain.Fields[^1] is RelationshipAttribute && rightTerm is not NullConstantExpression) - { - // Run another pass over left chain to produce an error. - OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); - } - return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); } @@ -178,16 +174,11 @@ protected MatchTextExpression ParseTextMatch(string matchFunctionName) EatSingleCharacterToken(TokenKind.OpenParen); ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - Type targetAttributeType = ((AttrAttribute)targetAttributeChain.Fields[^1]).Property.PropertyType; - - if (targetAttributeType != typeof(string)) - { - throw new QueryParseException("Attribute of type 'String' expected."); - } + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; EatSingleCharacterToken(TokenKind.Comma); - Converter constantValueConverter = stringValue => stringValue; + Converter constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute, typeof(MatchTextExpression)); LiteralConstantExpression constant = ParseConstant(constantValueConverter); EatSingleCharacterToken(TokenKind.CloseParen); @@ -201,13 +192,14 @@ protected AnyExpression ParseAny() EatText(Keywords.Any); EatSingleCharacterToken(TokenKind.OpenParen); - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - Converter constantValueConverter = GetConstantValueConverterForAttribute((AttrAttribute)targetAttribute.Fields[^1]); + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; EatSingleCharacterToken(TokenKind.Comma); ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); + Converter constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute, typeof(AnyExpression)); LiteralConstantExpression constant = ParseConstant(constantValueConverter); constantsBuilder.Add(constant); @@ -223,7 +215,7 @@ protected AnyExpression ParseAny() IImmutableSet constantSet = constantsBuilder.ToImmutable(); - return new AnyExpression(targetAttribute, constantSet); + return new AnyExpression(targetAttributeChain, constantSet); } protected HasExpression ParseHas() @@ -349,6 +341,25 @@ protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirem return ParseFieldChain(chainRequirements, "Count function or field name expected."); } + protected QueryExpression ParseCountOrConstantOrField(FieldChainRequirements chainRequirements, Converter constantValueConverter) + { + CountExpression? count = TryParseCount(); + + if (count != null) + { + return count; + } + + LiteralConstantExpression? constant = TryParseConstant(constantValueConverter); + + if (constant != null) + { + return constant; + } + + return ParseFieldChain(chainRequirements, "Count function, value between quotes or field name expected."); + } + protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements, Converter constantValueConverter) { CountExpression? count = TryParseCount(); @@ -368,6 +379,19 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); } + protected LiteralConstantExpression? TryParseConstant(Converter constantValueConverter) + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.QuotedText) + { + TokenStack.Pop(); + + object constantValue = constantValueConverter(nextToken.Value!); + return new LiteralConstantExpression(constantValue, nextToken.Value!); + } + + return null; + } + protected IdentifierExpression? TryParseConstantOrNull(Converter constantValueConverter) { if (TokenStack.TryPeek(out Token? nextToken)) @@ -392,13 +416,24 @@ protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequiremen protected LiteralConstantExpression ParseConstant(Converter constantValueConverter) { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) + LiteralConstantExpression? constant = TryParseConstant(constantValueConverter); + + if (constant == null) + { + throw new QueryParseException("Value between quotes expected."); + } + + return constant; + } + + protected NullConstantExpression ParseNull() + { + if (TokenStack.TryPop(out Token? token) && token is { Kind: TokenKind.Text, Value: Keywords.Null }) { - object constantValue = constantValueConverter(token.Value!); - return new LiteralConstantExpression(constantValue, token.Value!); + return NullConstantExpression.Instance; } - throw new QueryParseException("Value between quotes expected."); + throw new QueryParseException("null expected."); } private Converter GetConstantValueConverterForCount() @@ -406,23 +441,68 @@ private Converter GetConstantValueConverterForCount() return stringValue => ConvertStringToType(stringValue, typeof(int)); } - private object ConvertStringToType(string value, Type type) + private static object ConvertStringToType(string value, Type type) { try { return RuntimeTypeConverter.ConvertType(value, type)!; } - catch (FormatException) + catch (FormatException exception) { - throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'."); + throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{type.Name}'.", exception); } } - private Converter GetConstantValueConverterForAttribute(AttrAttribute attribute) + private Converter GetConstantValueConverterForAttribute(AttrAttribute attribute, Type outerExpressionType) { - return stringValue => attribute.Property.Name == nameof(Identifiable.Id) - ? DeObfuscateStringId(attribute.Type.ClrType, stringValue) - : ConvertStringToType(stringValue, attribute.Property.PropertyType); + return stringValue => + { + object? value = TryConvertFromStringUsingFilterValueConverters(attribute, stringValue, outerExpressionType); + + if (value != null) + { + return value; + } + + if (outerExpressionType == typeof(MatchTextExpression)) + { + if (attribute.Property.PropertyType != typeof(string)) + { + throw new QueryParseException("Attribute of type 'String' expected."); + } + } + else + { + // Partial text matching on an obfuscated ID usually fails. + if (attribute.Property.Name == nameof(Identifiable.Id)) + { + return DeObfuscateStringId(attribute.Type.ClrType, stringValue); + } + } + + return ConvertStringToType(stringValue, attribute.Property.PropertyType); + }; + } + + private object? TryConvertFromStringUsingFilterValueConverters(AttrAttribute attribute, string stringValue, Type outerExpressionType) + { + foreach (IFilterValueConverter converter in _filterValueConverters) + { + if (converter.CanConvert(attribute)) + { + object result = converter.Convert(attribute, stringValue, outerExpressionType); + + if (result == null) + { + throw new InvalidOperationException( + $"Converter '{converter.GetType().Name}' returned null for '{stringValue}' on attribute '{attribute.PublicName}'. Return a sentinel value instead."); + } + + return result; + } + } + + return null; } private object DeObfuscateStringId(Type resourceClrType, string stringId) @@ -436,29 +516,37 @@ protected override IImmutableList OnResolveFieldChain(st { if (chainRequirements == FieldChainRequirements.EndsInToMany) { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, - _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, ValidateSingleField); } if (chainRequirements == FieldChainRequirements.EndsInAttribute) { return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, - _validateSingleFieldCallback); + ValidateSingleField); } if (chainRequirements == FieldChainRequirements.EndsInToOne) { - return ChainResolver.ResolveToOneChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChain(_resourceTypeInScope!, path, ValidateSingleField); } if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceTypeInScope!, path, ValidateSingleField); } throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); } + protected override void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + { + if (field.IsFilterBlocked()) + { + string kind = field is AttrAttribute ? "attribute" : "relationship"; + throw new QueryParseException($"Filtering on {kind} '{field.PublicName}' is not allowed."); + } + } + private TResult InScopeOfResourceType(ResourceType resourceType, Func action) { ResourceType? backupType = _resourceTypeInScope; diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs index 1250e36312..2b13370b5b 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs @@ -154,7 +154,7 @@ private static void AssertAtLeastOneCanBeIncluded(ISet re ? $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed." : $"Including the relationship '{relationshipName}' in '{parentPath}.{relationshipName}' on '{resourceType}' is not allowed."; - throw new InvalidQueryStringParameterException("include", "Including the requested relationship is not allowed.", message); + throw new InvalidQueryStringParameterException("include", "The specified include is invalid.", message); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs index 50b542de6e..9148dc1463 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs @@ -9,14 +9,8 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; [PublicAPI] public class PaginationParser : QueryExpressionParser { - private readonly Action? _validateSingleFieldCallback; private ResourceType? _resourceTypeInScope; - public PaginationParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceTypeInScope) { ArgumentGuard.NotNull(resourceTypeInScope); @@ -104,6 +98,6 @@ protected PaginationElementQueryStringValueExpression ParsePaginationElement() protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) { - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs index 27466e3b0a..bdb1d6c478 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; using System.Text; using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -24,6 +25,10 @@ public abstract class QueryExpressionParser /// protected abstract IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements); + protected virtual void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + { + } + protected virtual void Tokenize(string source) { var tokenizer = new QueryTokenizer(source); @@ -49,7 +54,7 @@ private void EatFieldChain(StringBuilder pathBuilder, string? alternativeErrorMe { while (true) { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && token.Value != Keywords.Null) { pathBuilder.Append(token.Value); diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs index 2265ca56da..87cd15ab49 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs @@ -9,4 +9,9 @@ public QueryParseException(string message) : base(message) { } + + public QueryParseException(string message, Exception innerException) + : base(message, innerException) + { + } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs index ef95b3ed92..161eeeaa3d 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs @@ -10,14 +10,11 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; public class QueryStringParameterScopeParser : QueryExpressionParser { private readonly FieldChainRequirements _chainRequirements; - private readonly Action? _validateSingleFieldCallback; private ResourceType? _resourceTypeInScope; - public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, - Action? validateSingleFieldCallback = null) + public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements) { _chainRequirements = chainRequirements; - _validateSingleFieldCallback = validateSingleFieldCallback; } public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceTypeInScope) @@ -63,12 +60,12 @@ protected override IImmutableList OnResolveFieldChain(st if (chainRequirements == FieldChainRequirements.EndsInToMany) { // The mismatch here (ends-in-to-many being interpreted as entire-chain-must-be-to-many) is intentional. - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path); } if (chainRequirements == FieldChainRequirements.IsRelationship) { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); + return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path); } throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs index 7f4a142ef0..a9fc23d9e6 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs @@ -9,14 +9,8 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; [PublicAPI] public class SortParser : QueryExpressionParser { - private readonly Action? _validateSingleFieldCallback; private ResourceType? _resourceTypeInScope; - public SortParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - public SortExpression Parse(string source, ResourceType resourceTypeInScope) { ArgumentGuard.NotNull(resourceTypeInScope); @@ -107,9 +101,17 @@ protected override IImmutableList OnResolveFieldChain(st if (chainRequirements == FieldChainRequirements.EndsInAttribute) { return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch, - _validateSingleFieldCallback); + ValidateSingleField); } throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); } + + protected override void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + { + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + { + throw new QueryParseException($"Sorting on attribute '{attribute.PublicName}' is not allowed."); + } + } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs index 0cabbcf76e..0409948957 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs @@ -9,14 +9,8 @@ namespace JsonApiDotNetCore.Queries.Internal.Parsing; [PublicAPI] public class SparseFieldSetParser : QueryExpressionParser { - private readonly Action? _validateSingleFieldCallback; private ResourceType? _resourceType; - public SparseFieldSetParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) { ArgumentGuard.NotNull(resourceType); @@ -55,8 +49,17 @@ protected override IImmutableList OnResolveFieldChain(st { ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType!, path); - _validateSingleFieldCallback?.Invoke(field, _resourceType!, path); + ValidateSingleField(field, _resourceType!, path); return ImmutableArray.Create(field); } + + protected override void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) + { + if (field.IsViewBlocked()) + { + string kind = field is AttrAttribute ? "attribute" : "relationship"; + throw new QueryParseException($"Retrieving the {kind} '{field.PublicName}' is not allowed."); + } + } } diff --git a/src/JsonApiDotNetCore/QueryStrings/IFilterValueConverter.cs b/src/JsonApiDotNetCore/QueryStrings/IFilterValueConverter.cs new file mode 100644 index 0000000000..117513ba0b --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IFilterValueConverter.cs @@ -0,0 +1,44 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Provides conversion of a single-quoted value that occurs in a filter function of a query string. +/// +[PublicAPI] +public interface IFilterValueConverter +{ + /// + /// Indicates whether this converter can be used for the specified . + /// + /// + /// The JSON:API attribute this conversion applies to. + /// + bool CanConvert(AttrAttribute attribute); + + /// + /// Converts to the specified . + /// + /// + /// The JSON:API attribute this conversion applies to. + /// + /// + /// The literal text (without the surrounding single quotes) from the query string. + /// + /// + /// The filter function this conversion applies to, which can be , or + /// . + /// + /// + /// The converted value. Must not be null. In case the type differs from the resource property type, use a + /// from to produce a valid filter. + /// + /// + /// The conversion failed because is invalid. + /// + object Convert(AttrAttribute attribute, string value, Type outerExpressionType); +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs index dace5b8ca4..ba8cb73fb5 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs @@ -8,7 +8,6 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal.Parsing; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.QueryStrings.Internal; @@ -24,29 +23,18 @@ public class FilterQueryStringParameterReader : QueryStringParameterReader, IFil private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); private readonly Dictionary.Builder> _filtersPerScope = new(); - private string? _lastParameterName; - public bool AllowEmptyValue => false; - public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) + public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options, + IEnumerable filterValueConverters) : base(request, resourceGraph) { ArgumentGuard.NotNull(options); + ArgumentGuard.NotNull(filterValueConverters); _options = options; _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _filterParser = new FilterParser(resourceFactory, ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field.IsFilterBlocked()) - { - string kind = field is AttrAttribute ? "attribute" : "relationship"; - - throw new InvalidQueryStringParameterException(_lastParameterName!, $"Filtering on the requested {kind} is not allowed.", - $"Filtering on {kind} '{field.PublicName}' is not allowed."); - } + _filterParser = new FilterParser(resourceFactory, filterValueConverters); } /// @@ -69,8 +57,6 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; - foreach (string value in parameterValue.SelectMany(ExtractParameterValue)) { ReadSingleValue(parameterName, value); @@ -114,7 +100,7 @@ private void ReadSingleValue(string parameterName, string parameterValue) } catch (QueryParseException exception) { - throw new InvalidQueryStringParameterException(_lastParameterName!, "The specified filter is invalid.", exception.Message, exception); + throw new InvalidQueryStringParameterException(parameterName, "The specified filter is invalid.", exception.Message, exception); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs index 5e5842c960..4485a4ee9e 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs @@ -6,7 +6,6 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCore.QueryStrings.Internal; @@ -17,7 +16,6 @@ public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQ private readonly QueryStringParameterScopeParser _scopeParser; private readonly SortParser _sortParser; private readonly List _constraints = new(); - private string? _lastParameterName; public bool AllowEmptyValue => false; @@ -25,16 +23,7 @@ public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph re : base(request, resourceGraph) { _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _sortParser = new SortParser(ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Sorting on the requested attribute is not allowed.", - $"Sorting on attribute '{attribute.PublicName}' is not allowed."); - } + _sortParser = new SortParser(); } /// @@ -57,8 +46,6 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; - try { ResourceFieldChainExpression? scope = GetScope(parameterName); diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs index 09c3c0ede8..e31932b4dd 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs @@ -22,8 +22,6 @@ public class SparseFieldSetQueryStringParameterReader : QueryStringParameterRead private readonly ImmutableDictionary.Builder _sparseFieldTableBuilder = ImmutableDictionary.CreateBuilder(); - private string? _lastParameterName; - /// bool IQueryStringParameterReader.AllowEmptyValue => true; @@ -31,18 +29,7 @@ public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResour : base(request, resourceGraph) { _sparseFieldTypeParser = new SparseFieldTypeParser(resourceGraph); - _sparseFieldSetParser = new SparseFieldSetParser(ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field.IsViewBlocked()) - { - string kind = field is AttrAttribute ? "attribute" : "relationship"; - - throw new InvalidQueryStringParameterException(_lastParameterName!, $"Retrieving the requested {kind} is not allowed.", - $"Retrieving the {kind} '{field.PublicName}' is not allowed."); - } + _sparseFieldSetParser = new SparseFieldSetParser(); } /// @@ -64,8 +51,6 @@ public virtual bool CanRead(string parameterName) /// public virtual void Read(string parameterName, StringValues parameterValue) { - _lastParameterName = parameterName; - try { ResourceType targetResourceType = GetSparseFieldType(parameterName); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs index 2805745644..36a93f9ee3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs @@ -19,4 +19,7 @@ public sealed class Appointment : Identifiable [Attr] public DateTimeOffset EndTime { get; set; } + + [HasMany] + public IList Reminders { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/FilterRewritingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/FilterRewritingResourceDefinition.cs new file mode 100644 index 0000000000..b67de0ce5f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/FilterRewritingResourceDefinition.cs @@ -0,0 +1,27 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class FilterRewritingResourceDefinition : JsonApiResourceDefinition + where TResource : class, IIdentifiable +{ + public FilterRewritingResourceDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + if (existingFilter != null) + { + var rewriter = new FilterTimeRangeRewriter(); + return (FilterExpression)rewriter.Visit(existingFilter, null)!; + } + + return existingFilter; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/FilterTimeRangeRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/FilterTimeRangeRewriter.cs new file mode 100644 index 0000000000..3e3c903b39 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/FilterTimeRangeRewriter.cs @@ -0,0 +1,34 @@ +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion; + +internal sealed class FilterTimeRangeRewriter : QueryExpressionRewriter +{ + private static readonly Dictionary InverseComparisonOperatorTable = new() + { + [ComparisonOperator.GreaterThan] = ComparisonOperator.LessThan, + [ComparisonOperator.GreaterOrEqual] = ComparisonOperator.LessOrEqual, + [ComparisonOperator.Equals] = ComparisonOperator.Equals, + [ComparisonOperator.LessThan] = ComparisonOperator.GreaterThan, + [ComparisonOperator.LessOrEqual] = ComparisonOperator.GreaterOrEqual + }; + + public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) + { + if (expression.Right is LiteralConstantExpression { TypedValue: TimeRange timeRange }) + { + var offsetComparison = + new ComparisonExpression(timeRange.Offset < TimeSpan.Zero ? InverseComparisonOperatorTable[expression.Operator] : expression.Operator, + expression.Left, new LiteralConstantExpression(timeRange.Time + timeRange.Offset)); + + ComparisonExpression? timeComparison = expression.Operator is ComparisonOperator.LessThan or ComparisonOperator.LessOrEqual + ? new ComparisonExpression(timeRange.Offset < TimeSpan.Zero ? ComparisonOperator.LessOrEqual : ComparisonOperator.GreaterOrEqual, + expression.Left, new LiteralConstantExpression(timeRange.Time)) + : null; + + return timeComparison == null ? offsetComparison : new LogicalExpression(LogicalOperator.And, offsetComparison, timeComparison); + } + + return base.VisitComparison(expression, argument); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/RelativeTimeFilterValueConverter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/RelativeTimeFilterValueConverter.cs new file mode 100644 index 0000000000..aa82daa4d0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/RelativeTimeFilterValueConverter.cs @@ -0,0 +1,54 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Resources.Internal; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion; + +internal sealed class RelativeTimeFilterValueConverter : IFilterValueConverter +{ + private readonly ISystemClock _systemClock; + + public RelativeTimeFilterValueConverter(ISystemClock systemClock) + { + _systemClock = systemClock; + } + + public bool CanConvert(AttrAttribute attribute) + { + return attribute.Type.ClrType == typeof(Reminder) && attribute.Property.PropertyType == typeof(DateTime); + } + + public object Convert(AttrAttribute attribute, string value, Type outerExpressionType) + { + // A leading +/- indicates a relative value, based on the current time. + + if (value.Length > 1 && value[0] is '+' or '-') + { + if (outerExpressionType != typeof(ComparisonExpression)) + { + throw new QueryParseException("A relative time can only be used in a comparison function."); + } + + var timeSpan = ConvertStringValueTo(value[1..]); + TimeSpan offset = value[0] == '-' ? -timeSpan : timeSpan; + return new TimeRange(_systemClock.UtcNow.UtcDateTime, offset); + } + + return ConvertStringValueTo(value); + } + + private static T ConvertStringValueTo(string value) + { + try + { + return (T)RuntimeTypeConverter.ConvertType(value, typeof(T))!; + } + catch (FormatException exception) + { + throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{typeof(T).Name}'.", exception); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/RelativeTimeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/RelativeTimeTests.cs new file mode 100644 index 0000000000..435d60c661 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/RelativeTimeTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using FluentAssertions; +using FluentAssertions.Extensions; +using Humanizer; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion; + +public sealed class RelativeTimeTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public RelativeTimeTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(typeof(IResourceDefinition<,>), typeof(FilterRewritingResourceDefinition<,>)); + }); + } + + [Theory] + [InlineData("-0:10:00", ComparisonOperator.GreaterThan, "0")] // more than 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.GreaterOrEqual, "0,1")] // at least 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.Equals, "1")] // exactly 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.LessThan, "2,3")] // less than 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.LessOrEqual, "1,2,3")] // at most 10 minutes ago + [InlineData("+0:10:00", ComparisonOperator.GreaterThan, "6")] // more than 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.GreaterOrEqual, "5,6")] // at least 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.Equals, "5")] // in exactly 10 minutes + [InlineData("+0:10:00", ComparisonOperator.LessThan, "3,4")] // less than 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.LessOrEqual, "3,4,5")] // at most 10 minutes in the future + public async Task Can_filter_comparison_on_relative_time(string filterValue, ComparisonOperator comparisonOperator, string matchingRowsExpected) + { + // Arrange + var clock = _testContext.Factory.Services.GetRequiredService(); + + List reminders = _fakers.Reminder.Generate(7); + reminders[0].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-15)).DateTime.AsUtc(); + reminders[1].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-10)).DateTime.AsUtc(); + reminders[2].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(-5)).DateTime.AsUtc(); + reminders[3].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(0)).DateTime.AsUtc(); + reminders[4].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(5)).DateTime.AsUtc(); + reminders[5].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(10)).DateTime.AsUtc(); + reminders[6].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(15)).DateTime.AsUtc(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Reminders.AddRange(reminders); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/reminders?filter={comparisonOperator.ToString().Camelize()}(remindsAt,'{filterValue.Replace("+", "%2B")}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + int[] matchingRowIndices = matchingRowsExpected.Split(',').Select(int.Parse).ToArray(); + responseDocument.Data.ManyValue.ShouldHaveCount(matchingRowIndices.Length); + + foreach (int rowIndex in matchingRowIndices) + { + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == reminders[rowIndex].StringId); + } + } + + [Fact] + public async Task Cannot_filter_comparison_on_invalid_relative_time() + { + // Arrange + const string route = "/reminders?filter=equals(remindsAt,'-*')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be("Failed to convert '*' of type 'String' to type 'TimeSpan'."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_any_on_relative_time() + { + // Arrange + const string route = "/reminders?filter=any(remindsAt,'-0:10:00')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be("A relative time can only be used in a comparison function."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_text_match_on_relative_time() + { + // Arrange + const string route = "/reminders?filter=startsWith(remindsAt,'-0:10:00')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be("A relative time can only be used in a comparison function."); + error.Source.ShouldNotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Can_filter_comparison_on_relative_time_in_nested_expression() + { + // Arrange + var clock = _testContext.Factory.Services.GetRequiredService(); + + Calendar calendar = _fakers.Calendar.Generate(); + calendar.Appointments = _fakers.Appointment.Generate(2).ToHashSet(); + + calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.Generate(1); + calendar.Appointments.ElementAt(0).Reminders[0].RemindsAt = clock.UtcNow.DateTime.AsUtc(); + + calendar.Appointments.ElementAt(1).Reminders = _fakers.Reminder.Generate(1); + calendar.Appointments.ElementAt(1).Reminders[0].RemindsAt = clock.UtcNow.Add(TimeSpan.FromMinutes(30)).DateTime.AsUtc(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/calendars/{calendar.StringId}/appointments?filter=has(reminders,equals(remindsAt,'%2B0:30:00'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + + responseDocument.Data.ManyValue[0].Id.Should().Be(calendar.Appointments.ElementAt(1).StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/TimeRange.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/TimeRange.cs new file mode 100644 index 0000000000..e17eda9f9a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/FilterValueConversion/TimeRange.cs @@ -0,0 +1,13 @@ +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.FilterValueConversion; + +internal sealed class TimeRange +{ + public DateTime Time { get; } + public TimeSpan Offset { get; } + + public TimeRange(DateTime time, TimeSpan offset) + { + Time = time; + Offset = offset; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index 104f5a4f79..cf63d41008 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -84,7 +84,7 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Filtering on the requested attribute is not allowed."); + error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Be("Filtering on attribute 'dateOfBirth' is not allowed."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); @@ -106,7 +106,7 @@ public async Task Cannot_filter_on_ToMany_relationship_with_blocked_capability() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Filtering on the requested relationship is not allowed."); + error.Title.Should().Be("The specified filter is invalid."); error.Detail.Should().Be("Filtering on relationship 'appointments' is not allowed."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("filter"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index 88360bcfa2..2b63bf8c8b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -909,7 +909,7 @@ public async Task Cannot_include_relationship_when_inclusion_blocked() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Including the requested relationship is not allowed."); + error.Title.Should().Be("The specified include is invalid."); error.Detail.Should().Be("Including the relationship 'parent' on 'blogPosts' is not allowed."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); @@ -931,7 +931,7 @@ public async Task Cannot_include_relationship_when_nested_inclusion_blocked() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Including the requested relationship is not allowed."); + error.Title.Should().Be("The specified include is invalid."); error.Detail.Should().Be("Including the relationship 'parent' in 'posts.parent' on 'blogPosts' is not allowed."); error.Source.ShouldNotBeNull(); error.Source.Parameter.Should().Be("include"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index 473a7428ba..3aa40b2725 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -21,6 +21,7 @@ public sealed class QueryStringDbContext : TestableDbContext public DbSet LoginAttempts => Set(); public DbSet Calendars => Set(); public DbSet Appointments => Set(); + public DbSet Reminders => Set(); public QueryStringDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index ab7fc4b77e..8fad2f6b90 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -70,6 +70,12 @@ internal sealed class QueryStringFakers : FakerContainer .TruncateToWholeMilliseconds()) .RuleFor(appointment => appointment.EndTime, (faker, appointment) => appointment.StartTime.AddHours(faker.Random.Double(1, 4)))); + private readonly Lazy> _lazyReminderFaker = new(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(reminder => reminder.RemindsAt, faker => faker.Date.Future() + .TruncateToWholeMilliseconds())); + public Faker Blog => _lazyBlogFaker.Value; public Faker BlogPost => _lazyBlogPostFaker.Value; public Faker