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
+ ]
+ }
+ ]
+ }
+ ]
+}