Skip to content

Commit 78a8825

Browse files
[Fusion] Add null propagation and error forwarding (#8521)
1 parent f921856 commit 78a8825

File tree

48 files changed

+2584
-201
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2584
-201
lines changed

src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ namespace HotChocolate.Transport.Http;
99
/// </summary>
1010
public sealed class GraphQLHttpResponse : IDisposable
1111
{
12-
private static readonly OperationResult s_transportError = CreateTransportError();
13-
1412
private readonly HttpResponseMessage _message;
1513

1614
/// <summary>
@@ -182,21 +180,16 @@ public IAsyncEnumerable<OperationResult> ReadAsResultStreamAsync()
182180
ct => ReadAsResultInternalAsync(contentType.CharSet, ct));
183181
}
184182

185-
return SingleResult(new ValueTask<OperationResult>(s_transportError));
183+
_message.EnsureSuccessStatusCode();
184+
185+
throw new InvalidOperationException("Received a successful response with an unexpected content type.");
186186
}
187187

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

193-
private static OperationResult CreateTransportError()
194-
=> new OperationResult(
195-
errors: JsonDocument.Parse(
196-
"""
197-
[{"message": "Internal Execution Error"}]
198-
""").RootElement);
199-
200193
/// <summary>
201194
/// Disposes the underlying <see cref="HttpResponseMessage"/>.
202195
/// </summary>

src/HotChocolate/Core/src/Execution.Abstractions/Path.cs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,62 @@ public Path Append(int index)
6767
return new IndexerPathSegment(this, index);
6868
}
6969

70+
/// <summary>
71+
/// Appends another path to this path.
72+
/// </summary>
73+
/// <param name="path">
74+
/// The other path.
75+
/// </param>
76+
/// <returns>
77+
/// the combined path.
78+
/// </returns>
79+
public Path Append(Path path)
80+
{
81+
ArgumentNullException.ThrowIfNull(path);
82+
83+
if (path.IsRoot)
84+
{
85+
return this;
86+
}
87+
88+
var stack = new Stack<object>();
89+
var current = path;
90+
91+
while (!current.IsRoot)
92+
{
93+
switch (current)
94+
{
95+
case IndexerPathSegment indexer:
96+
stack.Push(indexer.Index);
97+
break;
98+
99+
case NamePathSegment name:
100+
stack.Push(name.Name);
101+
break;
102+
103+
default:
104+
throw new NotSupportedException("Unsupported path segment type.");
105+
}
106+
107+
current = current.Parent;
108+
}
109+
110+
var newPath = this;
111+
112+
while (stack.Count > 0)
113+
{
114+
var segment = stack.Pop();
115+
newPath = segment switch
116+
{
117+
string name => newPath.Append(name),
118+
int index => newPath.Append(index),
119+
_ => throw new NotSupportedException("Unsupported path segment type.")
120+
};
121+
}
122+
123+
return newPath;
124+
}
125+
70126
/// <summary>
71127
/// Generates a string that represents the current path.
72128
/// </summary>
@@ -147,6 +203,49 @@ public IReadOnlyList<object> ToList()
147203
return stack;
148204
}
149205

206+
/// <summary>
207+
/// Creates a new list representing the current <see cref="Path"/>.
208+
/// </summary>
209+
/// <returns>
210+
/// Returns a new list representing the current <see cref="Path"/>.
211+
/// </returns>
212+
public void ToList(Span<object> path)
213+
{
214+
if (IsRoot)
215+
{
216+
return;
217+
}
218+
219+
if (path.Length < Length)
220+
{
221+
throw new ArgumentException(
222+
"The path span mustn't be smaller than the length of the path.",
223+
nameof(path));
224+
}
225+
226+
var current = this;
227+
var length = path.Length;
228+
229+
while (!current.IsRoot)
230+
{
231+
switch (current)
232+
{
233+
case IndexerPathSegment indexer:
234+
path[--length] = indexer.Index;
235+
break;
236+
237+
case NamePathSegment name:
238+
path[--length] = name.Name;
239+
break;
240+
241+
default:
242+
throw new NotSupportedException();
243+
}
244+
245+
current = current.Parent;
246+
}
247+
}
248+
150249
/// <summary>Returns a string that represents the current <see cref="Path"/>.</summary>
151250
/// <returns>A string that represents the current <see cref="Path"/>.</returns>
152251
public override string ToString() => Print();
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
namespace HotChocolate.Fusion.Execution.Clients;
2+
3+
/// <summary>
4+
/// A trie (prefix tree) data structure for efficiently organizing and retrieving GraphQL errors
5+
/// based on their field paths. The <see cref="ErrorTrie"/> enables fast error lookups and
6+
/// maintains the hierarchical structure of GraphQL error paths in a federated GraphQL request.
7+
/// </summary>
8+
/// <remarks>
9+
/// <para>
10+
/// Each node in the trie represents a path segment (field name or list index) and can
11+
/// optionally contain an error. In GraphQL each path can have a single error.
12+
/// The tree structure mirrors the GraphQL response structure,
13+
/// allowing errors to be precisely located and retrieved.
14+
/// </para>
15+
/// <example>
16+
///
17+
/// For a GraphQL query structure like:
18+
/// <code>
19+
/// query {
20+
/// user {
21+
/// posts {
22+
/// title
23+
/// comments {
24+
/// text
25+
/// }
26+
/// }
27+
/// }
28+
/// }
29+
/// </code>
30+
///
31+
/// An error at path ["user", "posts", 0, "title"] would create the trie structure:
32+
/// <code>
33+
/// root["user"]["posts"][0]["title"] = error
34+
/// </code>
35+
/// </example>
36+
/// </remarks>
37+
public sealed class ErrorTrie : Dictionary<object, ErrorTrie>
38+
{
39+
/// <summary>
40+
/// Gets or sets the error associated with this node in the trie.
41+
/// </summary>
42+
/// <value>
43+
/// The <see cref="IError"/> instance if an error exists at this path,
44+
/// otherwise <c>null</c>.
45+
/// </value>
46+
/// <remarks>
47+
/// An error is stored at the final node of a path. Intermediate nodes
48+
/// typically have a <c>null</c> error value and serve as path segments
49+
/// leading to the actual error location.
50+
/// </remarks>
51+
public IError? Error { get; set; }
52+
53+
/// <summary>
54+
/// Finds and returns the first error encountered in the trie using depth-first traversal.
55+
/// </summary>
56+
public IError? FindFirstError()
57+
{
58+
if (Error is not null)
59+
{
60+
return Error;
61+
}
62+
63+
var stack = new Stack<ErrorTrie>(Values);
64+
65+
while (stack.TryPop(out var errorTrie))
66+
{
67+
if (errorTrie.Error is not null)
68+
{
69+
return errorTrie.Error;
70+
}
71+
72+
foreach (var child in errorTrie.Values)
73+
{
74+
stack.Push(child);
75+
}
76+
}
77+
78+
return null;
79+
}
80+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using System.Buffers;
2+
using System.Collections.Immutable;
3+
using System.Text.Json;
4+
5+
namespace HotChocolate.Fusion.Execution.Clients;
6+
7+
/// <summary>
8+
/// Represents the collection of errors returned from a specific source schema.
9+
/// This class organizes errors into two categories: root-level errors without field paths and
10+
/// field-specific errors organized by their GraphQL paths using an <see cref="ErrorTrie"/>.
11+
/// </summary>
12+
public sealed class SourceSchemaErrors
13+
{
14+
/// <summary>
15+
/// Gets the collection of errors that are not associated with specific GraphQL field paths.
16+
/// </summary>
17+
public required ImmutableArray<IError> RootErrors { get; init; }
18+
19+
/// <summary>
20+
/// Gets the trie structure containing errors organized by their GraphQL field paths.
21+
/// </summary>
22+
public required ErrorTrie Trie { get; init; }
23+
24+
/// <summary>
25+
/// Creates a <see cref="SourceSchemaErrors"/> instance from a JSON array of GraphQL errors.
26+
/// </summary>
27+
/// <param name="json">
28+
/// A <see cref="JsonElement"/> representing the "errors" array from a GraphQL response.
29+
/// </param>
30+
/// <returns>
31+
/// A <see cref="SourceSchemaErrors"/> instance containing the parsed errors, or
32+
/// <c>null</c> if the JSON is not a valid array format.
33+
/// </returns>
34+
/// <exception cref="InvalidOperationException">
35+
/// Thrown when an error path contains unsupported element types (only strings and integer are supported).
36+
/// </exception>
37+
public static SourceSchemaErrors? From(JsonElement json)
38+
{
39+
if (json.ValueKind != JsonValueKind.Array)
40+
{
41+
return null;
42+
}
43+
44+
ImmutableArray<IError>.Builder? rootErrors = null;
45+
var root = new ErrorTrie();
46+
47+
foreach (var jsonError in json.EnumerateArray())
48+
{
49+
var currentTrie = root;
50+
51+
var error = CreateError(jsonError);
52+
53+
if (error is null)
54+
{
55+
continue;
56+
}
57+
58+
if (error.Path is null)
59+
{
60+
rootErrors ??= ImmutableArray.CreateBuilder<IError>();
61+
rootErrors.Add(error);
62+
continue;
63+
}
64+
65+
var rented = ArrayPool<object>.Shared.Rent(error.Path.Length);
66+
var pathSegments = rented.AsSpan(0, error.Path.Length);
67+
error.Path.ToList(pathSegments);
68+
var lastPathIndex = pathSegments.Length - 1;
69+
70+
try
71+
{
72+
for (var i = 0; i < pathSegments.Length; i++)
73+
{
74+
var pathSegment = pathSegments[i];
75+
76+
if (currentTrie.TryGetValue(pathSegment, out var trieAtPath))
77+
{
78+
currentTrie = trieAtPath;
79+
}
80+
else
81+
{
82+
var newTrie = new ErrorTrie();
83+
currentTrie[pathSegment] = newTrie;
84+
currentTrie = newTrie;
85+
}
86+
87+
if (i == lastPathIndex)
88+
{
89+
currentTrie.Error = error;
90+
}
91+
}
92+
}
93+
finally
94+
{
95+
pathSegments.Clear();
96+
ArrayPool<object>.Shared.Return(rented);
97+
}
98+
}
99+
100+
return new SourceSchemaErrors { RootErrors = rootErrors?.ToImmutableArray() ?? [], Trie = root };
101+
}
102+
103+
private static IError? CreateError(JsonElement jsonError)
104+
{
105+
if (jsonError.ValueKind is not JsonValueKind.Object)
106+
{
107+
return null;
108+
}
109+
110+
if (jsonError.TryGetProperty("message", out var message)
111+
&& message.ValueKind is JsonValueKind.String)
112+
{
113+
var errorBuilder = ErrorBuilder.New()
114+
.SetMessage(message.GetString()!);
115+
116+
if (jsonError.TryGetProperty("path", out var path) && path.ValueKind == JsonValueKind.Array)
117+
{
118+
errorBuilder.SetPath(CreatePathFromJson(path));
119+
}
120+
121+
if (jsonError.TryGetProperty("code", out var code)
122+
&& code.ValueKind is JsonValueKind.String)
123+
{
124+
errorBuilder.SetCode(code.GetString());
125+
}
126+
127+
if (jsonError.TryGetProperty("extensions", out var extensions)
128+
&& extensions.ValueKind is JsonValueKind.Object)
129+
{
130+
foreach (var property in extensions.EnumerateObject())
131+
{
132+
errorBuilder.SetExtension(property.Name, property.Value);
133+
}
134+
}
135+
136+
return errorBuilder.Build();
137+
}
138+
139+
return null;
140+
}
141+
142+
private static Path CreatePathFromJson(JsonElement errorSubPath)
143+
{
144+
var path = Path.Root;
145+
146+
for (var i = 0; i < errorSubPath.GetArrayLength(); i++)
147+
{
148+
path = errorSubPath[i] switch
149+
{
150+
{ ValueKind: JsonValueKind.String } nameElement => path.Append(nameElement.GetString()!),
151+
{ ValueKind: JsonValueKind.Number } indexElement => path.Append(indexElement.GetInt32()),
152+
_ => throw new InvalidOperationException("The error path contains an unsupported element."),
153+
};
154+
}
155+
156+
return path;
157+
}
158+
}

0 commit comments

Comments
 (0)