diff --git a/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs b/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs index aa71de9ad74..5c54edf2827 100644 --- a/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs +++ b/src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs @@ -352,5 +352,10 @@ public static class Paging /// You must provide a `first` or `last` value to properly paginate the connection. /// public const string NoPagingBoundaries = "HC0052"; + + /// + /// The cursor format is invalid. + /// + public const string InvalidCursor = "HC0078"; } } diff --git a/src/HotChocolate/Core/src/Abstractions/ErrorExtensions.cs b/src/HotChocolate/Core/src/Abstractions/ErrorExtensions.cs index 89d8d89e540..940e234e825 100644 --- a/src/HotChocolate/Core/src/Abstractions/ErrorExtensions.cs +++ b/src/HotChocolate/Core/src/Abstractions/ErrorExtensions.cs @@ -18,14 +18,13 @@ public static class ErrorExtensions /// but without any syntax node details. /// public static IError RemoveSyntaxNode(this IError error) - { - return new Error(error.Message, + => new Error( + error.Message, error.Code, error.Path, error.Locations, error.Extensions, error.Exception); - } /// /// Creates a new error that contains all properties of this error @@ -40,13 +39,12 @@ public static IError RemoveSyntaxNode(this IError error) /// but with the specified . /// public static IError WithSyntaxNode(this IError error, ISyntaxNode? syntaxNode) - { - return new Error(error.Message, + => new Error( + error.Message, error.Code, error.Path, error.Locations, error.Extensions, error.Exception, syntaxNode); - } } diff --git a/src/HotChocolate/Core/src/Types.CursorPagination/CursorPaginationAlgorithm.cs b/src/HotChocolate/Core/src/Types.CursorPagination/CursorPaginationAlgorithm.cs index e8b2cf259e3..635216c4ab2 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination/CursorPaginationAlgorithm.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination/CursorPaginationAlgorithm.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using ThrowHelper = HotChocolate.Utilities.ThrowHelper; namespace HotChocolate.Types.Pagination; @@ -170,15 +171,29 @@ private static CursorPagingRange SliceRange( // afterEdge. // // The cursor is increased by one so that the index points to the element after - var startIndex = arguments.After is { } a - ? IndexEdge.DeserializeCursor(a) + 1 - : 0; + var startIndex = 0; + if (arguments.After is not null) + { + if (!IndexCursor.TryParse(arguments.After, out var index)) + { + throw ThrowHelper.InvalidIndexCursor("after", arguments.After); + } + + startIndex = index + 1; + } // [SPEC] if before is set then remove all elements of edges before and including // beforeEdge. - var before = arguments.Before is { } b - ? IndexEdge.DeserializeCursor(b) - : maxElementCount; + var before = maxElementCount; + if (arguments.Before is not null) + { + if (!IndexCursor.TryParse(arguments.Before, out var index)) + { + throw ThrowHelper.InvalidIndexCursor("before", arguments.Before); + } + + before = index; + } // if after is negative we have know how much of the offset was in the negative range. // The amount of positions that are in the negative range, have to be subtracted from diff --git a/src/HotChocolate/Core/src/Types.CursorPagination/IndexCursor.cs b/src/HotChocolate/Core/src/Types.CursorPagination/IndexCursor.cs new file mode 100644 index 00000000000..cab1f9f3a65 --- /dev/null +++ b/src/HotChocolate/Core/src/Types.CursorPagination/IndexCursor.cs @@ -0,0 +1,56 @@ +using System; +using System.Buffers; +using System.Buffers.Text; +using System.Text; + +namespace HotChocolate.Types.Pagination; + +internal static class IndexCursor +{ + private static readonly Encoding _utf8 = Encoding.UTF8; + + public static unsafe string Format(Span buffer) + { + fixed (byte* bytePtr = buffer) + { + return _utf8.GetString(bytePtr, buffer.Length); + } + } + + public static unsafe bool TryParse(string cursor, out int index) + { + fixed (char* cPtr = cursor) + { + var count = _utf8.GetByteCount(cPtr, cursor.Length); + byte[]? rented = null; + + var buffer = count <= 128 + ? stackalloc byte[count] + : rented = ArrayPool.Shared.Rent(count); + + try + { + fixed (byte* bytePtr = buffer) + { + _utf8.GetBytes(cPtr, cursor.Length, bytePtr, buffer.Length); + } + + Base64.DecodeFromUtf8InPlace(buffer, out var written); + if (Utf8Parser.TryParse(buffer.Slice(0, written), out index, out _)) + { + return true; + } + + index = -1; + return false; + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } + } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/src/Types.CursorPagination/IndexEdge.cs b/src/HotChocolate/Core/src/Types.CursorPagination/IndexEdge.cs index 7d2b3ad4f35..945352b22fb 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination/IndexEdge.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination/IndexEdge.cs @@ -1,7 +1,5 @@ using System; -using System.Buffers; using System.Buffers.Text; -using System.Text; using HotChocolate.Execution; #nullable enable @@ -10,8 +8,6 @@ namespace HotChocolate.Types.Pagination; public sealed class IndexEdge : Edge { - private static readonly Encoding _utf8 = Encoding.UTF8; - private IndexEdge(T node, string cursor, int index) : base(node, cursor) { @@ -25,51 +21,7 @@ public static IndexEdge Create(T node, int index) Span buffer = stackalloc byte[27 / 3 * 4]; Utf8Formatter.TryFormat(index, buffer, out var written); Base64.EncodeToUtf8InPlace(buffer, written, out written); - var cursor = CreateString(buffer.Slice(0, written)); + var cursor = IndexCursor.Format(buffer.Slice(0, written)); return new IndexEdge(node, cursor, index); } - - private static unsafe string CreateString(Span buffer) - { - fixed (byte* bytePtr = buffer) - { - return _utf8.GetString(bytePtr, buffer.Length); - } - } - - public static unsafe int DeserializeCursor(string cursor) - { - fixed (char* cPtr = cursor) - { - var count = _utf8.GetByteCount(cPtr, cursor.Length); - byte[]? rented = null; - - var buffer = count <= 128 - ? stackalloc byte[count] - : rented = ArrayPool.Shared.Rent(count); - - try - { - fixed (byte* bytePtr = buffer) - { - _utf8.GetBytes(cPtr, cursor.Length, bytePtr, buffer.Length); - } - - Base64.DecodeFromUtf8InPlace(buffer, out var written); - if (Utf8Parser.TryParse(buffer.Slice(0, written), out int index, out _)) - { - return index; - } - - throw new QueryException("The cursor has an invalid format."); - } - finally - { - if (rented is { }) - { - ArrayPool.Shared.Return(rented); - } - } - } - } -} +} \ No newline at end of file diff --git a/src/HotChocolate/Core/src/Types.CursorPagination/Properties/CursorResources.Designer.cs b/src/HotChocolate/Core/src/Types.CursorPagination/Properties/CursorResources.Designer.cs index 2448e37f01b..d59be8c4bfc 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination/Properties/CursorResources.Designer.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination/Properties/CursorResources.Designer.cs @@ -98,5 +98,11 @@ internal static string Edge_Cursor_CursorAndResolverNull { return ResourceManager.GetString("Edge_Cursor_CursorAndResolverNull", resourceCulture); } } + + internal static string ThrowHelper_InvalidIndexCursor_Message { + get { + return ResourceManager.GetString("ThrowHelper_InvalidIndexCursor_Message", resourceCulture); + } + } } } diff --git a/src/HotChocolate/Core/src/Types.CursorPagination/Properties/CursorResources.resx b/src/HotChocolate/Core/src/Types.CursorPagination/Properties/CursorResources.resx index f2f0f01dc21..8a6cbdab568 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination/Properties/CursorResources.resx +++ b/src/HotChocolate/Core/src/Types.CursorPagination/Properties/CursorResources.resx @@ -45,4 +45,7 @@ The edge state is invalid and has no cursor. + + The cursor specified in `{0}` has an invalid format. + diff --git a/src/HotChocolate/Core/src/Types.CursorPagination/Utilities/ThrowHelper.cs b/src/HotChocolate/Core/src/Types.CursorPagination/Utilities/ThrowHelper.cs index f63a12dd873..6ec5d5319b2 100644 --- a/src/HotChocolate/Core/src/Types.CursorPagination/Utilities/ThrowHelper.cs +++ b/src/HotChocolate/Core/src/Types.CursorPagination/Utilities/ThrowHelper.cs @@ -42,4 +42,15 @@ public static SchemaException PagingObjectFieldDescriptorExtensions_InvalidType( .SetMessage(PagingObjectFieldDescriptorExtensions_SchemaTypeNotValid) .SetCode(ErrorCodes.Paging.SchemaTypeInvalid) .Build()); + + public static GraphQLException InvalidIndexCursor(string argument, string cursor) + => new GraphQLException( + ErrorBuilder.New() + .SetMessage( + ThrowHelper_InvalidIndexCursor_Message, + argument) + .SetExtension("argument", argument) + .SetExtension("cursor", cursor) + .SetCode(ErrorCodes.Paging.InvalidCursor) + .Build()); } diff --git a/src/HotChocolate/Core/src/Types/Types/Pagination/PagingMiddleware.cs b/src/HotChocolate/Core/src/Types/Types/Pagination/PagingMiddleware.cs index 3a38fd10b25..9ac5912d877 100644 --- a/src/HotChocolate/Core/src/Types/Types/Pagination/PagingMiddleware.cs +++ b/src/HotChocolate/Core/src/Types/Types/Pagination/PagingMiddleware.cs @@ -6,18 +6,12 @@ namespace HotChocolate.Types.Pagination; -public class PagingMiddleware +public class PagingMiddleware(FieldDelegate next, IPagingHandler pagingHandler) { - private readonly FieldDelegate _next; - private readonly IPagingHandler _pagingHandler; - - public PagingMiddleware(FieldDelegate next, IPagingHandler pagingHandler) - { - _next = next ?? - throw new ArgumentNullException(nameof(next)); - _pagingHandler = pagingHandler ?? - throw new ArgumentNullException(nameof(pagingHandler)); - } + private readonly FieldDelegate _next = next ?? + throw new ArgumentNullException(nameof(next)); + private readonly IPagingHandler _pagingHandler = pagingHandler ?? + throw new ArgumentNullException(nameof(pagingHandler)); public async Task InvokeAsync(IMiddlewareContext context) { @@ -37,9 +31,28 @@ public async Task InvokeAsync(IMiddlewareContext context) if (context.Result is not null and not IPage) { - context.Result = await _pagingHandler - .SliceAsync(context, context.Result) - .ConfigureAwait(false); + try + { + context.Result = await _pagingHandler + .SliceAsync(context, context.Result) + .ConfigureAwait(false); + } + catch (GraphQLException ex) + { + var errors = new IError[ex.Errors.Count]; + + for (var i = 0; i < ex.Errors.Count; i++) + { + errors[i] = ErrorBuilder + .FromError(ex.Errors[i]) + .AddLocation(context.Selection.SyntaxNode) + .SetSyntaxNode(context.Selection.SyntaxNode) + .SetPath(context.Path) + .Build(); + } + + throw new GraphQLException(errors); + } } } } diff --git a/src/HotChocolate/Core/test/Types.CursorPagination.Tests/IntegrationTests.cs b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/IntegrationTests.cs index 71bce264a9a..a930a4166f4 100644 --- a/src/HotChocolate/Core/test/Types.CursorPagination.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/IntegrationTests.cs @@ -944,6 +944,60 @@ public async Task TotalCountWithCustomConnection() // assert result.ToJson().MatchSnapshot(); } + + [Fact] + public async Task Invalid_After_Index_Cursor() + { + Snapshot.FullName(); + + var executor = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .Services + .BuildServiceProvider() + .GetRequestExecutorAsync(); + + var result = await executor.ExecuteAsync( + """ + { + letters(first: 2 after: "INVALID") { + edges { + cursor + } + } + } + """); + + await result.MatchSnapshotAsync(); + } + + [Fact] + public async Task Invalid_Before_Index_Cursor() + { + Snapshot.FullName(); + + var executor = + await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .Services + .BuildServiceProvider() + .GetRequestExecutorAsync(); + + var result = await executor.ExecuteAsync( + """ + { + letters(first: 2 before: "INVALID") { + edges { + cursor + } + } + } + """); + + await result.MatchSnapshotAsync(); + } public class QueryType : ObjectType { diff --git a/src/HotChocolate/Core/test/Types.CursorPagination.Tests/__snapshots__/IntegrationTests.Invalid_After_Index_Cursor.snap b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/__snapshots__/IntegrationTests.Invalid_After_Index_Cursor.snap new file mode 100644 index 00000000000..fd32147a5f6 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/__snapshots__/IntegrationTests.Invalid_After_Index_Cursor.snap @@ -0,0 +1,24 @@ +{ + "errors": [ + { + "message": "The cursor specified in `after` has an invalid format.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "letters" + ], + "extensions": { + "argument": "after", + "cursor": "INVALID", + "code": "HC0078" + } + } + ], + "data": { + "letters": null + } +} diff --git a/src/HotChocolate/Core/test/Types.CursorPagination.Tests/__snapshots__/IntegrationTests.Invalid_Before_Index_Cursor.snap b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/__snapshots__/IntegrationTests.Invalid_Before_Index_Cursor.snap new file mode 100644 index 00000000000..0b3bb27ed69 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.CursorPagination.Tests/__snapshots__/IntegrationTests.Invalid_Before_Index_Cursor.snap @@ -0,0 +1,24 @@ +{ + "errors": [ + { + "message": "The cursor specified in `before` has an invalid format.", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "letters" + ], + "extensions": { + "argument": "before", + "cursor": "INVALID", + "code": "HC0078" + } + } + ], + "data": { + "letters": null + } +}