Skip to content

Commit

Permalink
Improved Cursor Invalid Format Error (#6882)
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelstaib authored Feb 11, 2024
1 parent 9062a25 commit 8c26d3b
Show file tree
Hide file tree
Showing 12 changed files with 237 additions and 76 deletions.
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

0 comments on commit 8c26d3b

Please sign in to comment.