Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ namespace HotChocolate.Transport.Http;
/// </summary>
public sealed class GraphQLHttpResponse : IDisposable
{
private static readonly OperationResult s_transportError = CreateTransportError();

private readonly HttpResponseMessage _message;

/// <summary>
Expand Down Expand Up @@ -182,21 +180,16 @@ public IAsyncEnumerable<OperationResult> ReadAsResultStreamAsync()
ct => ReadAsResultInternalAsync(contentType.CharSet, ct));
}

return SingleResult(new ValueTask<OperationResult>(s_transportError));
_message.EnsureSuccessStatusCode();

throw new InvalidOperationException("Received a successful response with an unexpected content type.");
}

private static async IAsyncEnumerable<OperationResult> SingleResult(ValueTask<OperationResult> result)
{
yield return await result.ConfigureAwait(false);
}

private static OperationResult CreateTransportError()
=> new OperationResult(
errors: JsonDocument.Parse(
"""
[{"message": "Internal Execution Error"}]
""").RootElement);

/// <summary>
/// Disposes the underlying <see cref="HttpResponseMessage"/>.
/// </summary>
Expand Down
99 changes: 99 additions & 0 deletions src/HotChocolate/Core/src/Execution.Abstractions/Path.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,62 @@ public Path Append(int index)
return new IndexerPathSegment(this, index);
}

/// <summary>
/// Appends another path to this path.
/// </summary>
/// <param name="path">
/// The other path.
/// </param>
/// <returns>
/// the combined path.
/// </returns>
public Path Append(Path path)
{
ArgumentNullException.ThrowIfNull(path);

if (path.IsRoot)
{
return this;
}

var stack = new Stack<object>();
var current = path;

while (!current.IsRoot)
{
switch (current)
{
case IndexerPathSegment indexer:
stack.Push(indexer.Index);
break;

case NamePathSegment name:
stack.Push(name.Name);
break;

default:
throw new NotSupportedException("Unsupported path segment type.");
}

current = current.Parent;
}

var newPath = this;

while (stack.Count > 0)
{
var segment = stack.Pop();
newPath = segment switch
{
string name => newPath.Append(name),
int index => newPath.Append(index),
_ => throw new NotSupportedException("Unsupported path segment type.")
};
}

return newPath;
}

/// <summary>
/// Generates a string that represents the current path.
/// </summary>
Expand Down Expand Up @@ -147,6 +203,49 @@ public IReadOnlyList<object> ToList()
return stack;
}

/// <summary>
/// Creates a new list representing the current <see cref="Path"/>.
/// </summary>
/// <returns>
/// Returns a new list representing the current <see cref="Path"/>.
/// </returns>
public void ToList(Span<object> path)
{
if (IsRoot)
{
return;
}

if (path.Length < Length)
{
throw new ArgumentException(
"The path span mustn't be smaller than the length of the path.",
nameof(path));
}

var current = this;
var length = path.Length;

while (!current.IsRoot)
{
switch (current)
{
case IndexerPathSegment indexer:
path[--length] = indexer.Index;
break;

case NamePathSegment name:
path[--length] = name.Name;
break;

default:
throw new NotSupportedException();
}

current = current.Parent;
}
}

/// <summary>Returns a string that represents the current <see cref="Path"/>.</summary>
/// <returns>A string that represents the current <see cref="Path"/>.</returns>
public override string ToString() => Print();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
namespace HotChocolate.Fusion.Execution.Clients;

/// <summary>
/// A trie (prefix tree) data structure for efficiently organizing and retrieving GraphQL errors
/// based on their field paths. The <see cref="ErrorTrie"/> enables fast error lookups and
/// maintains the hierarchical structure of GraphQL error paths in a federated GraphQL request.
/// </summary>
/// <remarks>
/// <para>
/// Each node in the trie represents a path segment (field name or list index) and can
/// optionally contain an error. In GraphQL each path can have a single error.
/// The tree structure mirrors the GraphQL response structure,
/// allowing errors to be precisely located and retrieved.
/// </para>
/// <example>
///
/// For a GraphQL query structure like:
/// <code>
/// query {
/// user {
/// posts {
/// title
/// comments {
/// text
/// }
/// }
/// }
/// }
/// </code>
///
/// An error at path ["user", "posts", 0, "title"] would create the trie structure:
/// <code>
/// root["user"]["posts"][0]["title"] = error
/// </code>
/// </example>
/// </remarks>
public sealed class ErrorTrie : Dictionary<object, ErrorTrie>
{
/// <summary>
/// Gets or sets the error associated with this node in the trie.
/// </summary>
/// <value>
/// The <see cref="IError"/> instance if an error exists at this path,
/// otherwise <c>null</c>.
/// </value>
/// <remarks>
/// An error is stored at the final node of a path. Intermediate nodes
/// typically have a <c>null</c> error value and serve as path segments
/// leading to the actual error location.
/// </remarks>
public IError? Error { get; set; }

/// <summary>
/// Finds and returns the first error encountered in the trie using depth-first traversal.
/// </summary>
public IError? FindFirstError()
{
if (Error is not null)
{
return Error;
}

var stack = new Stack<ErrorTrie>(Values);

while (stack.TryPop(out var errorTrie))
{
if (errorTrie.Error is not null)
{
return errorTrie.Error;
}

foreach (var child in errorTrie.Values)
{
stack.Push(child);
}
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System.Buffers;
using System.Collections.Immutable;
using System.Text.Json;

namespace HotChocolate.Fusion.Execution.Clients;

/// <summary>
/// Represents the collection of errors returned from a specific source schema.
/// This class organizes errors into two categories: root-level errors without field paths and
/// field-specific errors organized by their GraphQL paths using an <see cref="ErrorTrie"/>.
/// </summary>
public sealed class SourceSchemaErrors
{
/// <summary>
/// Gets the collection of errors that are not associated with specific GraphQL field paths.
/// </summary>
public required ImmutableArray<IError> RootErrors { get; init; }

/// <summary>
/// Gets the trie structure containing errors organized by their GraphQL field paths.
/// </summary>
public required ErrorTrie Trie { get; init; }

/// <summary>
/// Creates a <see cref="SourceSchemaErrors"/> instance from a JSON array of GraphQL errors.
/// </summary>
/// <param name="json">
/// A <see cref="JsonElement"/> representing the "errors" array from a GraphQL response.
/// </param>
/// <returns>
/// A <see cref="SourceSchemaErrors"/> instance containing the parsed errors, or
/// <c>null</c> if the JSON is not a valid array format.
/// </returns>
/// <exception cref="InvalidOperationException">
/// Thrown when an error path contains unsupported element types (only strings and integer are supported).
/// </exception>
public static SourceSchemaErrors? From(JsonElement json)
{
if (json.ValueKind != JsonValueKind.Array)
{
return null;
}

ImmutableArray<IError>.Builder? rootErrors = null;
var root = new ErrorTrie();

foreach (var jsonError in json.EnumerateArray())
{
var currentTrie = root;

var error = CreateError(jsonError);

if (error is null)
{
continue;
}

if (error.Path is null)
{
rootErrors ??= ImmutableArray.CreateBuilder<IError>();
rootErrors.Add(error);
continue;
}

var rented = ArrayPool<object>.Shared.Rent(error.Path.Length);
var pathSegments = rented.AsSpan(0, error.Path.Length);
error.Path.ToList(pathSegments);
var lastPathIndex = pathSegments.Length - 1;

try
{
for (var i = 0; i < pathSegments.Length; i++)
{
var pathSegment = pathSegments[i];

if (currentTrie.TryGetValue(pathSegment, out var trieAtPath))
{
currentTrie = trieAtPath;
}
else
{
var newTrie = new ErrorTrie();
currentTrie[pathSegment] = newTrie;
currentTrie = newTrie;
}

if (i == lastPathIndex)
{
currentTrie.Error = error;
}
}
}
finally
{
pathSegments.Clear();
ArrayPool<object>.Shared.Return(rented);
}
}

return new SourceSchemaErrors { RootErrors = rootErrors?.ToImmutableArray() ?? [], Trie = root };
}

private static IError? CreateError(JsonElement jsonError)
{
if (jsonError.ValueKind is not JsonValueKind.Object)
{
return null;
}

if (jsonError.TryGetProperty("message", out var message)
&& message.ValueKind is JsonValueKind.String)
{
var errorBuilder = ErrorBuilder.New()
.SetMessage(message.GetString()!);

if (jsonError.TryGetProperty("path", out var path) && path.ValueKind == JsonValueKind.Array)
{
errorBuilder.SetPath(CreatePathFromJson(path));
}

if (jsonError.TryGetProperty("code", out var code)
&& code.ValueKind is JsonValueKind.String)
{
errorBuilder.SetCode(code.GetString());
}

if (jsonError.TryGetProperty("extensions", out var extensions)
&& extensions.ValueKind is JsonValueKind.Object)
{
foreach (var property in extensions.EnumerateObject())
{
errorBuilder.SetExtension(property.Name, property.Value);
}
}

return errorBuilder.Build();
}

return null;
}

private static Path CreatePathFromJson(JsonElement errorSubPath)
{
var path = Path.Root;

for (var i = 0; i < errorSubPath.GetArrayLength(); i++)
{
path = errorSubPath[i] switch
{
{ ValueKind: JsonValueKind.String } nameElement => path.Append(nameElement.GetString()!),
{ ValueKind: JsonValueKind.Number } indexElement => path.Append(indexElement.GetInt32()),
_ => throw new InvalidOperationException("The error path contains an unsupported element."),
};
}

return path;
}
}
Loading
Loading