diff --git a/src/HotChocolate/Core/src/Types/Execution/Options/IErrorHandlerOptionsAccessor.cs b/src/HotChocolate/Core/src/Types/Execution/Options/IErrorHandlerOptionsAccessor.cs index b07dc9ea537..2d93a718774 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Options/IErrorHandlerOptionsAccessor.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Options/IErrorHandlerOptionsAccessor.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using HotChocolate.Language; namespace HotChocolate.Execution.Options; @@ -14,4 +15,15 @@ public interface IErrorHandlerOptionsAccessor /// . /// bool IncludeExceptionDetails { get; } + + /// + /// Gets the default error handling mode for null propagation. + /// + ErrorHandlingMode DefaultErrorHandlingMode { get; } + + /// + /// Gets a value indicating whether the can be + /// overridden on a per-request basis. + /// + bool AllowErrorHandlingModeOverride { get; } } diff --git a/src/HotChocolate/Core/src/Types/Execution/Options/RequestExecutorOptions.cs b/src/HotChocolate/Core/src/Types/Execution/Options/RequestExecutorOptions.cs index 6bd449f99b5..b6c507701d1 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Options/RequestExecutorOptions.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Options/RequestExecutorOptions.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using HotChocolate.Language; using HotChocolate.PersistedOperations; namespace HotChocolate.Execution.Options; @@ -67,4 +68,17 @@ public PersistedOperationOptions PersistedOperations field = value; } } = new(); + + /// + /// Gets or sets the default error handling mode for null propagation. + /// by default. + /// + public ErrorHandlingMode DefaultErrorHandlingMode { get; set; } = ErrorHandlingMode.Propagate; + + /// + /// Gets or sets whether the can be overridden + /// on a per-request basis. + /// false by default. + /// + public bool AllowErrorHandlingModeOverride { get; set; } } diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Pooling.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Pooling.cs index bedb56a317f..730ec5c552a 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Pooling.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/OperationContext.Pooling.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using HotChocolate.Execution.DependencyInjection; using HotChocolate.Execution.Instrumentation; +using HotChocolate.Execution.Options; using HotChocolate.Execution.Processing.Tasks; using HotChocolate.Fetching; using HotChocolate.Resolvers; @@ -91,8 +92,12 @@ public void Initialize( _batchDispatcher = batchDispatcher; _variableIndex = variableIndex; - var errorHandlingMode = _requestContext.Request.ErrorHandlingMode - ?? _schema.GetOptions().DefaultErrorHandlingMode; + var executorOptions = _schema.Services.GetRequiredService(); + var errorHandlingMode = + executorOptions.AllowErrorHandlingModeOverride + && _requestContext.Request.ErrorHandlingMode is { } requestedMode + ? requestedMode + : executorOptions.DefaultErrorHandlingMode; _propagateNullValues = errorHandlingMode is Language.ErrorHandlingMode.Propagate; IncludeFlags = operation.CreateIncludeFlags(variables); diff --git a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs index aca891a0d20..bb3678b4e98 100644 --- a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs @@ -1,7 +1,6 @@ using System.Reflection; using HotChocolate.Configuration; using HotChocolate.Execution; -using HotChocolate.Language; using HotChocolate.Types; namespace HotChocolate; @@ -232,9 +231,4 @@ public interface IReadOnlySchemaOptions /// Applies the @serializeAs directive to scalar types that specify a serialization format. /// bool ApplySerializeAsToScalars { get; } - - /// - /// Gets the default error handling mode for null propagation. - /// - ErrorHandlingMode DefaultErrorHandlingMode { get; } } diff --git a/src/HotChocolate/Core/src/Types/SchemaOptions.cs b/src/HotChocolate/Core/src/Types/SchemaOptions.cs index cfaad846b84..ee7ef7f294c 100644 --- a/src/HotChocolate/Core/src/Types/SchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/SchemaOptions.cs @@ -1,7 +1,6 @@ using System.Reflection; using HotChocolate.Configuration; using HotChocolate.Execution; -using HotChocolate.Language; using HotChocolate.Types; namespace HotChocolate; @@ -190,9 +189,6 @@ public int OperationDocumentCacheSize /// public bool ApplySerializeAsToScalars { get; set; } - /// - public ErrorHandlingMode DefaultErrorHandlingMode { get; set; } = ErrorHandlingMode.Propagate; - /// /// Creates a mutable options object from a read-only options object. /// @@ -234,7 +230,6 @@ public static SchemaOptions FromOptions(IReadOnlySchemaOptions options) ApplyShareableToPageInfo = options.ApplyShareableToPageInfo, ApplyShareableToConnections = options.ApplyShareableToConnections, ApplyShareableToNodeFields = options.ApplyShareableToNodeFields, - ApplySerializeAsToScalars = options.ApplySerializeAsToScalars, - DefaultErrorHandlingMode = options.DefaultErrorHandlingMode + ApplySerializeAsToScalars = options.ApplySerializeAsToScalars }; } diff --git a/src/HotChocolate/Core/test/Execution.Tests/Errors/NullErrorPropagationTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Errors/NullErrorPropagationTests.cs index 233194550d3..065a914d4f1 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Errors/NullErrorPropagationTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/Errors/NullErrorPropagationTests.cs @@ -250,7 +250,7 @@ public async Task List_NonNullElementHasError_NullMode(string fieldType) } [Fact] - public async Task DefaultErrorHandlingMode_AppliesFromSchemaOptions() + public async Task DefaultErrorHandlingMode_AppliesFromRequestExecutorOptions() { // arrange using var snapshot = SnapshotHelpers.StartResultSnapshot(); @@ -258,7 +258,7 @@ public async Task DefaultErrorHandlingMode_AppliesFromSchemaOptions() var executor = await new ServiceCollection() .AddGraphQL() .AddDocumentFromString(SchemaText) - .ModifyOptions(o => o.DefaultErrorHandlingMode = ErrorHandlingMode.Null) + .ModifyRequestOptions(o => o.DefaultErrorHandlingMode = ErrorHandlingMode.Null) .AddResolver("Query", "foo", _ => new(new object())) .AddResolver("Foo", "nullable_list_nullable_element", _ => new(new[] { new object() })) .AddResolver("Foo", "nonnull_list_nullable_element", _ => new(new[] { new object() })) @@ -285,12 +285,12 @@ public async Task DefaultErrorHandlingMode_AppliesFromSchemaOptions() } [Fact] - public async Task PerRequestOverride_OverridesSchemaDefault() + public async Task PerRequestOverride_OverridesDefaultErrorHandlingMode() { // arrange using var snapshot = SnapshotHelpers.StartResultSnapshot(); - // Server configured with Propagate (default), but request specifies Null + // Request executor options use Propagate (default), but the request specifies Null. var executor = await CreateExecutorAsync(); var request = @@ -307,6 +307,43 @@ public async Task PerRequestOverride_OverridesSchemaDefault() snapshot.Add(result); } + [Fact] + public async Task PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled() + { + // arrange + using var snapshot = SnapshotHelpers.StartResultSnapshot(); + + // Request executor options use Propagate (default) and override disabled (default). + // Even though the request asks for Null, the configured DefaultErrorHandlingMode must win. + var executor = await new ServiceCollection() + .AddGraphQL() + .AddDocumentFromString(SchemaText) + .AddResolver("Query", "foo", _ => new(new object())) + .AddResolver("Foo", "nullable_list_nullable_element", _ => new(new[] { new object() })) + .AddResolver("Foo", "nonnull_list_nullable_element", _ => new(new[] { new object() })) + .AddResolver("Foo", "nullable_list_nonnull_element", _ => new(new[] { new object() })) + .AddResolver("Foo", "nonnull_list_nonnull_element", _ => new(new[] { new object() })) + .AddResolver("Foo", "nonnull_prop", _ => new(new object())) + .AddResolver("Foo", "nullable_prop", _ => new(new object())) + .AddResolver("Bar", "a", c => new(c.GetGlobalStateOrDefault("a"))) + .AddResolver("Bar", "b", c => new(c.GetGlobalStateOrDefault("b"))) + .AddResolver("Bar", "c", _ => throw new GraphQLException("ERROR")) + .BuildRequestExecutorAsync(); + + var request = + OperationRequestBuilder.New() + .SetDocument("{ foo { nonnull_prop { b } } }") + .AddGlobalState("b", null) + .SetErrorHandlingMode(ErrorHandlingMode.Null) + .Build(); + + // act + var result = await executor.ExecuteAsync(request); + + // assert + snapshot.Add(result); + } + private const string SchemaText = """ type Query { @@ -334,6 +371,7 @@ private static async Task CreateExecutorAsync() return await new ServiceCollection() .AddGraphQL() .AddDocumentFromString(SchemaText) + .ModifyRequestOptions(o => o.AllowErrorHandlingModeOverride = true) .AddResolver("Query", "foo", _ => new(new object())) .AddResolver("Foo", "nullable_list_nullable_element", _ => new(new[] { new object() })) .AddResolver("Foo", "nonnull_list_nullable_element", _ => new(new[] { new object() })) diff --git a/src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.DefaultErrorHandlingMode_AppliesFromSchemaOptions.json b/src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.DefaultErrorHandlingMode_AppliesFromRequestExecutorOptions.json similarity index 100% rename from src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.DefaultErrorHandlingMode_AppliesFromSchemaOptions.json rename to src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.DefaultErrorHandlingMode_AppliesFromRequestExecutorOptions.json diff --git a/src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled.json b/src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled.json new file mode 100644 index 00000000000..1a5bb81281e --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled.json @@ -0,0 +1,18 @@ +{ + "errors": [ + { + "message": "Cannot return null for non-nullable field.", + "path": [ + "foo", + "nonnull_prop", + "b" + ], + "extensions": { + "code": "HC0018" + } + } + ], + "data": { + "foo": null + } +} diff --git a/src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.PerRequestOverride_OverridesSchemaDefault.json b/src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.PerRequestOverride_OverridesDefaultErrorHandlingMode.json similarity index 100% rename from src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.PerRequestOverride_OverridesSchemaDefault.json rename to src/HotChocolate/Core/test/Execution.Tests/Errors/__snapshots__/NullErrorPropagationTests.PerRequestOverride_OverridesDefaultErrorHandlingMode.json diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs index 6ee40f054df..24e08dd0beb 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs @@ -115,7 +115,7 @@ internal static ErrorHandlingMode ErrorHandlingMode( return errorHandlingMode; } - return context.Schema.GetOptions().DefaultErrorHandlingMode; + return requestOptions.DefaultErrorHandlingMode; } internal static bool AllowErrorHandlingModeOverride( diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs index 403b49e9c28..2673124aa6a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs @@ -1,7 +1,6 @@ using HotChocolate.Caching.Memory; using HotChocolate.Execution.Relay; using HotChocolate.Fusion.Types; -using HotChocolate.Language; namespace HotChocolate.Fusion.Execution; @@ -101,21 +100,6 @@ public int PathSegmentLocalPoolCapacity } } = 64; - /// - /// Gets or sets the default error handling mode. - /// by default. - /// - public ErrorHandlingMode DefaultErrorHandlingMode - { - get; - set - { - ExpectMutableOptions(); - - field = value; - } - } = ErrorHandlingMode.Propagate; - /// /// Gets or sets whether the request executor should be initialized lazily. /// false by default. @@ -214,7 +198,6 @@ public FusionOptions Clone() OperationExecutionPlanCacheDiagnostics = OperationExecutionPlanCacheDiagnostics, OperationDocumentCacheSize = OperationDocumentCacheSize, PathSegmentLocalPoolCapacity = PathSegmentLocalPoolCapacity, - DefaultErrorHandlingMode = DefaultErrorHandlingMode, LazyInitialization = LazyInitialization, NodeIdSerializerFormat = NodeIdSerializerFormat, ApplySerializeAsToScalars = ApplySerializeAsToScalars, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestOptions.cs index ede7b887171..b0ddbe34de4 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestOptions.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using HotChocolate.Language; using HotChocolate.PersistedOperations; namespace HotChocolate.Fusion.Execution; @@ -42,7 +43,22 @@ public bool CollectOperationPlanTelemetry } /// - /// Gets or sets whether the can be overriden + /// Gets or sets the default error handling mode. + /// by default. + /// + public ErrorHandlingMode DefaultErrorHandlingMode + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } = ErrorHandlingMode.Propagate; + + /// + /// Gets or sets whether the can be overridden /// on a per-request basis. /// false by default. /// @@ -124,6 +140,7 @@ public FusionRequestOptions Clone() { ExecutionTimeout = ExecutionTimeout, CollectOperationPlanTelemetry = CollectOperationPlanTelemetry, + DefaultErrorHandlingMode = DefaultErrorHandlingMode, AllowErrorHandlingModeOverride = AllowErrorHandlingModeOverride, AllowOperationPlanRequests = AllowOperationPlanRequests, PersistedOperations = PersistedOperations, diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SourceSchemaErrorTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SourceSchemaErrorTests.cs index 6ec46af7ccd..7fc42ff3c86 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SourceSchemaErrorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SourceSchemaErrorTests.cs @@ -61,7 +61,7 @@ public async Task OnError_SchemaDefault_Null_AppliesWithoutPerRequestOverride() ("A", server1) ], configureGatewayBuilder: builder => - builder.ModifyOptions(o => o.DefaultErrorHandlingMode = ErrorHandlingMode.Null)); + builder.ModifyRequestOptions(o => o.DefaultErrorHandlingMode = ErrorHandlingMode.Null)); // act — no per-request onError override using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -83,6 +83,54 @@ public async Task OnError_SchemaDefault_Null_AppliesWithoutPerRequestOverride() await MatchSnapshotAsync(gateway, request, result); } + [Fact] + public async Task OnError_PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled() + { + // arrange + using var server1 = CreateSourceSchema( + "A", + b => b.AddQueryType()); + + using var server2 = CreateSourceSchema( + "B", + b => b.AddQueryType()); + + using var gateway = await CreateCompositeSchemaAsync( + [ + ("A", server1), + ("B", server2) + ], + configureGatewayBuilder: builder => + builder.ModifyRequestOptions(o => + { + o.DefaultErrorHandlingMode = ErrorHandlingMode.Propagate; + o.AllowErrorHandlingModeOverride = false; + })); + + // act + // Even though the request asks for Null, the gateway must ignore the override + // and apply the configured Propagate mode (so data is fully omitted). + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var request = new OperationRequest( + """ + { + topProduct { + price + name + } + } + """, + onError: ErrorHandlingMode.Null); + + using var result = await client.PostAsync( + request, + new Uri("http://localhost:5000/graphql")); + + // assert + await MatchSnapshotAsync(gateway, request, result); + } + [Fact] public async Task OnError_Null_OnSourceSchema_Forwards_To_Subgraph_Request() { diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SourceSchemaErrorTests.OnError_PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SourceSchemaErrorTests.OnError_PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled.yaml new file mode 100644 index 00000000000..22721af12fb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/SourceSchemaErrorTests.OnError_PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled.yaml @@ -0,0 +1,146 @@ +title: OnError_PerRequestOverride_IsIgnored_When_AllowErrorHandlingModeOverride_IsDisabled +request: + onError: Null + document: | + { + topProduct { + price + name + } + } +response: + body: | + { + "errors": [ + { + "message": "Could not resolve Product", + "path": [ + "topProduct", + "name" + ] + } + ] + } +sourceSchemas: + - name: A + schema: | + schema { + query: Query + } + + type Query { + topProduct: Product! + nullableTopProduct: Product + topProducts: [Product!]! + productById(id: Int!): Product @lookup @internal + } + + type Product { + id: Int! + price: Float! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_e24d93b6_1 { + topProduct { + price + id + } + } + response: + results: + - | + { + "data": { + "topProduct": { + "price": 13.99, + "id": 1 + } + } + } + - name: B + schema: | + schema { + query: Query + } + + type Query { + productById(id: Int!): Product @lookup + } + + type Product { + name: String! + id: Int! + } + interactions: + - request: + accept: application/graphql-response+json; charset=utf-8, application/json; charset=utf-8, application/jsonl; charset=utf-8, text/event-stream; charset=utf-8 + document: | + query Op_e24d93b6_2($__fusion_1_id: Int!) { + productById(id: $__fusion_1_id) { + name + } + } + variables: | + { + "__fusion_1_id": 1 + } + response: + results: + - | + { + "errors": [ + { + "message": "Could not resolve Product", + "path": [ + "productById" + ] + } + ], + "data": { + "productById": null + } + } +operationPlan: + operation: + - document: | + { + topProduct { + price + name + id @fusion__requirement + } + } + hash: e24d93b6245cedd878fff4452f5f16b9 + searchSpace: 1 + expandedNodes: 2 + nodes: + - id: 1 + type: Operation + schema: A + operation: | + query Op_e24d93b6_1 { + topProduct { + price + id + } + } + - id: 2 + type: Operation + schema: B + operation: | + query Op_e24d93b6_2($__fusion_1_id: Int!) { + productById(id: $__fusion_1_id) { + name + } + } + source: $.productById + target: $.topProduct + requirements: + - name: __fusion_1_id + selectionMap: >- + id + dependencies: + - id: 1 diff --git a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md index 3ed92fc61ce..3428adfa99a 100644 --- a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md +++ b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md @@ -999,7 +999,7 @@ If you still need to keep the behavior of not propagating nulls for errors on no ```csharp builder .AddGraphQL() - .ModifyOptions(o => o.DefaultErrorHandlingMode = ErrorHandlingMode.Null); + .ModifyRequestOptions(o => o.DefaultErrorHandlingMode = ErrorHandlingMode.Null); ``` ### Clients that still need a schema with @semanticNonNull annotations