Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved Cursor Invalid Format Error #6882

Merged
merged 1 commit into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/HotChocolate/Core/src/Abstractions/ErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,5 +352,10 @@ public static class Paging
/// You must provide a `first` or `last` value to properly paginate the connection.
/// </summary>
public const string NoPagingBoundaries = "HC0052";

/// <summary>
/// The cursor format is invalid.
/// </summary>
public const string InvalidCursor = "HC0078";
}
}
10 changes: 4 additions & 6 deletions src/HotChocolate/Core/src/Abstractions/ErrorExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@ public static class ErrorExtensions
/// but without any syntax node details.
/// </returns>
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);
}

/// <summary>
/// Creates a new error that contains all properties of this error
Expand All @@ -40,13 +39,12 @@ public static IError RemoveSyntaxNode(this IError error)
/// but with the specified <paramref name="syntaxNode" />.
/// </returns>
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ThrowHelper = HotChocolate.Utilities.ThrowHelper;

namespace HotChocolate.Types.Pagination;

Expand Down Expand Up @@ -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<TEntity>.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<TEntity>.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
Expand Down
56 changes: 56 additions & 0 deletions src/HotChocolate/Core/src/Types.CursorPagination/IndexCursor.cs
Original file line number Diff line number Diff line change
@@ -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<byte> 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<byte>.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<byte>.Shared.Return(rented);
}
}
}
}
}
52 changes: 2 additions & 50 deletions src/HotChocolate/Core/src/Types.CursorPagination/IndexEdge.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
using System;
using System.Buffers;
using System.Buffers.Text;
using System.Text;
using HotChocolate.Execution;

#nullable enable
Expand All @@ -10,8 +8,6 @@ namespace HotChocolate.Types.Pagination;

public sealed class IndexEdge<T> : Edge<T>
{
private static readonly Encoding _utf8 = Encoding.UTF8;

private IndexEdge(T node, string cursor, int index)
: base(node, cursor)
{
Expand All @@ -25,51 +21,7 @@ public static IndexEdge<T> Create(T node, int index)
Span<byte> 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<T>(node, cursor, index);
}

private static unsafe string CreateString(Span<byte> 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<byte>.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<byte>.Shared.Return(rented);
}
}
}
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,7 @@
<data name="Edge_Cursor_CursorAndResolverNull" xml:space="preserve">
<value>The edge state is invalid and has no cursor.</value>
</data>
<data name="ThrowHelper_InvalidIndexCursor_Message" xml:space="preserve">
<value>The cursor specified in `{0}` has an invalid format.</value>
</data>
</root>
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryType>()
.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<QueryType>()
.Services
.BuildServiceProvider()
.GetRequestExecutorAsync();

var result = await executor.ExecuteAsync(
"""
{
letters(first: 2 before: "INVALID") {
edges {
cursor
}
}
}
""");

await result.MatchSnapshotAsync();
}

public class QueryType : ObjectType<Query>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading