diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSourceSchemaInitCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSourceSchemaInitCommand.cs index 8dab57f6118..a2119e27b89 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSourceSchemaInitCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSourceSchemaInitCommand.cs @@ -15,6 +15,7 @@ internal sealed class FusionSourceSchemaInitCommand : Command private const string TransportsPropertyName = "transports"; private const string HttpPropertyName = "http"; private const string CapabilitiesPropertyName = "capabilities"; + private const string BatchingPropertyName = "batching"; private const string ExtensionsPropertyName = "extensions"; private const string ChilliCreamPropertyName = "chillicream"; private const string ApolloFederationSupportPropertyName = "apolloFederationSupport"; @@ -36,23 +37,15 @@ public FusionSourceSchemaInitCommand() : base("init") Options.Add(Opt.Instance); Options.Add(Opt.Instance); Options.Add(Opt.Instance); - Options.Add(Opt.Instance); - Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); Options.Add(Opt.Instance); this.AddGlobalNitroOptions(); - Validators.Add(result => - { - var kind = result.GetValue(Opt.Instance); - var version = result.GetValue(Opt.Instance); - - if (version is not null && kind != SourceSchemaKindOption.ApolloFederation) - { - result.AddError(Messages.ApolloFederationVersionRequiresKind()); - } - }); - this.AddExamples( """ fusion source-schema init \ @@ -104,6 +97,18 @@ private static async Task ExecuteAsync( Messages.TransportUrlInvalid(OptionalTransportUrlOption.OptionName)); } + var schemaType = await ResolveSchemaTypeAsync( + console, + parseResult, + exists, + cancellationToken); + + var batching = await ResolveBatchingSettingsAsync( + console, + parseResult, + exists, + cancellationToken); + settings ??= new JsonObject(); ApplySettings( @@ -113,8 +118,12 @@ private static async Task ExecuteAsync( parseResult.GetValue(Opt.Instance), parseResult.GetValue(Opt.Instance), parseResult.GetValue(Opt.Instance), - parseResult.GetValue(Opt.Instance), - parseResult.GetValue(Opt.Instance)); + schemaType, + batching.VariableBatching, + batching.RequestBatching, + batching.AliasBatching, + batching.Formats, + applyBatchingDefaults: !exists); // composition rejects a settings file without a name, so an existing file that never had // one is not silently written back in the same broken state. @@ -135,6 +144,79 @@ private static async Task ExecuteAsync( return ExitCodes.Success; } + private static bool? GetBooleanOption(ParseResult parseResult, Option option) + => parseResult.GetValue(option) is { } value ? bool.Parse(value) : null; + + private static async Task ResolveBatchingSettingsAsync( + INitroConsole console, + ParseResult parseResult, + bool settingsExist, + CancellationToken cancellationToken) + { + var variableBatching = + GetBooleanOption(parseResult, Opt.Instance); + var requestBatching = + GetBooleanOption(parseResult, Opt.Instance); + var aliasBatching = + GetBooleanOption(parseResult, Opt.Instance); + IReadOnlyList? formats = + parseResult.GetResult(Opt.Instance) + is { Implicit: false } + ? parseResult.GetValue(Opt.Instance) + : null; + + if (settingsExist || !console.IsInteractive) + { + return new(variableBatching, requestBatching, aliasBatching, formats); + } + + variableBatching ??= await console.ConfirmAsync( + "Variable batching", + defaultValue: false, + cancellationToken); + requestBatching ??= await console.ConfirmAsync( + "Request batching", + defaultValue: false, + cancellationToken); + aliasBatching ??= await console.ConfirmAsync( + "Alias batching", + defaultValue: true, + cancellationToken); + + return new(variableBatching, requestBatching, aliasBatching, formats); + } + + private static async Task ResolveSchemaTypeAsync( + INitroConsole console, + ParseResult parseResult, + bool settingsExist, + CancellationToken cancellationToken) + { + var schemaType = parseResult.GetValue(Opt.Instance); + + if (schemaType is not null || settingsExist) + { + return schemaType; + } + + if (!console.IsInteractive) + { + return SourceSchemaTypeOption.GraphQLFederation; + } + + var selected = await console.PromptAsync( + "Source schema type", + ["GraphQL Federation", "Apollo Federation 1", "Apollo Federation 2"], + cancellationToken); + + return selected switch + { + "Apollo Federation 1" => SourceSchemaTypeOption.ApolloFederation1, + "Apollo Federation 2" => SourceSchemaTypeOption.ApolloFederation2, + _ => SourceSchemaTypeOption.GraphQLFederation + }; + } + /// /// Determines the settings file that composition expects for the targeted source schema. /// @@ -244,7 +326,7 @@ static string GetSettingsFile(string schemaFile) /// /// Applies the values that were passed as options. A value that was not passed keeps whatever - /// the settings already hold, except for the settings that owns. + /// the settings already hold, except when new-file defaults are requested. /// private static void ApplySettings( JsonObject settings, @@ -253,17 +335,19 @@ private static void ApplySettings( string? devUrl, string? clientName, string? apiId, - string? kind, - string? apolloFederationVersion) + string? schemaType, + bool? variableBatching, + bool? requestBatching, + bool? aliasBatching, + IReadOnlyList? batchingFormats, + bool applyBatchingDefaults) { if (name is not null) { settings[NamePropertyName] = name; } - var isHotChocolate = kind is SourceSchemaKindOption.HotChocolate; - - if (url is not null || devUrl is not null || clientName is not null || isHotChocolate) + if (url is not null || devUrl is not null || clientName is not null) { var http = GetOrAddObject( GetOrAddObject(settings, TransportsPropertyName), @@ -283,18 +367,41 @@ private static void ApplySettings( { http["clientName"] = clientName; } + } + + if (applyBatchingDefaults + || variableBatching is not null + || requestBatching is not null + || aliasBatching is not null + || batchingFormats is not null) + { + var batching = GetOrAddObject( + GetOrAddObject( + GetOrAddObject( + GetOrAddObject(settings, TransportsPropertyName), + HttpPropertyName), + CapabilitiesPropertyName), + BatchingPropertyName); + + if (variableBatching is not null || applyBatchingDefaults) + { + batching["variableBatching"] = variableBatching ?? false; + } - if (isHotChocolate) + if (requestBatching is not null || applyBatchingDefaults) { - // a Hot Chocolate source schema implements these transport extensions, so the - // gateway is told about them instead of being left on the defaults. - var capabilities = GetOrAddObject(http, CapabilitiesPropertyName); - var batching = GetOrAddObject(capabilities, "batching"); - - batching["variableBatching"] = true; - batching["requestBatching"] = true; - batching["aliasBatching"] = true; - capabilities["onError"] = "propagate"; + batching["requestBatching"] = requestBatching ?? false; + } + + if (aliasBatching is not null || applyBatchingDefaults) + { + batching["aliasBatching"] = aliasBatching ?? true; + } + + if (batchingFormats is not null) + { + batching["formats"] = new JsonArray( + batchingFormats.Select(static format => JsonValue.Create(format)).ToArray()); } } @@ -304,37 +411,22 @@ private static void ApplySettings( apiId; } - if (kind is SourceSchemaKindOption.ApolloFederation) + if (schemaType is SourceSchemaTypeOption.ApolloFederation1 + or SourceSchemaTypeOption.ApolloFederation2) { - // the settings reader rejects this object unless 'version' is its only property, so it - // is replaced rather than merged into. - var version = apolloFederationVersion - ?? GetApolloFederationVersion(settings) - ?? OptionalApolloFederationVersionOption.Version2; + var version = schemaType is SourceSchemaTypeOption.ApolloFederation1 + ? "1.0" + : "2.0"; GetOrAddObject(GetOrAddObject(settings, ExtensionsPropertyName), ChilliCreamPropertyName) [ApolloFederationSupportPropertyName] = new JsonObject { ["version"] = version }; } - else if (kind is not null) + else if (schemaType is SourceSchemaTypeOption.GraphQLFederation) { RemoveApolloFederationSupport(settings); } - - if (kind is not null && !isHotChocolate) - { - RemoveTransportCapabilities(settings); - } } - private static string? GetApolloFederationVersion(JsonObject settings) - => settings[ExtensionsPropertyName] is JsonObject extensions - && extensions[ChilliCreamPropertyName] is JsonObject chilliCream - && chilliCream[ApolloFederationSupportPropertyName] is JsonObject support - && support["version"] is JsonValue version - && version.TryGetValue(out var value) - ? value - : null; - private static void RemoveApolloFederationSupport(JsonObject settings) { if (settings[ExtensionsPropertyName] is not JsonObject extensions @@ -356,15 +448,6 @@ private static void RemoveApolloFederationSupport(JsonObject settings) } } - private static void RemoveTransportCapabilities(JsonObject settings) - { - if (settings[TransportsPropertyName] is JsonObject transports - && transports[HttpPropertyName] is JsonObject http) - { - http.Remove(CapabilitiesPropertyName); - } - } - private static JsonObject GetOrAddObject(JsonObject parent, string propertyName) { if (parent[propertyName] is JsonObject existing) @@ -397,4 +480,10 @@ private static async Task WriteSettingsAsync( settings.WriteTo(writer); await writer.FlushAsync(cancellationToken); } + + private readonly record struct BatchingSettings( + bool? VariableBatching, + bool? RequestBatching, + bool? AliasBatching, + IReadOnlyList? Formats); } diff --git a/src/Nitro/CommandLine/src/CommandLine/Extensions/NitroConsoleExtensions.cs b/src/Nitro/CommandLine/src/CommandLine/Extensions/NitroConsoleExtensions.cs index 7e8754c6066..95a5e5455b7 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Extensions/NitroConsoleExtensions.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Extensions/NitroConsoleExtensions.cs @@ -94,9 +94,16 @@ public static async Task ConfirmAsync( return await console.ConfirmAsync(question, cancellationToken); } + public static Task ConfirmAsync( + this INitroConsole console, + string question, + CancellationToken cancellationToken) + => console.ConfirmAsync(question, defaultValue: true, cancellationToken); + public static async Task ConfirmAsync( this INitroConsole console, string question, + bool defaultValue, CancellationToken cancellationToken) { if (!console.IsInteractive) @@ -105,8 +112,12 @@ public static async Task ConfirmAsync( "Attempted to prompt the user for confirmation, but the console is running in non-interactive mode."); } - return await new ConfirmationPrompt(question.AsQuestion()) - .ShowAsync(console, cancellationToken); + var prompt = new ConfirmationPrompt(question.AsQuestion()) + { + DefaultValue = defaultValue + }; + + return await prompt.ShowAsync(console, cancellationToken); } public static async Task GetOrPromptForApiIdAsync( diff --git a/src/Nitro/CommandLine/src/CommandLine/Messages.cs b/src/Nitro/CommandLine/src/CommandLine/Messages.cs index defd27b36f2..881738634b3 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Messages.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Messages.cs @@ -38,10 +38,6 @@ public static string SourceSchemaSettingsFileNotAnObject(string path) => $"Source schema settings file '{path}' does not contain a JSON object. " + $"Remove it or write to a different path with '{OptionalSettingsFileOption.OptionName}'."; - public static string ApolloFederationVersionRequiresKind() - => $"The option '{OptionalApolloFederationVersionOption.OptionName}' requires " - + $"'{SourceSchemaKindOption.OptionName} {SourceSchemaKindOption.ApolloFederation}'."; - public static string SourceSchemaUrlSettingsCountMismatch() => $"The options '{OptionalSourceSchemaUrlListOption.OptionName}' and " + $"'{OptionalSourceSchemaSettingsFileListOption.OptionName}' must be specified the same number of times."; diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/OptionalAliasBatchingOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalAliasBatchingOption.cs new file mode 100644 index 00000000000..434ac097226 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalAliasBatchingOption.cs @@ -0,0 +1,12 @@ +namespace ChilliCream.Nitro.CommandLine; + +internal sealed class OptionalAliasBatchingOption : Option +{ + public const string OptionName = "--alias-batching"; + + public OptionalAliasBatchingOption() : base(OptionName) + { + Description = "Whether the source schema supports alias batching. Defaults to true"; + AcceptOnlyFromAmong("true", "false"); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/OptionalApolloFederationVersionOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalApolloFederationVersionOption.cs deleted file mode 100644 index e550254fbfa..00000000000 --- a/src/Nitro/CommandLine/src/CommandLine/Options/OptionalApolloFederationVersionOption.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace ChilliCream.Nitro.CommandLine; - -internal sealed class OptionalApolloFederationVersionOption : Option -{ - public const string OptionName = "--apollo-federation-version"; - public const string Version1 = "1.0"; - public const string Version2 = "2.0"; - - public OptionalApolloFederationVersionOption() : base(OptionName) - { - Description = "The Apollo Federation version the source schema is built with"; - AcceptOnlyFromAmong(Version1, Version2); - } -} diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/OptionalBatchingFormatListOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalBatchingFormatListOption.cs new file mode 100644 index 00000000000..0ae7793f7f3 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalBatchingFormatListOption.cs @@ -0,0 +1,31 @@ +using System.Net.Http.Headers; + +namespace ChilliCream.Nitro.CommandLine; + +internal sealed class OptionalBatchingFormatListOption : Option> +{ + public const string OptionName = "--batching-format"; + + public OptionalBatchingFormatListOption() : base(OptionName) + { + Description = "One or more response formats the source schema supports for batching"; + Arity = ArgumentArity.OneOrMore; + AllowMultipleArgumentsPerToken = true; + Validators.Add(result => + { + foreach (var token in result.Tokens) + { + if (!IsValid(token.Value)) + { + result.AddError(FormatError(token.Value)); + } + } + }); + } + + private static bool IsValid(string value) + => MediaTypeHeaderValue.TryParse(value, out _); + + private static string FormatError(string value) + => $"The value '{value}' for '{OptionName}' must be a valid media type."; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/OptionalRequestBatchingOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalRequestBatchingOption.cs new file mode 100644 index 00000000000..0be26949921 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalRequestBatchingOption.cs @@ -0,0 +1,12 @@ +namespace ChilliCream.Nitro.CommandLine; + +internal sealed class OptionalRequestBatchingOption : Option +{ + public const string OptionName = "--request-batching"; + + public OptionalRequestBatchingOption() : base(OptionName) + { + Description = "Whether the source schema supports request batching. Defaults to false"; + AcceptOnlyFromAmong("true", "false"); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/OptionalVariableBatchingOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalVariableBatchingOption.cs new file mode 100644 index 00000000000..0bbcbb29dff --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Options/OptionalVariableBatchingOption.cs @@ -0,0 +1,12 @@ +namespace ChilliCream.Nitro.CommandLine; + +internal sealed class OptionalVariableBatchingOption : Option +{ + public const string OptionName = "--variable-batching"; + + public OptionalVariableBatchingOption() : base(OptionName) + { + Description = "Whether the source schema supports variable batching. Defaults to false"; + AcceptOnlyFromAmong("true", "false"); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/SourceSchemaKindOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/SourceSchemaKindOption.cs deleted file mode 100644 index 32c5750b376..00000000000 --- a/src/Nitro/CommandLine/src/CommandLine/Options/SourceSchemaKindOption.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace ChilliCream.Nitro.CommandLine; - -internal sealed class SourceSchemaKindOption : Option -{ - public const string OptionName = "--kind"; - public const string Generic = "generic"; - public const string HotChocolate = "hot-chocolate"; - public const string ApolloFederation = "apollo-federation"; - - public SourceSchemaKindOption() : base(OptionName) - { - Description = - "The kind of GraphQL server that serves the source schema. " - + "When omitted, kind-specific settings are left unchanged"; - AcceptOnlyFromAmong(Generic, HotChocolate, ApolloFederation); - } -} diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/SourceSchemaTypeOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/SourceSchemaTypeOption.cs new file mode 100644 index 00000000000..585c26dffb8 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Options/SourceSchemaTypeOption.cs @@ -0,0 +1,16 @@ +namespace ChilliCream.Nitro.CommandLine; + +internal sealed class SourceSchemaTypeOption : Option +{ + public const string OptionName = "--schema-type"; + public const string GraphQLFederation = "graphql-federation"; + public const string ApolloFederation1 = "apollo-federation-1"; + public const string ApolloFederation2 = "apollo-federation-2"; + + public SourceSchemaTypeOption() : base(OptionName) + { + Description = + "The source schema specification. New settings default to GraphQL Federation"; + AcceptOnlyFromAmong(GraphQLFederation, ApolloFederation1, ApolloFederation2); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSourceSchemaInitCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSourceSchemaInitCommandTests.cs index d13509cfc19..936339856dd 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSourceSchemaInitCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSourceSchemaInitCommandTests.cs @@ -25,20 +25,23 @@ Create a source schema settings file. nitro fusion source-schema init [options] Options: - --name The name that identifies the source schema in the composite schema - -f, --source-schema-file The path to the source schema file (.graphqls) the settings belong to, or a directory containing it - --settings-file The path to write the settings file to, instead of deriving it from the source schema file - --url The URL the gateway uses to reach the source schema - --dev-url The URL a local development environment uses to reach the source schema - --client-name The name of the HTTP client the gateway uses to reach the source schema - --api-id The ID of the API [env: NITRO_API_ID] - --kind The kind of GraphQL server that serves the source schema. When omitted, kind-specific settings are left unchanged - --apollo-federation-version <1.0|2.0> The Apollo Federation version the source schema is built with - -w, --working-directory Set the working directory for the command - --cloud-url The URL of the Nitro backend (only needed for self-hosted or dedicated deployments) [env: NITRO_CLOUD_URL] - --api-key The API key or PAT used for authentication [env: NITRO_API_KEY] - --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] - -?, -h, --help Show help and usage information + --name The name that identifies the source schema in the composite schema + -f, --source-schema-file The path to the source schema file (.graphqls) the settings belong to, or a directory containing it + --settings-file The path to write the settings file to, instead of deriving it from the source schema file + --url The URL the gateway uses to reach the source schema + --dev-url The URL a local development environment uses to reach the source schema + --client-name The name of the HTTP client the gateway uses to reach the source schema + --api-id The ID of the API [env: NITRO_API_ID] + --schema-type The source schema specification. New settings default to GraphQL Federation + --variable-batching Whether the source schema supports variable batching. Defaults to false + --request-batching Whether the source schema supports request batching. Defaults to false + --alias-batching Whether the source schema supports alias batching. Defaults to true + --batching-format One or more response formats the source schema supports for batching + -w, --working-directory Set the working directory for the command + --cloud-url The URL of the Nitro backend (only needed for self-hosted or dedicated deployments) [env: NITRO_CLOUD_URL] + --api-key The API key or PAT used for authentication [env: NITRO_API_KEY] + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information Example: nitro fusion source-schema init \ @@ -81,7 +84,14 @@ public async Task Init_WithSourceSchemaFile_WritesSettingsNextToSchema() "name": "products", "transports": { "http": { - "url": "https://products.example.com/graphql" + "url": "https://products.example.com/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } } } @@ -120,7 +130,14 @@ public async Task Init_WithSourceSchemaDirectory_WritesSettingsNextToSchemaInDir "name": "products", "transports": { "http": { - "url": "https://products.example.com/graphql" + "url": "https://products.example.com/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } } } @@ -158,7 +175,14 @@ public async Task Init_WithSourceSchemaDirectoryThatDoesNotExistYet_WritesSettin "name": "products", "transports": { "http": { - "url": "https://products.example.com/graphql" + "url": "https://products.example.com/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } } } @@ -198,7 +222,14 @@ public async Task Init_WithSettingsFile_WritesSettingsToGivenPath() "name": "products", "transports": { "http": { - "url": "https://products.example.com/graphql" + "url": "https://products.example.com/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } } } @@ -259,7 +290,14 @@ public async Task Init_WithoutSourceSchemaFile_WritesSettingsToWorkingDirectory( "name": "reviews", "transports": { "http": { - "url": "http://localhost:5000/graphql" + "url": "http://localhost:5000/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } } } @@ -304,7 +342,14 @@ public async Task Init_WithAllTransportOptions_WritesCompleteSettings() "http": { "url": "{{API_URL}}", "devUrl": "http://localhost:5110/graphql", - "clientName": "fusion" + "clientName": "fusion", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } }, "extensions": { @@ -317,7 +362,7 @@ public async Task Init_WithAllTransportOptions_WritesCompleteSettings() } [Fact] - public async Task Init_WithHotChocolateKind_WritesTransportCapabilities() + public async Task Init_Should_WriteDefaultBatchingCapabilities_When_SettingsAreNew() { // arrange SetupNoAuthentication(); @@ -332,9 +377,7 @@ public async Task Init_WithHotChocolateKind_WritesTransportCapabilities() "--name", "products", "--url", - "http://localhost:5000/graphql", - "--kind", - "hot-chocolate"); + "http://localhost:5000/graphql"); // assert result.AssertSuccess( @@ -351,11 +394,10 @@ public async Task Init_WithHotChocolateKind_WritesTransportCapabilities() "url": "http://localhost:5000/graphql", "capabilities": { "batching": { - "variableBatching": true, - "requestBatching": true, + "variableBatching": false, + "requestBatching": false, "aliasBatching": true - }, - "onError": "propagate" + } } } } @@ -364,7 +406,7 @@ public async Task Init_WithHotChocolateKind_WritesTransportCapabilities() } [Fact] - public async Task Init_WithApolloFederationKind_WritesFederationSupport() + public async Task Init_Should_WriteFederation1Support_When_SchemaTypeIsApolloFederation1() { // arrange SetupNoAuthentication(); @@ -380,10 +422,8 @@ public async Task Init_WithApolloFederationKind_WritesFederationSupport() "products", "--url", "http://localhost:5000/graphql", - "--kind", - "apollo-federation", - "--apollo-federation-version", - "1.0"); + "--schema-type", + "apollo-federation-1"); // assert result.AssertSuccess( @@ -397,7 +437,14 @@ public async Task Init_WithApolloFederationKind_WritesFederationSupport() "name": "products", "transports": { "http": { - "url": "http://localhost:5000/graphql" + "url": "http://localhost:5000/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } }, "extensions": { @@ -412,7 +459,7 @@ public async Task Init_WithApolloFederationKind_WritesFederationSupport() } [Fact] - public async Task Init_WithApolloFederationKind_DefaultsToVersion2() + public async Task Init_Should_WriteFederation2Support_When_SchemaTypeIsApolloFederation2() { // arrange SetupNoAuthentication(); @@ -428,8 +475,8 @@ public async Task Init_WithApolloFederationKind_DefaultsToVersion2() "products", "--url", "http://localhost:5000/graphql", - "--kind", - "apollo-federation"); + "--schema-type", + "apollo-federation-2"); // assert result.AssertSuccess( @@ -443,7 +490,14 @@ public async Task Init_WithApolloFederationKind_DefaultsToVersion2() "name": "products", "transports": { "http": { - "url": "http://localhost:5000/graphql" + "url": "http://localhost:5000/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } }, "extensions": { @@ -539,8 +593,6 @@ public async Task Init_ExistingApolloFederationSettings_KeepsConfiguredVersion() "fusion", "source-schema", "init", - "--kind", - "apollo-federation", "--url", "https://products.example.com/graphql"); @@ -568,7 +620,7 @@ public async Task Init_ExistingApolloFederationSettings_KeepsConfiguredVersion() } [Fact] - public async Task Init_ChangingKindToGeneric_RemovesKindSpecificSettings() + public async Task Init_Should_PreserveBatchingSettings_When_ChangingToGraphQLFederation() { // arrange SetupNoAuthentication(); @@ -580,7 +632,14 @@ public async Task Init_ChangingKindToGeneric_RemovesKindSpecificSettings() "transports": { "http": { "url": "http://localhost:5110/graphql", - "capabilities": { "onError": "propagate" } + "capabilities": { + "batching": { + "variableBatching": true, + "requestBatching": true, + "aliasBatching": false, + "formats": ["application/jsonl"] + } + } } }, "extensions": { @@ -596,8 +655,8 @@ public async Task Init_ChangingKindToGeneric_RemovesKindSpecificSettings() "fusion", "source-schema", "init", - "--kind", - "generic"); + "--schema-type", + "graphql-federation"); // assert Assert.Equal(0, result.ExitCode); @@ -608,7 +667,17 @@ public async Task Init_ChangingKindToGeneric_RemovesKindSpecificSettings() "name": "products", "transports": { "http": { - "url": "http://localhost:5110/graphql" + "url": "http://localhost:5110/graphql", + "capabilities": { + "batching": { + "variableBatching": true, + "requestBatching": true, + "aliasBatching": false, + "formats": [ + "application/jsonl" + ] + } + } } }, "extensions": { @@ -646,8 +715,8 @@ public async Task Init_ExistingApolloFederationSupportWithExtraProperties_Writes "fusion", "source-schema", "init", - "--kind", - "apollo-federation"); + "--schema-type", + "apollo-federation-2"); // assert Assert.Equal(0, result.ExitCode); @@ -806,10 +875,12 @@ public async Task Init_InvalidUrl_ReturnsError() } [Fact] - public async Task Init_ApolloFederationVersionWithoutMatchingKind_ReturnsError() + public async Task Init_Should_WriteConfiguredBatching_When_BatchingOptionsArePassed() { // arrange SetupNoAuthentication(); + var capturedStream = SetupCreateFile("schema-settings.json"); + SetupDirectory(""); // act var result = await ExecuteCommandAsync( @@ -818,19 +889,47 @@ public async Task Init_ApolloFederationVersionWithoutMatchingKind_ReturnsError() "init", "--name", "products", - "--apollo-federation-version", - "2.0"); + "--url", + "http://localhost:5000/graphql", + "--variable-batching", + "true", + "--request-batching", + "true", + "--alias-batching", + "false", + "--batching-format", + "application/jsonl", + "multipart/mixed"); // assert - result.StdErr.MatchInlineSnapshot( + Assert.Equal(0, result.ExitCode); + + Encoding.UTF8.GetString(capturedStream.ToArray()).MatchInlineSnapshot( """ - The option '--apollo-federation-version' requires '--kind apollo-federation'. + { + "name": "products", + "transports": { + "http": { + "url": "http://localhost:5000/graphql", + "capabilities": { + "batching": { + "variableBatching": true, + "requestBatching": true, + "aliasBatching": false, + "formats": [ + "application/jsonl", + "multipart/mixed" + ] + } + } + } + } + } """); - Assert.Equal(1, result.ExitCode); } [Fact] - public async Task Init_Interactive_PromptsForNameAndUrl() + public async Task Init_Should_PromptForSettings_When_Interactive() { // arrange SetupNoAuthentication(); @@ -845,6 +944,10 @@ public async Task Init_Interactive_PromptsForNameAndUrl() // act command.Input("products"); command.Input("https://products.example.com/graphql"); + command.SelectOption(0); + command.Confirm(true); + command.Confirm(true); + command.Confirm(false); var result = await command.RunToCompletionAsync(TestContext.Current.CancellationToken); @@ -857,7 +960,14 @@ public async Task Init_Interactive_PromptsForNameAndUrl() "name": "products", "transports": { "http": { - "url": "https://products.example.com/graphql" + "url": "https://products.example.com/graphql", + "capabilities": { + "batching": { + "variableBatching": true, + "requestBatching": true, + "aliasBatching": false + } + } } } } diff --git a/website/content/docs/nitro/cli/fusion.md b/website/content/docs/nitro/cli/fusion.md index 96a8bc24111..09d872b31da 100644 --- a/website/content/docs/nitro/cli/fusion.md +++ b/website/content/docs/nitro/cli/fusion.md @@ -417,7 +417,7 @@ For examples of node resolution, shareable runtime type routing, and tag exclusi Create the `schema-settings.json` file that a source schema needs for composition. The settings file is written next to the schema file it belongs to, under the name composition looks for, so `nitro fusion compose` picks it up without further configuration. -Running the command against an existing settings file updates the values you pass and preserves every other setting in the file, which makes it safe to re-run from a pipeline. The one exception is `--kind`, which owns the settings described under [Source schema kinds](#source-schema-kinds). +Running the command against an existing settings file updates the values you pass and preserves every other setting in the file, which makes it safe to re-run from a pipeline. ```shell nitro fusion source-schema init [options] @@ -425,38 +425,57 @@ nitro fusion source-schema init [options] ## Options -| Option | Env | Description | -| --------------------------------------- | -------------- | --------------------------------------------------------------------------------------------- | -| `--name ` | | Name that identifies the source schema in the composite schema. Required for a new file. | -| `-f, --source-schema-file ` | | Source schema file (`.graphqls`), or a directory containing one, that the settings belong to. | -| `--settings-file ` | | Write the settings to this path instead of deriving it from the schema file. | -| `--url ` | | URL the gateway uses to reach the source schema. Required for a new file. | -| `--dev-url ` | | URL a local development environment uses to reach the source schema. | -| `--client-name ` | | Name of the HTTP client the gateway uses to reach the source schema. | -| `--api-id ` | `NITRO_API_ID` | Nitro Cloud API identifier, written to `extensions.nitro.apiId`. | -| `--kind ` | | `generic`, `hot-chocolate`, or `apollo-federation`. See below. | -| `--apollo-federation-version ` | | `1.0` or `2.0`. Requires `--kind apollo-federation`. | -| `-w, --working-directory ` | | Working directory for the command. | +| Option | Env | Description | +| --------------------------------- | -------------- | --------------------------------------------------------------------------------------------- | +| `--name ` | | Name that identifies the source schema in the composite schema. Required for a new file. | +| `-f, --source-schema-file ` | | Source schema file (`.graphqls`), or a directory containing one, that the settings belong to. | +| `--settings-file ` | | Write the settings to this path instead of deriving it from the schema file. | +| `--url ` | | URL the gateway uses to reach the source schema. Required for a new file. | +| `--dev-url ` | | URL a local development environment uses to reach the source schema. | +| `--client-name ` | | Name of the HTTP client the gateway uses to reach the source schema. | +| `--api-id ` | `NITRO_API_ID` | Nitro Cloud API identifier, written to `extensions.nitro.apiId`. | +| `--schema-type ` | | `graphql-federation`, `apollo-federation-1`, or `apollo-federation-2`. | +| `--variable-batching ` | | Whether the source schema supports variable batching. Defaults to `false` for a new file. | +| `--request-batching ` | | Whether the source schema supports request batching. Defaults to `false` for a new file. | +| `--alias-batching ` | | Whether the source schema supports alias batching. Defaults to `true` for a new file. | +| `--batching-format ...` | | One or more response media types supported for batching, such as `application/jsonl`. | +| `-w, --working-directory ` | | Working directory for the command. | `--name` and `--url` are only required when the settings file does not exist yet, since an existing file already carries both. On an interactive terminal the command asks for whichever of the two you did not pass. Everywhere else, including CI, omitting one fails rather than guessing a value. +New settings default to GraphQL Federation. An interactive terminal asks for the schema type and preselects GraphQL Federation. It also asks whether variable, request, and alias batching are supported. Existing settings keep their schema type unless `--schema-type` is passed. + Both `--url` and `--dev-url` accept `{{VARIABLE_NAME}}` placeholders, which composition resolves against the [`environments`](../../fusion/cli.md#environments) section of the settings file. -## Source schema kinds +## Source schema types + +`--schema-type` describes the specification implemented by the source schema. + +| Type | What it declares | +| --------------------- | ---------------------------------------------------------------------------------------------------------- | +| `graphql-federation` | GraphQL Federation semantics from the Composite Schemas Specification. This is the default used by Fusion. | +| `apollo-federation-1` | Apollo Federation v1 semantics through `extensions.chillicream.apolloFederationSupport.version`. | +| `apollo-federation-2` | Apollo Federation v2 semantics through `extensions.chillicream.apolloFederationSupport.version`. | + +Selecting an Apollo Federation type writes the corresponding `1.0` or `2.0` marker. Selecting `graphql-federation` removes that marker. Schema-type changes do not modify transport capabilities. + +## Batching capabilities -`--kind` describes the server behind the source schema, which decides what the file declares beyond the name and transport URL. +New settings declare the following batching capabilities: -| Kind | What it declares | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `generic` | Nothing beyond the name and transport. Use this when the server's transport capabilities are unknown. | -| `hot-chocolate` | The batching capabilities and error behavior a Hot Chocolate server implements, matching what `dotnet run -- schema export` writes. | -| `apollo-federation` | `extensions.chillicream.apolloFederationSupport.version`, which selects Apollo Federation v1 or v2 directive semantics during composition. | +```json +{ + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true +} +``` -`--kind` owns these settings: passing it removes the settings belonging to the other kinds, so switching a source schema from one kind to another leaves no stale markers behind. Omit `--kind` to leave them untouched. +Pass the corresponding batching option with `true` or `false` to override a value. Pass one or more media types to `--batching-format` to write the `formats` array. When updating an existing file, omitted batching options preserve their current values. -`--apollo-federation-version` defaults to `2.0` for a source schema that is not yet marked as an Apollo Federation subgraph. For one that already is, the version already in the file is kept unless you pass a new one. +In interactive mode, pressing Enter accepts `false` for variable batching, `false` for request batching, and `true` for alias batching. Batching response formats can only be configured explicitly with `--batching-format`. -> A source schema marked `apollo-federation` that composition fetches over HTTP (via `--source-schema-url` on `nitro fusion compose`) is retrieved through Apollo Federation's `_service` field. A source schema read from a local file is read as it is on disk, and the marker only affects directive semantics. +> A source schema configured with an Apollo Federation type that composition fetches over HTTP (via `--source-schema-url` on `nitro fusion compose`) is retrieved through Apollo Federation's `_service` field. A source schema read from a local file is read as it is on disk, and the marker only affects directive semantics. ## Where the file is written @@ -484,7 +503,14 @@ This writes `./products/schema-settings.json`: "name": "products", "transports": { "http": { - "url": "https://products.example.com/graphql" + "url": "https://products.example.com/graphql", + "capabilities": { + "batching": { + "variableBatching": false, + "requestBatching": false, + "aliasBatching": true + } + } } } } @@ -497,8 +523,7 @@ nitro fusion source-schema init \ --name "reviews" \ --source-schema-file ./reviews/schema.graphqls \ --url "https://reviews.example.com/graphql" \ - --kind apollo-federation \ - --apollo-federation-version 2.0 + --schema-type apollo-federation-2 ``` Point an existing settings file at a new URL without touching its other settings: