diff --git a/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs b/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs index 8d7efb4194d..9ffab1a6a8e 100644 --- a/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs +++ b/src/HotChocolate/AspNetCore/src/Transport.Http/GraphQLHttpResponse.cs @@ -9,8 +9,6 @@ namespace HotChocolate.Transport.Http; /// public sealed class GraphQLHttpResponse : IDisposable { - private static readonly OperationResult s_transportError = CreateTransportError(); - private readonly HttpResponseMessage _message; /// @@ -182,7 +180,9 @@ public IAsyncEnumerable ReadAsResultStreamAsync() ct => ReadAsResultInternalAsync(contentType.CharSet, ct)); } - return SingleResult(new ValueTask(s_transportError)); + _message.EnsureSuccessStatusCode(); + + throw new InvalidOperationException("Received a successful response with an unexpected content type."); } private static async IAsyncEnumerable SingleResult(ValueTask result) @@ -190,13 +190,6 @@ private static async IAsyncEnumerable SingleResult(ValueTask new OperationResult( - errors: JsonDocument.Parse( - """ - [{"message": "Internal Execution Error"}] - """).RootElement); - /// /// Disposes the underlying . /// diff --git a/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs b/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs index 49b5f4e6f33..e5bf18143ad 100644 --- a/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs +++ b/src/HotChocolate/Core/src/Execution.Abstractions/Path.cs @@ -67,6 +67,62 @@ public Path Append(int index) return new IndexerPathSegment(this, index); } + /// + /// Appends another path to this path. + /// + /// + /// The other path. + /// + /// + /// the combined path. + /// + public Path Append(Path path) + { + ArgumentNullException.ThrowIfNull(path); + + if (path.IsRoot) + { + return this; + } + + var stack = new Stack(); + 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; + } + /// /// Generates a string that represents the current path. /// diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/ErrorTrie.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/ErrorTrie.cs new file mode 100644 index 00000000000..c98ff392d58 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/ErrorTrie.cs @@ -0,0 +1,38 @@ +namespace HotChocolate.Fusion.Execution.Clients; + +public sealed class ErrorTrie : Dictionary +{ + public IError? Error { get; set; } + + public (object[] Path, IError Error)? FindPathToFirstError() + { + if (Error is not null) + { + return ([], Error); + } + + var stack = new Stack<(ErrorTrie Node, List Path)>(); + + foreach (var kvp in this) + { + stack.Push((kvp.Value, [kvp.Key])); + } + + while (stack.Count > 0) + { + var (node, path) = stack.Pop(); + + if (node.Error is not null) + { + return ([..path], node.Error); + } + + foreach (var kvp in node) + { + stack.Push((kvp.Value, [..path, kvp.Key])); + } + } + + return null; + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaError.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaError.cs new file mode 100644 index 00000000000..c1b917bf625 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaError.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Fusion.Execution.Clients; + +public sealed class SourceSchemaError(IError error, Path path) +{ + public IError Error { get; } = error; + + public Path Path { get; } = path; +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaErrors.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaErrors.cs new file mode 100644 index 00000000000..036f4b5ef53 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaErrors.cs @@ -0,0 +1,125 @@ +using System.Text.Json; + +namespace HotChocolate.Fusion.Execution.Clients; + +public sealed class SourceSchemaErrors +{ + /// + /// Errors without a path. + /// + public required List? RootErrors { get; init; } + + public required ErrorTrie Trie { get; init; } + + public static SourceSchemaErrors? From(JsonElement json) + { + if (json.ValueKind != JsonValueKind.Array) + { + return null; + } + + List? rootErrors = null; + ErrorTrie 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 ??= []; + rootErrors.Add(error); + continue; + } + + var pathSegments = error.Path.ToList(); + var lastPathIndex = pathSegments.Count - 1; + + for (var i = 0; i < pathSegments.Count; 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; + } + } + } + + return new SourceSchemaErrors { RootErrors = rootErrors, 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; + } +} diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaResult.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaResult.cs index fe79445a7ee..22eaea55611 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaResult.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Clients/SourceSchemaResult.cs @@ -6,38 +6,6 @@ public sealed class SourceSchemaResult : IDisposable { private readonly IDisposable? _resource; - public SourceSchemaResult(Path path, JsonDocument document, FinalMessage final = FinalMessage.Undefined) - { - ArgumentNullException.ThrowIfNull(path); - ArgumentNullException.ThrowIfNull(document); - - _resource = document; - Path = path; - - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object) - { - return; - } - - if (root.TryGetProperty("data", out var data)) - { - Data = data; - } - - if (root.TryGetProperty("errors", out var errors)) - { - Errors = errors; - } - - if (root.TryGetProperty("extensions", out var extensions)) - { - Extensions = extensions; - } - - Final = final; - } - public SourceSchemaResult( Path path, IDisposable resource, @@ -52,7 +20,7 @@ public SourceSchemaResult( _resource = resource; Path = path; Data = data; - Errors = errors; + Errors = SourceSchemaErrors.From(errors); Extensions = extensions; Final = final; } @@ -61,7 +29,7 @@ public SourceSchemaResult( public JsonElement Data { get; } - public JsonElement Errors { get; } + public SourceSchemaErrors? Errors { get; } public JsonElement Extensions { get; } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs index d4bba9f60d6..7962c35c9d3 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs @@ -55,8 +55,14 @@ public OperationExecutionNode( } _variables = variables.ToArray(); + + // TODO: This might include requirements that we wouldn't want to create a front-end facing error for + _responseNames = GetResponseNamesFromPath(operation, source); } + // TODO: Move + private readonly ImmutableArray _responseNames; + public override int Id { get; } /// @@ -139,15 +145,22 @@ private async Task ExecuteInternalAsync( }; var client = context.GetClient(SchemaName, Operation.Operation); - var response = await client.ExecuteAsync(context, request, cancellationToken); + SourceSchemaClientResponse response; - if (!response.IsSuccessful) + try { + response = await client.ExecuteAsync(context, request, cancellationToken); + } + catch (Exception exception) + { + AddErrors(context, exception, variables, _responseNames); + return new ExecutionNodeResult( Id, Activity.Current, ExecutionStatus.Failed, - Stopwatch.GetElapsedTime(start)); + Stopwatch.GetElapsedTime(start), + exception); } var index = 0; @@ -163,7 +176,7 @@ private async Task ExecuteInternalAsync( context.AddPartialResults(Source, buffer.AsSpan(0, index)); } - catch + catch (Exception exception) { // if there is an error, we need to make sure that the pooled buffers for the JsonDocuments // are returned to the pool. @@ -173,7 +186,14 @@ private async Task ExecuteInternalAsync( result?.Dispose(); } - throw; + AddErrors(context, exception, variables, _responseNames); + + return new ExecutionNodeResult( + Id, + Activity.Current, + ExecutionStatus.Failed, + Stopwatch.GetElapsedTime(start), + exception); } finally { @@ -292,6 +312,109 @@ protected internal override void Seal() _isSealed = true; } + private static ImmutableArray GetResponseNamesFromPath( + OperationDefinitionNode operationDefinition, + SelectionPath path) + { + var selectionSet = GetSelectionSetNodeFromPath(operationDefinition, path); + + if (selectionSet is null) + { + return []; + } + + var responseNames = new List(); + + foreach (var selection in selectionSet.Selections) + { + // TODO: We need to handle InlineFragmentNodes here + if (selection is FieldNode fieldNode) + { + responseNames.Add(fieldNode.Alias?.Value ?? fieldNode.Name.Value); + } + } + + return [..responseNames]; + } + + private static SelectionSetNode? GetSelectionSetNodeFromPath(OperationDefinitionNode operationDefinition, SelectionPath path) + { + var current = operationDefinition.SelectionSet; + + if (path.IsRoot) + { + return current; + } + + for (var i = path.Segments.Length - 1; i >= 0; i--) + { + var segment = path.Segments[i]; + + if (segment.Kind == SelectionPathSegmentKind.InlineFragment) + { + // TODO: Do we need to handle the case without a type condition? + var selection = current.Selections + .OfType() + .FirstOrDefault(s => s.TypeCondition?.Name.Value == segment.Name); + + if (selection is null) + { + return null; + } + + current = selection.SelectionSet; + } + else if (segment.Kind is SelectionPathSegmentKind.Field) + { + var selection = current.Selections + .OfType() + .FirstOrDefault(s => s.Alias?.Value == segment.Name || s.Name.Value == segment.Name); + + if (selection?.SelectionSet is null) + { + return null; + } + current = selection.SelectionSet; + } + } + + return current; + } + + private static void AddErrors( + OperationPlanContext context, + Exception exception, + ImmutableArray variables, + ImmutableArray responseNames) + { + var bufferLength = Math.Max(variables.Length, 1); + var buffer = ArrayPool.Shared.Rent(bufferLength); + + try + { + var error = ErrorBuilder.FromException(exception).Build(); + + if (variables.Length == 0) + { + buffer[0] = new SourceSchemaError(error, Path.Root); + } + else + { + for (var i = 0; i < variables.Length; i++) + { + buffer[i] = new SourceSchemaError(error, variables[i].Path); + } + } + + context.AddErrors(buffer.AsSpan(0, bufferLength), responseNames.AsSpan()); + } + finally + { + buffer.AsSpan(0, bufferLength).Clear(); + ArrayPool.Shared.Return(buffer); + } + } + private sealed class SubscriptionEnumerable : IAsyncEnumerable { private readonly OperationPlanContext _context; diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/Selection.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/Selection.cs index 3ea38955fe7..bd81ccc2a12 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/Selection.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Nodes/Selection.cs @@ -100,6 +100,11 @@ public bool IsIncluded(ulong includeFlags) return false; } + public override string ToString() + { + return Field.Name; + } + internal void Seal(SelectionSet selectionSet) { if ((_flags & Flags.Sealed) == Flags.Sealed) diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs index 2749e47b150..f344421ada5 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -105,6 +105,9 @@ internal void AddPartialResults(SelectionPath sourcePath, ReadOnlySpan selections) => _resultStore.AddPartialResults(result, selections); + internal void AddErrors(ReadOnlySpan errors, ReadOnlySpan responseNames) + => _resultStore.AddErrors(errors, responseNames); + internal PooledArrayWriter CreateRentedBuffer() => _resultStore.CreateRentedBuffer(); @@ -141,7 +144,7 @@ internal IOperationResult Complete() var result = resultBuilder .AddErrors(_resultStore.Errors) - .SetData(_resultStore.Data) + .SetData(_resultStore.Data.IsInvalidated ? null : _resultStore.Data) .RegisterForCleanup(_resultStore.MemoryOwners) .RegisterForCleanup(_resultPoolSessionHolder) .Build(); diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Pipeline/OperationPlanMiddleware.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Pipeline/OperationPlanMiddleware.cs index a4cd302a272..f2e83d1defa 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Pipeline/OperationPlanMiddleware.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Pipeline/OperationPlanMiddleware.cs @@ -1,7 +1,6 @@ using HotChocolate.Execution; using HotChocolate.Fusion.Planning; using HotChocolate.Fusion.Rewriters; -using HotChocolate.Language; using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Fusion.Execution.Pipeline; @@ -39,26 +38,11 @@ public ValueTask InvokeAsync(RequestContext context, RequestDelegate next) var operationHash = context.OperationDocumentInfo.Hash.Value; var operationShortHash = operationHash[..8]; var rewritten = _rewriter.RewriteDocument(operationDocumentInfo.Document, context.Request.OperationName); - var operation = GetOperation(rewritten); + var operation = rewritten.GetOperation(context.Request.OperationName); var executionPlan = _planner.CreatePlan(operationId, operationHash, operationShortHash, operation); context.SetOperationPlan(executionPlan); return next(context); - - // TODO: this algorithm is wrong and will fail with multiple operations. - static OperationDefinitionNode GetOperation(DocumentNode document) - { - for (var i = 0; i < document.Definitions.Count; i++) - { - if (document.Definitions[i] is OperationDefinitionNode operation) - { - return operation; - } - } - - throw new InvalidOperationException( - "The operation document does not contain an operation definition."); - } } public static RequestMiddleware Create() diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index 5c1da4b5368..51cfbdf4a8b 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -14,17 +14,17 @@ namespace HotChocolate.Fusion.Execution; -// we must make this thread-safe +// TODO: we must make this thread-safe internal sealed class FetchResultStore : IDisposable { private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion); private readonly ISchemaDefinition _schema; private readonly Operation _operation; private readonly ulong _includeFlags; - private readonly ImmutableArray _errors = []; private readonly ConcurrentStack _memory = []; private ObjectResult _root = null!; private ValueCompletion _valueCompletion = null!; + private List _errors = null!; private bool _disposed; public FetchResultStore( @@ -47,15 +47,22 @@ public FetchResultStore( public void Reset(ResultPoolSession resultPoolSession) { ObjectDisposedException.ThrowIf(_disposed, this); - - _valueCompletion = new ValueCompletion(_schema, resultPoolSession, ErrorHandling.Propagate, 32, _includeFlags); _root = resultPoolSession.RentObjectResult(); _root.Initialize(resultPoolSession, _operation.RootSelectionSet, _includeFlags); + _errors = []; + + _valueCompletion = new ValueCompletion( + _schema, + resultPoolSession, + ErrorHandling.Propagate, + 32, + _includeFlags, + _errors); } public ObjectResult Data => _root; - public ImmutableArray Errors => _errors; + public List Errors => _errors; public ConcurrentStack MemoryOwners => _memory; @@ -73,13 +80,16 @@ public bool AddPartialResults( nameof(results)); } - var startElements = ArrayPool.Shared.Rent(results.Length); - var startElementsSpan = startElements.AsSpan()[..results.Length]; + var dataElements = ArrayPool.Shared.Rent(results.Length); + var errorTries = ArrayPool.Shared.Rent(results.Length); + var dataElementsSpan = dataElements.AsSpan()[..results.Length]; + var errorTriesSpan = errorTries.AsSpan()[..results.Length]; try { ref var result = ref MemoryMarshal.GetReference(results); - ref var startElement = ref MemoryMarshal.GetReference(startElementsSpan); + ref var dataElement = ref MemoryMarshal.GetReference(dataElementsSpan); + ref var errorTrie = ref MemoryMarshal.GetReference(errorTriesSpan); ref var end = ref Unsafe.Add(ref result, results.Length); while (Unsafe.IsAddressLessThan(ref result, ref end)) @@ -87,17 +97,62 @@ public bool AddPartialResults( // we need to track the result objects as they used rented memory. _memory.Push(result); - startElement = GetStartElement(sourcePath, result.Data); + if (result.Errors?.RootErrors is { } rootErrors) + { + _errors.AddRange(rootErrors); + } + + dataElement = GetDataElement(sourcePath, result.Data); + errorTrie = GetErrorTrie(sourcePath, result.Errors?.Trie); + result = ref Unsafe.Add(ref result, 1)!; - startElement = ref Unsafe.Add(ref startElement, 1); + dataElement = ref Unsafe.Add(ref dataElement, 1); + errorTrie = ref Unsafe.Add(ref errorTrie, 1)!; } - return SaveSafe(results, startElementsSpan); + return SaveSafe(results, dataElementsSpan, errorTriesSpan); + } + finally + { + ArrayPool.Shared.Return(dataElements); + ArrayPool.Shared.Return(errorTries); + } + } + + public bool AddErrors(ReadOnlySpan errors, ReadOnlySpan responseNames) + { + _lock.EnterWriteLock(); + + try + { + ref var error = ref MemoryMarshal.GetReference(errors); + ref var end = ref Unsafe.Add(ref error, errors.Length); + + while (Unsafe.IsAddressLessThan(ref error, ref end)) + { + if (_root.IsInvalidated) + { + return false; + } + + var result = error.Path.IsRoot ? _root : GetStartObjectResult(error.Path); + + if (result.IsInvalidated) + { + continue; + } + + _valueCompletion.BuildResult(result, responseNames, error); + + error = ref Unsafe.Add(ref error, 1)!; + } } finally { - ArrayPool.Shared.Return(startElements); + _lock.ExitWriteLock(); } + + return true; } public void AddPartialResults(ObjectResult result, ReadOnlySpan selections) @@ -134,14 +189,16 @@ public void AddPartialResults(ObjectResult result, ReadOnlySpan selec private bool SaveSafe( ReadOnlySpan results, - ReadOnlySpan startElements) + ReadOnlySpan dataElements, + ReadOnlySpan errorTries) { _lock.EnterWriteLock(); try { ref var result = ref MemoryMarshal.GetReference(results); - ref var startElement = ref MemoryMarshal.GetReference(startElements); + ref var data = ref MemoryMarshal.GetReference(dataElements); + ref var errorTrie = ref MemoryMarshal.GetReference(errorTries); ref var end = ref Unsafe.Add(ref result, results.Length); while (Unsafe.IsAddressLessThan(ref result, ref end)) @@ -149,7 +206,7 @@ private bool SaveSafe( if (result.Path.IsRoot) { var selectionSet = _operation.RootSelectionSet; - if (!_valueCompletion.BuildResult(selectionSet, result, startElement, _root)) + if (!_valueCompletion.BuildResult(selectionSet, data, errorTrie, _root)) { return false; } @@ -157,14 +214,15 @@ private bool SaveSafe( else { var startResult = GetStartObjectResult(result.Path); - if (!_valueCompletion.BuildResult(startResult.SelectionSet, result, startElement, startResult)) + if (!_valueCompletion.BuildResult(startResult.SelectionSet, data, errorTrie, startResult)) { return false; } } result = ref Unsafe.Add(ref result, 1)!; - startElement = ref Unsafe.Add(ref startElement, 1); + data = ref Unsafe.Add(ref data, 1); + errorTrie = ref Unsafe.Add(ref errorTrie, 1)!; } } finally @@ -368,7 +426,7 @@ public PooledArrayWriter CreateRentedBuffer() return buffer; } - private static JsonElement GetStartElement(SelectionPath sourcePath, JsonElement data) + private static JsonElement GetDataElement(SelectionPath sourcePath, JsonElement data) { if (sourcePath.IsRoot) { @@ -382,8 +440,29 @@ private static JsonElement GetStartElement(SelectionPath sourcePath, JsonElement var segment = sourcePath.Segments[i]; if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment.Name, out current)) { - throw new InvalidOperationException( - $"The path segment '{segment.Name}' does not exist in the data."); + return new JsonElement(); // TODO: Is this bad? + } + } + + return current; + } + + private static ErrorTrie? GetErrorTrie(SelectionPath sourcePath, ErrorTrie? errors) + { + if (errors is null || sourcePath.IsRoot) + { + return errors; + } + + var current = errors; + + for (var i = sourcePath.Segments.Length - 1; i >= 0; i--) + { + var segment = sourcePath.Segments[i]; + + if (!current.TryGetValue(segment.Name, out current)) + { + return null; } } diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/NestedListResult.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/NestedListResult.cs index 7373a01e7ed..9808087bec4 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/NestedListResult.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/NestedListResult.cs @@ -1,5 +1,6 @@ using System.Text.Json; using HotChocolate.Execution; +using HotChocolate.Types; namespace HotChocolate.Fusion.Execution; @@ -30,6 +31,14 @@ public override void SetNextValueNull() Items.Add(null); } + /// + public override bool TrySetValueNull(int index) + { + Items[index] = null; + + return !ElementType.IsNonNullType(); + } + /// /// Adds a list to this list. /// diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ObjectListResult.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ObjectListResult.cs index b3dfee17881..5d978d59c38 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ObjectListResult.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ObjectListResult.cs @@ -1,5 +1,6 @@ using System.Text.Json; using HotChocolate.Execution; +using HotChocolate.Types; namespace HotChocolate.Fusion.Execution; @@ -30,6 +31,14 @@ public override void SetNextValueNull() Items.Add(null); } + /// + public override bool TrySetValueNull(int index) + { + Items[index] = null; + + return !ElementType.IsNonNullType(); + } + /// /// Adds the given value to the list. /// diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ObjectResult.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ObjectResult.cs index 697f0f14164..444f18627f6 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ObjectResult.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ObjectResult.cs @@ -72,6 +72,15 @@ internal void MoveFieldTo(string fieldName, ObjectResult target) target._fieldMap[fieldName] = field; } + /// + public override bool TrySetValueNull(int index) + { + var field = _fields[index]; + field.SetNextValueNull(); + + return !field.Selection.Type.IsNonNullType(); + } + /// /// Writes the object result to the specified JSON writer. /// diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ResultData.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ResultData.cs index 9a4098f3891..7e157911d31 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ResultData.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ResultData.cs @@ -21,6 +21,8 @@ public abstract class ResultData : IResultDataJsonFormatter /// protected internal int ParentIndex { get; private set; } + public bool IsInvalidated { get; set; } + /// /// Gets the path of the result. /// diff --git a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ValueCompletion.cs b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ValueCompletion.cs index 67dabea911a..b1e40dab5af 100644 --- a/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ValueCompletion.cs +++ b/src/HotChocolate/Fusion-vnext/src/Fusion.Execution/Execution/Results/ValueCompletion.cs @@ -2,6 +2,7 @@ using System.Text.Json; using HotChocolate.Fusion.Execution.Clients; using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Language; using HotChocolate.Types; namespace HotChocolate.Fusion.Execution; @@ -13,31 +14,56 @@ internal sealed class ValueCompletion private readonly ErrorHandling _errorHandling; private readonly int _maxDepth; private readonly ulong _includeFlags; + private readonly List _errors; public ValueCompletion( ISchemaDefinition schema, ResultPoolSession resultPoolSession, ErrorHandling errorHandling, int maxDepth, - ulong includeFlags) + ulong includeFlags, + List errors) { ArgumentNullException.ThrowIfNull(schema); ArgumentNullException.ThrowIfNull(resultPoolSession); + ArgumentNullException.ThrowIfNull(errors); _schema = schema; _resultPoolSession = resultPoolSession; - _includeFlags = includeFlags; - _maxDepth = maxDepth; _errorHandling = errorHandling; + _maxDepth = maxDepth; + _includeFlags = includeFlags; + _errors = errors; } public bool BuildResult( SelectionSet selectionSet, - SourceSchemaResult sourceSchemaResult, JsonElement data, + ErrorTrie? errorTrie, ObjectResult objectResult) { - // we need to validate the data and create a GraphQL error if its not an object. + if (data is not { ValueKind: JsonValueKind.Object }) + { + // If we encounter a null, we check if there's an error on this field + // or somewhere below. If there is, we add the error, since it likely + // propagated and erased the current field result. + if (errorTrie?.FindPathToFirstError() is { } pathToFirstError) + { + var path = Path.FromList(pathToFirstError.Path); + + var errorWithPath = ErrorBuilder.FromError(pathToFirstError.Error) + .SetPath(objectResult.Path.Append(path)) + + // We should add the location here, but not sure if it's worth it to iterate the + // selections for it. + .Build(); + + _errors.Add(errorWithPath); + } + + return false; + } + foreach (var selection in selectionSet.Selections) { if (!selection.IsIncluded(_includeFlags)) @@ -47,47 +73,89 @@ public bool BuildResult( var fieldResult = objectResult[selection.ResponseName]; - if (data.TryGetProperty(selection.ResponseName, out var element) - && !TryCompleteValue(selection, selection.Type, sourceSchemaResult, element, 0, fieldResult)) + if (data.TryGetProperty(selection.ResponseName, out var element)) { - var parentIndex = fieldResult.ParentIndex; - var parent = fieldResult.Parent; + ErrorTrie? errorTrieForResponseName = null; + errorTrie?.TryGetValue(selection.ResponseName, out errorTrieForResponseName); - if (_errorHandling is ErrorHandling.Propagate) + if (!TryCompleteValue(selection, selection.Type, element, errorTrieForResponseName, 0, fieldResult)) { - while (parent is not null) + if (_errorHandling is ErrorHandling.Propagate) { - if (parent.TrySetValueNull(parentIndex)) - { - break; - } - else - { - parentIndex = parent.ParentIndex; - parent = parent.Parent; - - if (parent is null) - { - return false; - } - } + PropagateNullValues(objectResult); } } - else - { - fieldResult?.TrySetValueNull(parentIndex); - } } } return true; } + public void BuildResult( + ObjectResult objectResult, + ReadOnlySpan responseNames, + SourceSchemaError sourceSchemaError) + { + foreach (var responseName in responseNames) + { + var fieldResult = objectResult[responseName]; + + if (!fieldResult.Selection.IsIncluded(_includeFlags)) + { + continue; + } + + var error = ErrorBuilder.FromError(sourceSchemaError.Error) + .SetPath(sourceSchemaError.Path.Append(responseName)) + .AddLocation(fieldResult.Selection.SyntaxNodes[0].Node) + .Build(); + + _errors.Add(error); + + if (_errorHandling is ErrorHandling.Propagate && fieldResult.Selection.Type.IsNonNullType()) + { + PropagateNullValues(objectResult); + + return; + } + } + } + + private static void PropagateNullValues(ResultData result) + { + if (result.IsInvalidated) + { + return; + } + + result.IsInvalidated = true; + + while (result.Parent is not null) + { + var index = result.ParentIndex; + var parent = result.Parent; + + if (parent.IsInvalidated) + { + return; + } + + if (parent.TrySetValueNull(index)) + { + return; + } + + parent.IsInvalidated = true; + + result = parent; + } + } + private bool TryCompleteValue( Selection selection, IType type, - SourceSchemaResult sourceSchemaResult, JsonElement data, + ErrorTrie? errorTrie, int depth, ResultData parent) { @@ -96,6 +164,14 @@ private bool TryCompleteValue( if (data.IsNullOrUndefined() && _errorHandling is ErrorHandling.Propagate) { parent.SetNextValueNull(); + if (errorTrie?.Error is { } error) + { + var errorWithPath = ErrorBuilder.FromError(error) + .SetPath(parent.Path) + .AddLocation(selection.SyntaxNodes[0].Node) + .Build(); + _errors.Add(errorWithPath); + } return false; } @@ -105,22 +181,30 @@ private bool TryCompleteValue( if (data.IsNullOrUndefined()) { parent.SetNextValueNull(); + if (errorTrie?.Error is { } error) + { + var errorWithPath = ErrorBuilder.FromError(error) + .SetPath(parent.Path) + .AddLocation(selection.SyntaxNodes[0].Node) + .Build(); + _errors.Add(errorWithPath); + } return true; } if (type.Kind is TypeKind.List) { - return TryCompleteList(selection, type, sourceSchemaResult, data, depth, parent); + return TryCompleteList(selection, type, data, errorTrie, depth, parent); } if (type.Kind is TypeKind.Object) { - return TryCompleteObjectValue(selection, type, sourceSchemaResult, data, depth, parent); + return TryCompleteObjectValue(selection, type, data, errorTrie, depth, parent); } if (type.Kind is TypeKind.Interface or TypeKind.Union) { - return TryCompleteAbstractValue(selection, type, sourceSchemaResult, data, depth, parent); + return TryCompleteAbstractValue(selection, type, data, errorTrie, depth, parent); } if (type.Kind is TypeKind.Scalar or TypeKind.Enum) @@ -135,8 +219,8 @@ private bool TryCompleteValue( private bool TryCompleteList( Selection selection, IType type, - SourceSchemaResult sourceSchemaResult, JsonElement data, + ErrorTrie? errorTrie, int depth, ResultData parent) { @@ -154,14 +238,10 @@ private bool TryCompleteList( : _resultPoolSession.RentObjectListResult(); listResult.Initialize(type); -#if NET9_0_OR_GREATER for (int i = 0, len = data.GetArrayLength(); i < len; ++i) { var item = data[i]; -#else - foreach (var item in data.EnumerateArray()) - { -#endif + if (item.IsNullOrUndefined()) { if (!isNullable && _errorHandling is ErrorHandling.Propagate) @@ -171,10 +251,11 @@ private bool TryCompleteList( } listResult.SetNextValueNull(); + continue; } - if (!HandleElement(item)) + if (!HandleElement(item, i)) { if (!isNullable) { @@ -189,11 +270,14 @@ private bool TryCompleteList( parent.SetNextValue(listResult); return true; - bool HandleElement(in JsonElement item) + bool HandleElement(in JsonElement item, int index) { + ErrorTrie? errorTrieForIndex = null; + errorTrie?.TryGetValue(index, out errorTrieForIndex); + if (isNested) { - return TryCompleteList(selection, elementType, sourceSchemaResult, item, depth, listResult); + return TryCompleteList(selection, elementType, item, errorTrieForIndex, depth, listResult); } else if (isLeaf) { @@ -202,7 +286,7 @@ bool HandleElement(in JsonElement item) } else { - return TryCompleteObjectValue(selection, elementType, sourceSchemaResult, item, depth, listResult); + return TryCompleteObjectValue(selection, elementType, item, errorTrieForIndex, depth, listResult); } } } @@ -210,22 +294,22 @@ bool HandleElement(in JsonElement item) private bool TryCompleteObjectValue( Selection selection, IType type, - SourceSchemaResult sourceSchemaResult, JsonElement data, + ErrorTrie? errorTrie, int depth, ResultData parent) { var namedType = type.NamedType(); var objectType = Unsafe.As(ref namedType); - return TryCompleteObjectValue(selection, objectType, sourceSchemaResult, data, depth, parent); + return TryCompleteObjectValue(selection, objectType, data, errorTrie, depth, parent); } private bool TryCompleteObjectValue( Selection selection, IObjectTypeDefinition objectType, - SourceSchemaResult sourceSchemaResult, JsonElement data, + ErrorTrie? errorTrie, int depth, ResultData parent) { @@ -236,6 +320,7 @@ private bool TryCompleteObjectValue( var objectResult = _resultPoolSession.RentObjectResult(); objectResult.Initialize(_resultPoolSession, selectionSet, _includeFlags); + objectResult.SetParent(parent, parent.ParentIndex); foreach (var field in objectResult.Fields) { @@ -246,11 +331,16 @@ private bool TryCompleteObjectValue( continue; } - if (data.TryGetProperty(fieldSelection.ResponseName, out var child) - && !TryCompleteValue(fieldSelection, fieldSelection.Type, sourceSchemaResult, child, depth, field)) + if (data.TryGetProperty(fieldSelection.ResponseName, out var child)) { - parent.SetNextValueNull(); - return false; + ErrorTrie? errorTrieForResponseName = null; + errorTrie?.TryGetValue(fieldSelection.ResponseName, out errorTrieForResponseName); + + if (!TryCompleteValue(fieldSelection, fieldSelection.Type, child, errorTrieForResponseName, depth, field)) + { + parent.SetNextValueNull(); + return false; + } } } @@ -261,15 +351,15 @@ private bool TryCompleteObjectValue( private bool TryCompleteAbstractValue( Selection selection, IType type, - SourceSchemaResult sourceSchemaResult, JsonElement data, + ErrorTrie? errorTrie, int depth, ResultData parent) => TryCompleteObjectValue( selection, GetType(type, data), - sourceSchemaResult, data, + errorTrie, depth, parent); diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/Extensions/ServiceCollectionExtensions.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/Extensions/ServiceCollectionExtensions.cs index cc7feb43636..9d5e38afc2a 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/Extensions/ServiceCollectionExtensions.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using System.Net; +using HotChocolate.Transport.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -8,10 +10,11 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddHttpClient( this IServiceCollection services, string name, - TestServer server) + TestServer server, + bool isOffline = false) { services.TryAddSingleton(); - return services.AddSingleton(new TestServerRegistration(name, server)); + return services.AddSingleton(new TestServerRegistration(name, server, isOffline)); } private class Factory : IHttpClientFactory @@ -27,13 +30,27 @@ public HttpClient CreateClient(string name) { if (_registrations.TryGetValue(name, out var registration)) { - return registration.Server.CreateClient(); + var client = registration.IsOffline + ? new HttpClient(new ErrorHandler()) + : registration.Server.CreateClient(); + + client.DefaultRequestHeaders.AddGraphQLPreflight(); + + return client; } throw new InvalidOperationException( $"No test server registered with the name: {name}"); } + + private class ErrorHandler : HttpClientHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } } - private record TestServerRegistration(string Name, TestServer Server); + private record TestServerRegistration(string Name, TestServer Server, bool IsOffline = false); } diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/FusionTestBase.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/FusionTestBase.cs index 9dfc63e9f79..8a582396177 100644 --- a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/FusionTestBase.cs +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/FusionTestBase.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace HotChocolate.Fusion; @@ -19,7 +20,8 @@ public TestServer CreateSourceSchema( string schemaName, Action configureBuilder, Action? configureServices = null, - Action? configureApplication = null) + Action? configureApplication = null, + bool isOffline = false) { configureApplication ??= app => @@ -36,6 +38,8 @@ public TestServer CreateSourceSchema( var builder = services.AddGraphQLServer(schemaName); configureBuilder(builder); configureServices?.Invoke(services); + + services.Configure(opt => opt.IsOffline = isOffline); }, configureApplication); } @@ -53,7 +57,9 @@ public async Task CreateCompositeSchemaAsync( { var schemaDocument = await server.Services.GetSchemaAsync(name); sourceSchemas.Add(new SourceSchemaText(name, schemaDocument.ToString())); - gatewayServices.AddHttpClient(name, server); + + var subgraphOptions = server.Services.GetRequiredService>().Value; + gatewayServices.AddHttpClient(name, server, subgraphOptions.IsOffline); gatewayBuilder.AddHttpClientConfiguration(name, new Uri("http://localhost:5000/graphql")); } @@ -113,6 +119,11 @@ protected virtual void Dispose(bool disposing) } } + private sealed class SubgraphOptions + { + public bool IsOffline { get; set; } + } + private sealed class OperationPlanHttpRequestInterceptor : DefaultHttpRequestInterceptor { public override ValueTask OnCreateAsync( diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/SubgraphErrorTests.cs b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/SubgraphErrorTests.cs new file mode 100644 index 00000000000..44554676c7a --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/SubgraphErrorTests.cs @@ -0,0 +1,689 @@ +using HotChocolate.Execution; +using HotChocolate.Resolvers; +using HotChocolate.Transport.Http; +using HotChocolate.Types.Composite; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion; + +public class SubgraphErrorTests : FusionTestBase +{ + #region Root + + [Fact] + public async Task Error_On_Root_Field() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + productById(id: 1) { + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task Error_On_Root_Leaf() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + productById(id: 1) { + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task No_Data_And_Error_With_Path_For_Root_Field() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + productById(id: 1) { + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task No_Data_And_Error_Without_Path_For_Root_Field() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType() + .InsertUseRequest( + before: "OperationExecutionMiddleware", + middleware: (_, _) => + { + return context => + { + context.Result = OperationResultBuilder.CreateError( + ErrorBuilder.New() + .SetMessage("A global error") + .Build()); + + return ValueTask.CompletedTask; + }; + }, + key: "error")); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + productById(id: 1) { + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task Subgraph_Request_Fails_For_Root_Field() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType(), + isOffline: true); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + nullableTopProduct { + price + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task Subgraph_Request_Fails_For_Root_Field_NonNull() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType(), + isOffline: true); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProduct { + price + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + #endregion + + #region Lookup + + [Fact] + public async Task Error_On_Lookup_Leaf() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType()); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProduct { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task Error_On_Lookup_Field() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType()); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProduct { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task No_Data_And_Error_With_Path_For_Lookup() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType()); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProduct { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task No_Data_And_Error_With_Path_For_Lookup_Leaf() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType()); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProduct { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task No_Data_And_Error_Without_Path_For_Lookup() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType() + .InsertUseRequest( + before: "OperationExecutionMiddleware", + middleware: (_, _) => + { + return context => + { + context.Result = OperationResultBuilder.CreateError( + ErrorBuilder.New() + .SetMessage("A global error") + .Build()); + + return ValueTask.CompletedTask; + }; + }, + key: "error")); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProduct { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task Subgraph_Request_Fails_For_Lookup() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType(), + isOffline: true); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + nullableTopProduct { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task Subgraph_Request_Fails_For_Lookup_NonNull() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType(), + isOffline: true); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProduct { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task Subgraph_Request_Fails_For_Lookup_On_List() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType(), + isOffline: true); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProducts { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + [Fact] + public async Task Subgraph_Request_Fails_For_Lookup_On_List_NonNull() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType(), + isOffline: true); + + // act + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2), + ]); + + // assert + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + using var result = await client.PostAsync( + """ + { + topProducts { + price + name + } + } + """, + new Uri("http://localhost:5000/graphql")); + + // act + using var response = await result.ReadAsResultAsync(); + response.MatchSnapshot(); + } + + #endregion + + public static class SourceSchema1 + { + public class Query + { + public Product GetTopProduct() => new(1, 13.99); + + public Product? GetNullableTopProduct() => new (1, 13.99); + + public List GetTopProducts() + => [new(1, 13.99), new(2, 13.99), new(3, 13.99)]; + + [Lookup] + [Internal] + public Product? GetProductById(int id) => new(id, 13.99); + } + + public record Product(int Id, double Price); + } + + public static class SourceSchema2 + { + public class Query + { + [Lookup] + public Product? GetProductById(int id) => new(id); + } + + public record Product(int Id) + { + public string? GetName(IResolverContext context) + { + throw new GraphQLException(ErrorBuilder.New().SetMessage("Could not resolve Product.name") + .SetPath(context.Path).Build()); + } + } + } + + public static class SourceSchema3 + { + public class Query + { + [Lookup] + public Product? GetProductById(int id, IResolverContext context) + => throw new GraphQLException(ErrorBuilder.New().SetMessage("Could not resolve Product") + .SetPath(context.Path).Build()); + } + + public record Product(int Id) + { + public string GetName() => "Product " + Id; + } + } + + public static class SourceSchema4 + { + public class Query + { + [Lookup] + public Product GetProductById(int id, IResolverContext context) + => throw new GraphQLException(ErrorBuilder.New().SetMessage("Could not resolve Product") + .SetPath(context.Path).Build()); + } + + public record Product(int Id) + { + public string GetName() => "Product " + Id; + } + } + + public static class SourceSchema5 + { + public class Query + { + [Lookup] + public Product? GetProductById(int id) => null; + } + + public record Product(int Id) + { + public string GetName() => "Product " + Id; + } + } + + public static class SourceSchema6 + { + public class Query + { + [Lookup] + public Product GetProductById(int id) => new(id); + } + + public record Product(int Id) + { + public string GetName(IResolverContext context) + { + throw new GraphQLException(ErrorBuilder.New().SetMessage("Could not resolve Product.name") + .SetPath(context.Path).Build()); + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Lookup_Field.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Lookup_Field.snap new file mode 100644 index 00000000000..f1861e46240 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Lookup_Field.snap @@ -0,0 +1,51 @@ +{ + "data": { + "topProduct": { + "price": 13.99, + "name": null + } + }, + "errors": [ + { + "message": "Could not resolve Product", + "path": [ + "topProduct" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProduct {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "e24d93b6245cedd878fff4452f5f16b9" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_e24d93b6_1 {\n topProduct {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_e24d93b6_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.topProduct", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Lookup_Leaf.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Lookup_Leaf.snap new file mode 100644 index 00000000000..252cc556ce9 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Lookup_Leaf.snap @@ -0,0 +1,52 @@ +{ + "data": { + "topProduct": { + "price": 13.99, + "name": null + } + }, + "errors": [ + { + "message": "Could not resolve Product.name", + "path": [ + "topProduct", + "name" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProduct {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "e24d93b6245cedd878fff4452f5f16b9" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_e24d93b6_1 {\n topProduct {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_e24d93b6_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.topProduct", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Root_Field.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Root_Field.snap new file mode 100644 index 00000000000..dd30085ec96 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Root_Field.snap @@ -0,0 +1,31 @@ +{ + "data": { + "productById": null + }, + "errors": [ + { + "message": "Could not resolve Product", + "path": [ + "productById" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n productById(id: 1) {\n name\n }\n}", + "hash": "97fd38fcea394bc6badd7ea22c6de660" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_97fd38fc_1 {\n productById(id: 1) {\n name\n }\n}" + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Root_Leaf.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Root_Leaf.snap new file mode 100644 index 00000000000..2d50def780d --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Error_On_Root_Leaf.snap @@ -0,0 +1,34 @@ +{ + "data": { + "productById": { + "name": null + } + }, + "errors": [ + { + "message": "Could not resolve Product.name", + "path": [ + "productById", + "name" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n productById(id: 1) {\n name\n }\n}", + "hash": "97fd38fcea394bc6badd7ea22c6de660" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_97fd38fc_1 {\n productById(id: 1) {\n name\n }\n}" + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Lookup.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Lookup.snap new file mode 100644 index 00000000000..f1861e46240 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Lookup.snap @@ -0,0 +1,51 @@ +{ + "data": { + "topProduct": { + "price": 13.99, + "name": null + } + }, + "errors": [ + { + "message": "Could not resolve Product", + "path": [ + "topProduct" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProduct {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "e24d93b6245cedd878fff4452f5f16b9" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_e24d93b6_1 {\n topProduct {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_e24d93b6_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.topProduct", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Lookup_Leaf.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Lookup_Leaf.snap new file mode 100644 index 00000000000..252cc556ce9 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Lookup_Leaf.snap @@ -0,0 +1,52 @@ +{ + "data": { + "topProduct": { + "price": 13.99, + "name": null + } + }, + "errors": [ + { + "message": "Could not resolve Product.name", + "path": [ + "topProduct", + "name" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProduct {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "e24d93b6245cedd878fff4452f5f16b9" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_e24d93b6_1 {\n topProduct {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_e24d93b6_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.topProduct", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Root_Field.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Root_Field.snap new file mode 100644 index 00000000000..dd30085ec96 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_With_Path_For_Root_Field.snap @@ -0,0 +1,31 @@ +{ + "data": { + "productById": null + }, + "errors": [ + { + "message": "Could not resolve Product", + "path": [ + "productById" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n productById(id: 1) {\n name\n }\n}", + "hash": "97fd38fcea394bc6badd7ea22c6de660" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_97fd38fc_1 {\n productById(id: 1) {\n name\n }\n}" + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_Without_Path_For_Lookup.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_Without_Path_For_Lookup.snap new file mode 100644 index 00000000000..54a632a4517 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_Without_Path_For_Lookup.snap @@ -0,0 +1,48 @@ +{ + "data": { + "topProduct": { + "price": 13.99, + "name": null + } + }, + "errors": [ + { + "message": "A global error" + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProduct {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "e24d93b6245cedd878fff4452f5f16b9" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_e24d93b6_1 {\n topProduct {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_e24d93b6_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.topProduct", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_Without_Path_For_Root_Field.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_Without_Path_For_Root_Field.snap new file mode 100644 index 00000000000..c083ce35b73 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.No_Data_And_Error_Without_Path_For_Root_Field.snap @@ -0,0 +1,28 @@ +{ + "data": { + "productById": null + }, + "errors": [ + { + "message": "A global error" + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n productById(id: 1) {\n name\n }\n}", + "hash": "97fd38fcea394bc6badd7ea22c6de660" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_97fd38fc_1 {\n productById(id: 1) {\n name\n }\n}" + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup.snap new file mode 100644 index 00000000000..e063db3c70f --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup.snap @@ -0,0 +1,52 @@ +{ + "data": { + "nullableTopProduct": { + "price": 13.99, + "name": null + } + }, + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "nullableTopProduct", + "name" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n nullableTopProduct {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "12015865a78f4ab2330778c5486a4026" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_12015865_1 {\n nullableTopProduct {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_12015865_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.nullableTopProduct", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_NonNull.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_NonNull.snap new file mode 100644 index 00000000000..452ed0760d7 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_NonNull.snap @@ -0,0 +1,46 @@ +{ + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "topProduct", + "name" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProduct {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "e24d93b6245cedd878fff4452f5f16b9" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_e24d93b6_1 {\n topProduct {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_e24d93b6_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.topProduct", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_On_List.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_On_List.snap new file mode 100644 index 00000000000..9c174286bc6 --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_On_List.snap @@ -0,0 +1,79 @@ +{ + "data": { + "topProducts": [ + { + "price": 13.99, + "name": null + }, + { + "price": 13.99, + "name": null + }, + { + "price": 13.99, + "name": null + } + ] + }, + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "topProducts", + 0, + "name" + ] + }, + { + "message": "Unexpected Execution Error", + "path": [ + "topProducts", + 1, + "name" + ] + }, + { + "message": "Unexpected Execution Error", + "path": [ + "topProducts", + 2, + "name" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProducts {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "67b3f75afa8d6eb979605a59d5462c1d" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_67b3f75a_1 {\n topProducts {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_67b3f75a_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.topProducts", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_On_List_NonNull.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_On_List_NonNull.snap new file mode 100644 index 00000000000..6d3f369540d --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Lookup_On_List_NonNull.snap @@ -0,0 +1,47 @@ +{ + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "topProducts", + 0, + "name" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProducts {\n price\n name\n id @fusion__requirement\n }\n}", + "hash": "67b3f75afa8d6eb979605a59d5462c1d" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_67b3f75a_1 {\n topProducts {\n price\n id\n }\n}" + }, + { + "id": 2, + "type": "Operation", + "schema": "B", + "operation": "query Op_67b3f75a_2(\n $__fusion_1_id: Int!\n) {\n productById(id: $__fusion_1_id) {\n name\n }\n}", + "source": "$.productById", + "target": "$.topProducts", + "requirements": [ + { + "name": "__fusion_1_id", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Root_Field.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Root_Field.snap new file mode 100644 index 00000000000..decf81f34ca --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Root_Field.snap @@ -0,0 +1,31 @@ +{ + "data": { + "nullableTopProduct": null + }, + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "nullableTopProduct" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n nullableTopProduct {\n price\n }\n}", + "hash": "14c79a43eb6ce3e1a1fa16337c365654" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_14c79a43_1 {\n nullableTopProduct {\n price\n }\n}" + } + ] + } + } + } +} diff --git a/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Root_Field_NonNull.snap b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Root_Field_NonNull.snap new file mode 100644 index 00000000000..cac6c4061fa --- /dev/null +++ b/src/HotChocolate/Fusion-vnext/test/Fusion.AspNetCore.Tests/__snapshots__/SubgraphErrorTests.Subgraph_Request_Fails_For_Root_Field_NonNull.snap @@ -0,0 +1,28 @@ +{ + "errors": [ + { + "message": "Unexpected Execution Error", + "path": [ + "topProduct" + ] + } + ], + "extensions": { + "fusion": { + "operationPlan": { + "operation": { + "document": "{\n topProduct {\n price\n }\n}", + "hash": "1c1cf0244bcda9f7f91e7e0becca0616" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "A", + "operation": "query Op_1c1cf024_1 {\n topProduct {\n price\n }\n}" + } + ] + } + } + } +}