diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs index b88430f8e96..09b9913f6b4 100644 --- a/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/Extensions/HotChocolateAspNetCoreServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using HotChocolate.AspNetCore.Parsers; using HotChocolate.AspNetCore.Warmup; using HotChocolate.Execution.Configuration; +using HotChocolate.Execution.Internal; using HotChocolate.Internal; using HotChocolate.Language; using HotChocolate.Utilities; @@ -165,6 +166,10 @@ private static IRequestExecutorBuilder AddGraphQLServerCore( IPostConfigureOptions, SourceSchemaServerOptionsPostConfigure>()); + builder.Services.TryAddSingleton< + ITransportCapabilitiesProvider, + TransportCapabilitiesProvider>(); + if (!builder.Services.IsImplementationTypeRegistered()) { builder.Services.AddSingleton(); diff --git a/src/HotChocolate/AspNetCore/src/AspNetCore/TransportCapabilitiesProvider.cs b/src/HotChocolate/AspNetCore/src/AspNetCore/TransportCapabilitiesProvider.cs new file mode 100644 index 00000000000..afe20aa6cc7 --- /dev/null +++ b/src/HotChocolate/AspNetCore/src/AspNetCore/TransportCapabilitiesProvider.cs @@ -0,0 +1,22 @@ +using HotChocolate.Execution.Internal; +using Microsoft.Extensions.Options; + +namespace HotChocolate.AspNetCore; + +/// +/// Derives the transport capabilities declared in an exported schema settings file +/// from the of the schema. +/// +internal sealed class TransportCapabilitiesProvider( + IOptionsMonitor serverOptions) + : ITransportCapabilitiesProvider +{ + public TransportCapabilities GetCapabilities(string schemaName) + { + var batching = serverOptions.Get(schemaName).Batching; + + return new TransportCapabilities( + VariableBatching: batching.HasFlag(AllowedBatching.VariableBatching), + RequestBatching: batching.HasFlag(AllowedBatching.RequestBatching)); + } +} diff --git a/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/TransportCapabilitiesProviderTests.cs b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/TransportCapabilitiesProviderTests.cs new file mode 100644 index 00000000000..b747e9d184c --- /dev/null +++ b/src/HotChocolate/AspNetCore/test/AspNetCore.Tests/TransportCapabilitiesProviderTests.cs @@ -0,0 +1,81 @@ +using HotChocolate.Execution.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.AspNetCore; + +public class TransportCapabilitiesProviderTests +{ + [Fact] + public void GetCapabilities_Should_DeclareNoBatching_When_ServerOptionsAllowNoBatching() + { + // arrange + var services = new ServiceCollection(); + services.AddGraphQLServer("a"); + var provider = GetProvider(services); + + // act + var capabilities = provider.GetCapabilities("a"); + + // assert + Assert.Equal( + new TransportCapabilities(VariableBatching: false, RequestBatching: false), + capabilities); + } + + [Fact] + public void GetCapabilities_Should_DeclareBothBatchingModes_When_SourceSchemaDefaultsApplied() + { + // arrange + var services = new ServiceCollection(); + services.AddGraphQLServer("a").AddSourceSchemaDefaults(); + var provider = GetProvider(services); + + // act + var capabilities = provider.GetCapabilities("a"); + + // assert + Assert.Equal( + new TransportCapabilities(VariableBatching: true, RequestBatching: true), + capabilities); + } + + [Fact] + public void GetCapabilities_Should_MapEachFlagSeparately_When_OnlyVariableBatchingIsAllowed() + { + // arrange + var services = new ServiceCollection(); + services + .AddGraphQLServer("a") + .ModifyServerOptions(o => o.Batching = AllowedBatching.VariableBatching); + var provider = GetProvider(services); + + // act + var capabilities = provider.GetCapabilities("a"); + + // assert + Assert.Equal( + new TransportCapabilities(VariableBatching: true, RequestBatching: false), + capabilities); + } + + [Fact] + public void GetCapabilities_Should_ReadNamedSchemaOptions_When_MultipleSchemasAreRegistered() + { + // arrange + var services = new ServiceCollection(); + services.AddGraphQLServer("a").AddSourceSchemaDefaults(); + services.AddGraphQLServer("b"); + var provider = GetProvider(services); + + // act + var capabilities = provider.GetCapabilities("b"); + + // assert + Assert.Equal( + new TransportCapabilities(VariableBatching: false, RequestBatching: false), + capabilities); + } + + private static ITransportCapabilitiesProvider GetProvider(IServiceCollection services) + => services.BuildServiceProvider().GetRequiredService(); +} diff --git a/src/HotChocolate/Core/src/Types/Execution/Internal/ITransportCapabilitiesProvider.cs b/src/HotChocolate/Core/src/Types/Execution/Internal/ITransportCapabilitiesProvider.cs new file mode 100644 index 00000000000..a91680e11f7 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Execution/Internal/ITransportCapabilitiesProvider.cs @@ -0,0 +1,19 @@ +namespace HotChocolate.Execution.Internal; + +/// +/// Supplies the transport capabilities that declares +/// in the settings file it creates for a schema. +/// +internal interface ITransportCapabilitiesProvider +{ + /// + /// Gets the transport capabilities of the schema with the given name. + /// + /// + /// The name of the schema. + /// + /// + /// The transport capabilities to declare for the schema. + /// + TransportCapabilities GetCapabilities(string schemaName); +} diff --git a/src/HotChocolate/Core/src/Types/Execution/Internal/SchemaFileExporter.cs b/src/HotChocolate/Core/src/Types/Execution/Internal/SchemaFileExporter.cs index 9f9d9d0ba53..f261e46551b 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Internal/SchemaFileExporter.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Internal/SchemaFileExporter.cs @@ -52,7 +52,17 @@ await File.WriteAllTextAsync( new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), cancellationToken); - await WriteSettingsFile(settingsFileName, executor.Schema.Name, cancellationToken); + var capabilities = executor.Schema + .GetRootServiceProvider() + .GetService() + ?.GetCapabilities(executor.Schema.Name) + ?? new TransportCapabilities(VariableBatching: true, RequestBatching: true); + + await WriteSettingsFile( + settingsFileName, + executor.Schema.Name, + capabilities, + cancellationToken); return new SchemaFileInfo(schemaFileName, settingsFileName); } @@ -60,11 +70,12 @@ await File.WriteAllTextAsync( private static async Task WriteSettingsFile( string fileName, string schemaName, + TransportCapabilities capabilities, CancellationToken cancellationToken) { if (!await TryUpdateSettingsFile(fileName, schemaName, cancellationToken)) { - await CreateNewSettingsFile(fileName, schemaName, cancellationToken); + await CreateNewSettingsFile(fileName, schemaName, capabilities, cancellationToken); } } @@ -109,6 +120,7 @@ private static async Task TryUpdateSettingsFile( private static async Task CreateNewSettingsFile( string fileName, string schemaName, + TransportCapabilities capabilities, CancellationToken cancellationToken) { await using var settingsFileStream = File.Create(fileName); @@ -124,13 +136,13 @@ private static async Task CreateNewSettingsFile( jsonWriter.WriteString("url", "http://localhost:5000/graphql"); - // A Hot Chocolate source schema knows which transport extensions it implements, so the - // exported template declares them instead of leaving the gateway on the defaults. + // The exported template declares the transport extensions the server accepts + // instead of leaving the gateway on the defaults. jsonWriter.WriteStartObject("capabilities"); jsonWriter.WriteStartObject("batching"); - jsonWriter.WriteBoolean("variableBatching", true); - jsonWriter.WriteBoolean("requestBatching", true); + jsonWriter.WriteBoolean("variableBatching", capabilities.VariableBatching); + jsonWriter.WriteBoolean("requestBatching", capabilities.RequestBatching); jsonWriter.WriteBoolean("aliasBatching", true); jsonWriter.WriteEndObject(); diff --git a/src/HotChocolate/Core/src/Types/Execution/Internal/TransportCapabilities.cs b/src/HotChocolate/Core/src/Types/Execution/Internal/TransportCapabilities.cs new file mode 100644 index 00000000000..0303df8daa0 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Execution/Internal/TransportCapabilities.cs @@ -0,0 +1,15 @@ +namespace HotChocolate.Execution.Internal; + +/// +/// The transport capabilities that declares +/// in the settings file it creates for a schema. +/// +/// +/// Whether the server accepts variable batching requests. +/// +/// +/// Whether the server accepts request batching requests. +/// +internal readonly record struct TransportCapabilities( + bool VariableBatching, + bool RequestBatching); diff --git a/src/HotChocolate/Core/test/Types.Tests/Execution/Internal/SchemaFileExporterTests.cs b/src/HotChocolate/Core/test/Types.Tests/Execution/Internal/SchemaFileExporterTests.cs new file mode 100644 index 00000000000..cf1c76effea --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Execution/Internal/SchemaFileExporterTests.cs @@ -0,0 +1,120 @@ +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Execution.Internal; + +public class SchemaFileExporterTests : IDisposable +{ + private readonly string _directory = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + System.IO.Path.GetRandomFileName()); + + [Fact] + public async Task Export_Should_DeclareBothBatchingModes_When_NoProviderIsRegistered() + { + // arrange + var services = new ServiceCollection(); + services + .AddGraphQL() + .AddQueryType(d => d.Name("Query").Field("foo").Resolve("bar")); + var executor = await GetExecutorAsync(services); + Directory.CreateDirectory(_directory); + + // act + var result = await SchemaFileExporter.Export( + System.IO.Path.Combine(_directory, "schema.graphqls"), + executor, + rewriteToSemanticNonNull: false, + TestContext.Current.CancellationToken); + + // assert + var settings = await File.ReadAllTextAsync( + result.SettingsFileName, + TestContext.Current.CancellationToken); + settings.ReplaceLineEndings("\n").MatchInlineSnapshot( + """ + { + "name": "_Default", + "transports": { + "http": { + "url": "http://localhost:5000/graphql", + "capabilities": { + "batching": { + "variableBatching": true, + "requestBatching": true, + "aliasBatching": true + }, + "onError": "propagate" + } + } + } + } + """ + "\n"); + } + + [Fact] + public async Task Export_Should_DeclareProviderCapabilities_When_ProviderIsRegistered() + { + // arrange + var services = new ServiceCollection(); + services.AddSingleton( + new FixedCapabilitiesProvider( + new TransportCapabilities(VariableBatching: false, RequestBatching: true))); + services + .AddGraphQL() + .AddQueryType(d => d.Name("Query").Field("foo").Resolve("bar")); + var executor = await GetExecutorAsync(services); + Directory.CreateDirectory(_directory); + + // act + var result = await SchemaFileExporter.Export( + System.IO.Path.Combine(_directory, "schema.graphqls"), + executor, + rewriteToSemanticNonNull: false, + TestContext.Current.CancellationToken); + + // assert + var settings = await File.ReadAllTextAsync( + result.SettingsFileName, + TestContext.Current.CancellationToken); + settings.ReplaceLineEndings("\n").MatchInlineSnapshot( + """ + { + "name": "_Default", + "transports": { + "http": { + "url": "http://localhost:5000/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": true, + "aliasBatching": true + }, + "onError": "propagate" + } + } + } + } + """ + "\n"); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + private static async Task GetExecutorAsync(IServiceCollection services) + => await services + .BuildServiceProvider() + .GetRequiredService() + .GetExecutorAsync(cancellationToken: TestContext.Current.CancellationToken); + + private sealed class FixedCapabilitiesProvider(TransportCapabilities capabilities) + : ITransportCapabilitiesProvider + { + public TransportCapabilities GetCapabilities(string schemaName) => capabilities; + } +} diff --git a/website/content/docs/fusion/batching.md b/website/content/docs/fusion/batching.md index 6fb1256e368..afd6d02370b 100644 --- a/website/content/docs/fusion/batching.md +++ b/website/content/docs/fusion/batching.md @@ -160,7 +160,7 @@ The connector kind only sets the starting value, so partial settings mix with it - `{ "variableBatching": false, "requestBatching": false }` on an Apollo Federation subgraph still leaves alias batching on. Declare `"aliasBatching": false` as well to turn batching off completely. > [!NOTE] -> The settings template a Hot Chocolate subgraph exports declares all three flags explicitly, so a gateway that uses it does not fall back to the defaults. +> The settings template a Hot Chocolate subgraph exports declares all three flags explicitly, so a gateway that uses it does not fall back to the defaults. `variableBatching` and `requestBatching` mirror the batching the subgraph's server options allow, which `AddSourceSchemaDefaults()` turns on, and `aliasBatching` is always `true`. # Apollo Federation Subgraphs