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 eac4b34cf7f..6efb9b4cd45 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/FusionRequestContextExtensions.cs @@ -125,6 +125,13 @@ internal static bool AllowErrorHandlingModeOverride( return context.Schema.GetRequestOptions().AllowErrorHandlingModeOverride; } + internal static bool AllowOperationPlanRequests( + this RequestContext context) + { + ArgumentNullException.ThrowIfNull(context); + return context.Schema.GetRequestOptions().AllowOperationPlanRequests; + } + internal static ISourceSchemaClientScope CreateClientScope( this RequestContext context) { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestOptions.cs index 2d19d76c50c..ede7b887171 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestOptions.cs @@ -57,6 +57,23 @@ public bool AllowErrorHandlingModeOverride } } + /// + /// Gets or sets whether clients are allowed to request the operation plan + /// to be included in the GraphQL response by sending the + /// Fusion-Operation-Plan header. + /// false by default. + /// + public bool AllowOperationPlanRequests + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } + /// /// Gets or sets the persisted operation options. /// @@ -108,6 +125,7 @@ public FusionRequestOptions Clone() ExecutionTimeout = ExecutionTimeout, CollectOperationPlanTelemetry = CollectOperationPlanTelemetry, AllowErrorHandlingModeOverride = AllowErrorHandlingModeOverride, + AllowOperationPlanRequests = AllowOperationPlanRequests, PersistedOperations = PersistedOperations, IncludeExceptionDetails = IncludeExceptionDetails }; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs index 8ec0c2c9c9f..d01a7c3a3ef 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs @@ -608,7 +608,8 @@ internal OperationResult Complete(bool reusable = false, bool retainMemoryForDef operationResult.Features.Set(OperationPlan); if (OperationPlan is OperationPlan rootPlan - && RequestContext.ContextData.ContainsKey(ExecutionContextData.IncludeOperationPlan)) + && RequestContext.ContextData.ContainsKey(ExecutionContextData.IncludeOperationPlan) + && RequestContext.AllowOperationPlanRequests()) { var writer = new PooledArrayWriter(); s_planFormatter.Format(writer, rootPlan, trace); diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DefaultSecurityTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DefaultSecurityTests.cs index 673acfcfe4e..06b7dd47352 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DefaultSecurityTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/DefaultSecurityTests.cs @@ -1,3 +1,5 @@ +using System.Net.Http.Headers; +using System.Text; using System.Text.Json; using HotChocolate.AspNetCore; using HotChocolate.Transport.Http; @@ -293,4 +295,97 @@ public async Task DefaultSecurity_Disabled_InProduction_FieldCycleDepthIsNotEnfo Assert.Equal(JsonValueKind.Undefined, response.Errors.ValueKind); Assert.Equal(JsonValueKind.Object, response.Data.ValueKind); } + + [Fact] + public async Task OperationPlanHeader_When_Allowed_Should_Include_OperationPlan() + { + // arrange + using var server1 = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + configureGatewayBuilder: b => + { + b.ConfigureSchemaServices((_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + }); + b.ModifyRequestOptions(o => o.AllowOperationPlanRequests = true); + }); + + // act + using var response = await SendQueryAsync(gateway, includeOperationPlanHeader: true); + + // assert + response.MatchSnapshot(); + } + + [Fact] + public async Task OperationPlanHeader_When_Not_Allowed_Should_Omit_OperationPlan() + { + // arrange + using var server1 = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + configureGatewayBuilder: b => + { + b.ConfigureSchemaServices((_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + }); + b.ModifyRequestOptions(o => o.AllowOperationPlanRequests = false); + }); + + // act + using var response = await SendQueryAsync(gateway, includeOperationPlanHeader: true); + + // assert + response.MatchSnapshot(); + } + + [Fact] + public async Task NoOperationPlanHeader_When_Allowed_Should_Omit_OperationPlan() + { + // arrange + using var server1 = CreateSourceSchema("A", SimpleSchema); + + using var gateway = await CreateCompositeSchemaAsync( + [("A", server1)], + configureGatewayBuilder: b => + { + b.ConfigureSchemaServices((_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + }); + b.ModifyRequestOptions(o => o.AllowOperationPlanRequests = true); + }); + + // act + using var response = await SendQueryAsync(gateway, includeOperationPlanHeader: false); + + // assert + response.MatchSnapshot(); + } + + private static Task SendQueryAsync(Gateway gateway, bool includeOperationPlanHeader) + { + var httpClient = gateway.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/graphql"); + request.Content = new StringContent( + """{"query":"{ field }"}""", + Encoding.UTF8, + "application/json"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/graphql-response+json")); + + if (includeOperationPlanHeader) + { + request.Headers.Add("Fusion-Operation-Plan", "1"); + } + + return httpClient.SendAsync(request); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs index d519e1ff63d..4ce1071f293 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/FusionTestBase.cs @@ -167,6 +167,7 @@ protected async Task CreateCompositeSchemaAsync( { o.CollectOperationPlanTelemetry = false; o.AllowErrorHandlingModeOverride = true; + o.AllowOperationPlanRequests = true; }); configureGatewayBuilder?.Invoke(gatewayBuilder); diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.NoOperationPlanHeader_When_Allowed_Should_Omit_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.NoOperationPlanHeader_When_Allowed_Should_Omit_OperationPlan.snap new file mode 100644 index 00000000000..3b59182c2b5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.NoOperationPlanHeader_When_Allowed_Should_Omit_OperationPlan.snap @@ -0,0 +1,6 @@ +Headers: +Content-Type: application/graphql-response+json; charset=utf-8 +--------------------------> +Status Code: OK +--------------------------> +{"data":{"field":"Query"}} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.OperationPlanHeader_When_Allowed_Should_Include_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.OperationPlanHeader_When_Allowed_Should_Include_OperationPlan.snap new file mode 100644 index 00000000000..4d995975c17 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.OperationPlanHeader_When_Allowed_Should_Include_OperationPlan.snap @@ -0,0 +1,6 @@ +Headers: +Content-Type: application/graphql-response+json; charset=utf-8 +--------------------------> +Status Code: OK +--------------------------> +{"data":{"field":"Query"},"extensions":{"fusion":{"operationPlan":{"id":"83e3f6ccc9691c16992aba024cc36790692a435fd43d0d47dfca18eeb1c43a20","operation":{"kind":"Query","document":"{\n field\n}","id":"388c5f9c43fb589b1c5d1e144121613b","hash":"388c5f9c43fb589b1c5d1e144121613b","shortHash":"388c5f9c"},"searchSpace":1,"expandedNodes":1,"nodes":[{"id":1,"type":"Operation","schema":"A","operation":{"name":"Op_388c5f9c_1","kind":"Query","document":"query Op_388c5f9c_1 {\n field\n}","hash":"d243d6aa03056cfbc2ae6272df4c2dbbafb78f04901fe6faa816dcd979e1d239","shortHash":"d243d6aa"},"resultSelectionSet":"{ field }"}]}}}} diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.OperationPlanHeader_When_Not_Allowed_Should_Omit_OperationPlan.snap b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.OperationPlanHeader_When_Not_Allowed_Should_Omit_OperationPlan.snap new file mode 100644 index 00000000000..3b59182c2b5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/DefaultSecurityTests.OperationPlanHeader_When_Not_Allowed_Should_Omit_OperationPlan.snap @@ -0,0 +1,6 @@ +Headers: +Content-Type: application/graphql-response+json; charset=utf-8 +--------------------------> +Status Code: OK +--------------------------> +{"data":{"field":"Query"}} diff --git a/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_SubPlans.snap b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_SubPlans.snap new file mode 100644 index 00000000000..4a7410cee69 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Execution.Tests/Execution/Serialization/__snapshots__/JsonOperationPlanSerializationTests.Parse_Plan_Preserves_DeliveryGroup_Identity_Across_Plan_And_SubPlans.snap @@ -0,0 +1,156 @@ +{ + "id": "68c2a64a8c04735ef1b991eeca37e895e81946d427b3c305d68fd2a19d988d51", + "operation": { + "kind": "Query", + "document": "{\n user(id: \"1\") {\n name\n }\n}", + "id": "123456789101112", + "hash": "123456789101112", + "shortHash": "12345678" + }, + "searchSpace": 1, + "expandedNodes": 1, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "a", + "operation": { + "name": "Op_123456789101112_1", + "kind": "Query", + "document": "query Op_123456789101112_1 {\n user(id: \"1\") {\n name\n }\n}", + "hash": "167b5bb497f34e9c7516ec2d4598ae6d7f0fd73a9a9a329156910e99e5db4ec3", + "shortHash": "167b5bb4" + }, + "resultSelectionSet": "{ user }" + } + ], + "deliveryGroups": [ + { + "id": 0, + "path": "$.user", + "label": "contact" + }, + { + "id": 1, + "path": "$.user", + "label": "nested", + "parentId": 0 + }, + { + "id": 2, + "path": "$.user", + "label": "location" + } + ], + "deferredSubPlans": [ + { + "deliveryGroupIds": [ + 0, + 2 + ], + "parentNodeId": 1, + "operation": { + "kind": "Query", + "document": "{\n user(id: \"1\") {\n email\n id @fusion__requirement\n }\n}", + "id": "123456789101112#defer_0", + "hash": "123456789101112#defer_0", + "shortHash": "12345678" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "a", + "operation": { + "name": "Op_defer_1", + "kind": "Query", + "document": "query Op_defer_1 {\n user(id: \"1\") {\n id\n }\n}", + "hash": "93841f3bbdb1c1bf3a949d7089b6713b11e6d33666aae10bdc8a885607465ca5", + "shortHash": "93841f3b" + }, + "resultSelectionSet": "{ user }" + }, + { + "id": 2, + "type": "Operation", + "schema": "b", + "operation": { + "name": "Op_defer_2", + "kind": "Query", + "document": "query Op_defer_2($__fusion_1_id: ID!) {\n userById(id: $__fusion_1_id) {\n email\n }\n}", + "hash": "65dbc31ddfe32ceb540eaace9ebb429fdc84b5f7758a861ec21db29c098ce8ee", + "shortHash": "65dbc31d" + }, + "resultSelectionSet": "{ email }", + "source": "$.userById", + "target": "$.user", + "requirements": [ + { + "name": "__fusion_1_id", + "type": "ID!", + "path": "$.user", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + }, + { + "deliveryGroupIds": [ + 1 + ], + "parentNodeId": 2, + "operation": { + "kind": "Query", + "document": "{\n user(id: \"1\") {\n address\n id @fusion__requirement\n }\n}", + "id": "123456789101112#defer_1", + "hash": "123456789101112#defer_1", + "shortHash": "12345678" + }, + "nodes": [ + { + "id": 1, + "type": "Operation", + "schema": "a", + "operation": { + "name": "Op_defer_1", + "kind": "Query", + "document": "query Op_defer_1 {\n user(id: \"1\") {\n id\n }\n}", + "hash": "93841f3bbdb1c1bf3a949d7089b6713b11e6d33666aae10bdc8a885607465ca5", + "shortHash": "93841f3b" + }, + "resultSelectionSet": "{ user }" + }, + { + "id": 2, + "type": "Operation", + "schema": "b", + "operation": { + "name": "Op_defer_2", + "kind": "Query", + "document": "query Op_defer_2($__fusion_1_id: ID!) {\n userById(id: $__fusion_1_id) {\n address\n }\n}", + "hash": "1abf4c1bb273be32d1f61887253fa9843c5a15491e86d6962b34ecd01ad1cb01", + "shortHash": "1abf4c1b" + }, + "resultSelectionSet": "{ address }", + "source": "$.userById", + "target": "$.user", + "requirements": [ + { + "name": "__fusion_1_id", + "type": "ID!", + "path": "$.user", + "selectionMap": "id" + } + ], + "dependencies": [ + 1 + ] + } + ] + } + ] +}