diff --git a/src/HotChocolate/Core/src/Types.CursorPagination/Extensions/UseConnectionAttribute.cs b/src/HotChocolate/Core/src/Types.CursorPagination/Extensions/UseConnectionAttribute.cs index 788844e3fc0..2df4efb16a3 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination/Extensions/UseConnectionAttribute.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination/Extensions/UseConnectionAttribute.cs @@ -1,9 +1,12 @@ using System.Reflection; using System.Runtime.CompilerServices; +using HotChocolate.Resolvers; using HotChocolate.Types.Descriptors; using HotChocolate.Types.Descriptors.Configurations; using HotChocolate.Types.Pagination; using HotChocolate.Utilities; +using static HotChocolate.Types.Pagination.CursorPagingArgumentNames; +using static HotChocolate.WellKnownMiddleware; // ReSharper disable once CheckNamespace namespace HotChocolate.Types; @@ -125,6 +128,10 @@ protected internal override void TryConfigure( if (descriptor is IObjectFieldDescriptor fieldDesc) { var definition = fieldDesc.Extend().Configuration; + definition.MiddlewareConfigurations.Add( + new FieldMiddlewareConfiguration( + CreatePagingValidationMiddleware(), + key: Paging)); definition.Tasks.Add( new OnCreateTypeSystemConfigurationTask( (_, d) => d.Features.Set(options), definition)); @@ -161,4 +168,112 @@ static void ApplyPagingOptions( } } } + + private static FieldMiddleware CreatePagingValidationMiddleware() + => next => context => + { + var options = PagingHelper.GetPagingOptions(context.Schema, context.Selection.Field); + ValidateContext(context, options); + PublishPagingArguments(context, options); + return next(context); + }; + + private static void ValidateContext( + IMiddlewareContext context, + PagingOptions options) + { + var allowBackwardPagination = + options.AllowBackwardPagination ?? PagingDefaults.AllowBackwardPagination; + var requirePagingBoundaries = + options.RequirePagingBoundaries ?? PagingDefaults.RequirePagingBoundaries; + var maxPageSize = + options.MaxPageSize ?? PagingDefaults.MaxPageSize; + + var first = context.ArgumentValue(First); + var last = allowBackwardPagination + ? context.ArgumentValue(Last) + : null; + + if (requirePagingBoundaries && first is null && last is null) + { + if (allowBackwardPagination) + { + throw ThrowHelper.PagingHandler_NoBoundariesSet( + context.Selection.Field, + context.Path); + } + + throw ThrowHelper.PagingHandler_FirstValueNotSet( + context.Selection.Field, + context.Path); + } + + if (first < 0) + { + throw ThrowHelper.PagingHandler_MinPageSize( + (int)first, + context.Selection.Field, + context.Path); + } + + if (first > maxPageSize) + { + throw ThrowHelper.PagingHandler_MaxPageSize( + (int)first, + maxPageSize, + context.Selection.Field, + context.Path); + } + + if (last < 0) + { + throw ThrowHelper.PagingHandler_MinPageSize( + (int)last, + context.Selection.Field, + context.Path); + } + + if (last > maxPageSize) + { + throw ThrowHelper.PagingHandler_MaxPageSize( + (int)last, + maxPageSize, + context.Selection.Field, + context.Path); + } + } + + private static void PublishPagingArguments( + IMiddlewareContext context, + PagingOptions options) + { + var allowBackwardPagination = options.AllowBackwardPagination ?? PagingDefaults.AllowBackwardPagination; + var maxPageSize = options.MaxPageSize ?? PagingDefaults.MaxPageSize; + var defaultPageSize = options.DefaultPageSize ?? PagingDefaults.DefaultPageSize; + + if (maxPageSize < defaultPageSize) + { + defaultPageSize = maxPageSize; + } + + var first = context.ArgumentValue(First); + var last = allowBackwardPagination + ? context.ArgumentValue(Last) + : null; + + if (first is null && last is null) + { + first = defaultPageSize; + } + + context.SetLocalState( + WellKnownContextData.PagingArguments, + new CursorPagingArguments( + first, + last, + context.ArgumentValue(After), + allowBackwardPagination + ? context.ArgumentValue(Before) + : null)); + } } diff --git a/src/HotChocolate/Core/test/Types.CursorPagination.Tests/UseConnectionAttributeTests.cs b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/UseConnectionAttributeTests.cs new file mode 100644 index 00000000000..0956aabb5d3 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/UseConnectionAttributeTests.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using HotChocolate.Execution; +using HotChocolate.Resolvers; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Types.Pagination; + +public class UseConnectionAttributeTests +{ + [Fact] + public async Task UseConnectionAttribute_Validates_Max_Page_Size() + { + var result = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .ExecuteRequestAsync( + """ + { + foos(first: 3) + } + """); + + AssertErrorCode(result, ErrorCodes.Paging.MaxPaginationItems); + } + + [Fact] + public async Task UseConnectionAttribute_Validates_Boundaries() + { + var result = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .ExecuteRequestAsync( + """ + { + foos + } + """); + + AssertErrorCode(result, ErrorCodes.Paging.NoPagingBoundaries); + } + + [Fact] + public async Task UseConnectionAttribute_Validates_First_When_Backward_Paging_Is_Disabled() + { + var result = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .ExecuteRequestAsync( + """ + { + foosNoBackward + } + """); + + AssertErrorCode(result, ErrorCodes.Paging.FirstValueNotSet); + } + + [Fact] + public async Task UseConnectionAttribute_Clamps_Default_Page_Size_To_Max_Page_Size() + { + var result = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .ExecuteRequestAsync( + """ + { + foosClampedDefault + } + """); + + var operationResult = result.ExpectOperationResult(); + + Assert.True( + operationResult.Errors is null || operationResult.Errors.Count == 0, + $"Expected no errors but got: {operationResult.ToJson()}"); + + using var document = JsonDocument.Parse(operationResult.ToJson()); + Assert.Equal( + 2, + document + .RootElement + .GetProperty("data") + .GetProperty("foosClampedDefault") + .GetInt32()); + } + + public class UseConnectionQueryType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Field(t => t.GetFoos()) + .AddPagingArguments(); + + descriptor + .Field(t => t.GetFoosNoBackward()) + .AddPagingArguments(); + + descriptor + .Field(t => t.GetFoosClampedDefault(default!)) + .AddPagingArguments(); + } + } + + public class UseConnectionQuery + { + [UseConnection(MaxPageSize = 2, RequirePagingBoundaries = true)] + public string GetFoos() + => throw new InvalidOperationException("Resolver should not be called."); + + [UseConnection(AllowBackwardPagination = false, RequirePagingBoundaries = true)] + public string GetFoosNoBackward() + => throw new InvalidOperationException("Resolver should not be called."); + + [UseConnection(DefaultPageSize = 3, MaxPageSize = 2)] + public int GetFoosClampedDefault(IResolverContext context) + { + var pagingArgs = + context.GetLocalState(WellKnownContextData.PagingArguments); + return pagingArgs.First ?? -1; + } + } + + private static void AssertErrorCode( + IExecutionResult executionResult, + string code) + { + var operationResult = executionResult.ExpectOperationResult(); + var error = Assert.Single(operationResult.Errors!); + + Assert.True( + error.Code == code, + $"Expected code {code} but got {error.Code}. Message: {error.Message}. Result: {operationResult.ToJson()}"); + } +}