diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs index ebf7a116bb1..9bdc2bd764c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/AspireCompositionHelper.cs @@ -213,16 +213,23 @@ internal static CompositionSettings CreateCompositionSettings( { CacheControlMergeBehavior = settings.CacheControlMergeBehavior, EnableGlobalObjectIdentification = settings.EnableGlobalObjectIdentification, + EnumValuesMergeBehavior = settings.EnumValuesMergeBehavior, NodeResolution = settings.NodeResolution, TagMergeBehavior = settings.TagMergeBehavior }, - Satisfiability = { IncludeSatisfiabilityPaths = settings.IncludeSatisfiabilityPaths }, + Satisfiability = + { + IncludeSatisfiabilityPaths = settings.IncludeSatisfiabilityPaths + }, ApolloFederationCompatibility = { AllowNonResolvableInterfaceObjects = settings.AllowNonResolvableInterfaceObjects, ShareableFieldRuntimeTypeRouting = settings.ShareableFieldRuntimeTypeRouting }, - Preprocessor = { ExcludeByTag = settings.ExcludeByTag?.ToHashSet() } + Preprocessor = + { + ExcludeByTag = settings.ExcludeByTag?.ToHashSet() + } }; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLCompositionSettings.cs b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLCompositionSettings.cs index edb8eaac2e9..7cd5b684caa 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLCompositionSettings.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Aspire/GraphQLCompositionSettings.cs @@ -22,6 +22,12 @@ public struct GraphQLCompositionSettings /// public bool? EnableGlobalObjectIdentification { get; set; } + /// + /// Gets or sets how enum values are merged when the same enum type is defined in multiple + /// source schemas. + /// + public EnumValuesMergeBehavior? EnumValuesMergeBehavior { get; set; } + /// /// Gets or sets how the gateway resolves the Query.node field. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs index f376e54d757..13a697635f0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Logging/LogEntryCodes.cs @@ -44,6 +44,7 @@ public static class LogEntryCodes public const string InterfaceObjectKeyMismatch = "INTERFACE_OBJECT_KEY_MISMATCH"; public const string InterfaceObjectKeyMissing = "INTERFACE_OBJECT_KEY_MISSING"; public const string InterfaceObjectNoInterface = "INTERFACE_OBJECT_NO_INTERFACE"; + public const string InvalidEnumValuesMergeBehavior = "INVALID_ENUM_VALUES_MERGE_BEHAVIOR"; public const string InvalidFieldSharing = "INVALID_FIELD_SHARING"; public const string InvalidGraphQL = "INVALID_GRAPHQL"; public const string InvalidApolloFederationSupportSettings = diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/EnumValuesMergeBehavior.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/EnumValuesMergeBehavior.cs new file mode 100644 index 00000000000..4ad40f58052 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/EnumValuesMergeBehavior.cs @@ -0,0 +1,25 @@ +namespace HotChocolate.Fusion.Options; + +/// +/// Defines how enum values are merged when the same enum type is defined in multiple source +/// schemas. +/// +public enum EnumValuesMergeBehavior +{ + /// + /// Applies , unless at least one Apollo Federation connector source + /// schema is part of the composition, in which case applies. + /// + Auto = 0, + + /// + /// Enum types with the same name must define identical value sets. + /// + Strict = 1, + + /// + /// Values of enums used only in output positions are merged by union. Enums used in any + /// input position still require identical value sets. + /// + Union = 2 +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SourceSchemaMergerOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SourceSchemaMergerOptions.cs index 1462b2df2c5..fbdbfd389a2 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SourceSchemaMergerOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Options/SourceSchemaMergerOptions.cs @@ -20,6 +20,12 @@ public sealed class SourceSchemaMergerOptions /// public bool EnableGlobalObjectIdentification { get; set; } + /// + /// Defines how enum values are merged when the same enum type is defined in multiple source + /// schemas. + /// + public EnumValuesMergeBehavior EnumValuesMergeBehavior { get; set; } = EnumValuesMergeBehavior.Auto; + /// /// Defines how the gateway resolves the Query.node field. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/EnumValuesMismatchRule.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/EnumValuesMismatchRule.cs index cdf80f6bdf8..96c605067dd 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/EnumValuesMismatchRule.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/PreMergeValidationRules/EnumValuesMismatchRule.cs @@ -1,7 +1,12 @@ using System.Collections.Immutable; +using HotChocolate.Fusion.ApolloFederation; using HotChocolate.Fusion.Events; using HotChocolate.Fusion.Events.Contracts; using HotChocolate.Fusion.Extensions; +using HotChocolate.Fusion.Info; +using HotChocolate.Fusion.Options; +using HotChocolate.Types; +using HotChocolate.Types.Mutable; using static HotChocolate.Fusion.Logging.LogEntryHelper; namespace HotChocolate.Fusion.PreMergeValidationRules; @@ -19,21 +24,36 @@ namespace HotChocolate.Fusion.PreMergeValidationRules; /// inconsistencies by enforcing that all instances of the same named enum across schemas have an /// exact match in their values. /// +/// +/// The configured merge behavior is provided at construction. Under +/// , values of enums used only in output positions +/// merge by union. applies Union when at least one +/// Apollo Federation connector source schema is part of the composition, otherwise Strict. +/// /// /// /// Specification /// -internal sealed class EnumValuesMismatchRule : IEventHandler +internal sealed class EnumValuesMismatchRule(EnumValuesMergeBehavior enumValuesMergeBehavior) + : IEventHandler { + private readonly EnumValuesMergeBehavior _enumValuesMergeBehavior = enumValuesMergeBehavior; + public void Handle(EnumTypeGroupEvent @event, CompositionContext context) { - var (_, enumGroup) = @event; + var (typeName, enumGroup) = @event; if (enumGroup.Length < 2) { return; } + if (ResolveMergeBehavior(context) is EnumValuesMergeBehavior.Union + && IsOutputOnlyEnum(typeName, enumGroup)) + { + return; + } + var enumValues = enumGroup .SelectMany(e => e.Type.Values.AsEnumerable()) .Where(v => !v.IsInaccessible) @@ -51,4 +71,82 @@ public void Handle(EnumTypeGroupEvent @event, CompositionContext context) } } } + + private EnumValuesMergeBehavior ResolveMergeBehavior(CompositionContext context) + { + if (_enumValuesMergeBehavior is not EnumValuesMergeBehavior.Auto) + { + return _enumValuesMergeBehavior; + } + + foreach (var schema in context.SchemaDefinitions) + { + if (schema.Features.Get()?.Kind == "ApolloFederation") + { + return EnumValuesMergeBehavior.Union; + } + } + + return EnumValuesMergeBehavior.Strict; + } + + private static bool IsOutputOnlyEnum(string typeName, ImmutableArray enumGroup) + { + foreach (var (_, schema) in enumGroup) + { + if (IsUsedInInputPosition(typeName, schema)) + { + return false; + } + } + + return true; + } + + private static bool IsUsedInInputPosition(string typeName, MutableSchemaDefinition schema) + { + foreach (var type in schema.Types) + { + switch (type) + { + case MutableComplexTypeDefinition complexType: + foreach (var field in complexType.Fields) + { + foreach (var argument in field.Arguments) + { + if (argument.Type.NamedType().Name == typeName) + { + return true; + } + } + } + + break; + + case MutableInputObjectTypeDefinition inputType: + foreach (var field in inputType.Fields) + { + if (field.Type.NamedType().Name == typeName) + { + return true; + } + } + + break; + } + } + + foreach (var directiveDefinition in schema.DirectiveDefinitions) + { + foreach (var argument in directiveDefinition.Arguments) + { + if (argument.Type.NamedType().Name == typeName) + { + return true; + } + } + } + + return false; + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs index 811ba2397a0..0b6924fe691 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SchemaComposer.cs @@ -36,6 +36,12 @@ public SchemaComposer( public CompositionResult Compose() { + if (!Enum.IsDefined(_schemaComposerOptions.Merger.EnumValuesMergeBehavior)) + { + return InvalidEnumValuesMergeBehavior( + $"The enum values merge behavior '{_schemaComposerOptions.Merger.EnumValuesMergeBehavior}' is invalid."); + } + if (!Enum.IsDefined(_schemaComposerOptions.Merger.NodeResolution)) { return InvalidNodeResolution( @@ -159,7 +165,8 @@ public CompositionResult Compose() // Pre Merge Validation var preMergeValidationResult = - new PreMergeValidator(schemas, PreMergeRules, _log).Validate(); + new PreMergeValidator(schemas, CreatePreMergeRules(_schemaComposerOptions), _log) + .Validate(); if (preMergeValidationResult.IsFailure) { @@ -207,6 +214,18 @@ public CompositionResult Compose() return mergedSchema; } + private CompositionError InvalidEnumValuesMergeBehavior(string message) + { + _log.Write( + LogEntryBuilder.New() + .SetMessage(message) + .SetCode(LogEntryCodes.InvalidEnumValuesMergeBehavior) + .SetSeverity(LogSeverity.Error) + .Build()); + + return new CompositionError(message); + } + private CompositionError InvalidNodeResolution(string message) { _log.Write( @@ -276,12 +295,12 @@ private CompositionError InvalidShareableFieldRuntimeTypeRouting(string message) ]; /// - /// Gets the rules that run across the source schemas before the merge, in the order they are - /// applied. + /// Creates the rules that run across the source schemas before the merge, in the order they + /// are applied, configured from the given options. /// - internal static ImmutableArray PreMergeRules { get; } = + internal static ImmutableArray CreatePreMergeRules(SchemaComposerOptions options) => [ - new EnumValuesMismatchRule(), + new EnumValuesMismatchRule(options.Merger.EnumValuesMergeBehavior), new ExternalArgumentDefaultMismatchRule(), new ExternalArgumentMissingRule(), new ExternalArgumentTypeMismatchRule(), diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/CompositionSettings.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/CompositionSettings.cs index 105753130fc..c2b28df7ea8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/CompositionSettings.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/CompositionSettings.cs @@ -42,6 +42,8 @@ internal sealed record MergerSettings public bool? EnableGlobalObjectIdentification { get; set; } + public EnumValuesMergeBehavior? EnumValuesMergeBehavior { get; set; } + public NodeResolution? NodeResolution { get; set; } public bool? RemoveUnreferencedDefinitions { get; init; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsExtensions.cs index fcc54ff73de..cd5274f7196 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsExtensions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsExtensions.cs @@ -21,6 +21,9 @@ public CompositionSettings MergeInto(CompositionSettings settings) EnableGlobalObjectIdentification = compositionSettings.Merger.EnableGlobalObjectIdentification ?? settings.Merger.EnableGlobalObjectIdentification, + EnumValuesMergeBehavior = + compositionSettings.Merger.EnumValuesMergeBehavior + ?? settings.Merger.EnumValuesMergeBehavior, NodeResolution = compositionSettings.Merger.NodeResolution ?? settings.Merger.NodeResolution, @@ -113,6 +116,11 @@ public SourceSchemaMergerOptions ToOptions() mergerOptions.EnableGlobalObjectIdentification = enableGlobalObjectIdentification; } + if (mergerSettings.EnumValuesMergeBehavior is { } enumValuesMergeBehavior) + { + mergerOptions.EnumValuesMergeBehavior = enumValuesMergeBehavior; + } + if (mergerSettings.NodeResolution is { } nodeResolution) { mergerOptions.NodeResolution = nodeResolution; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsJsonSerializerContext.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsJsonSerializerContext.cs index 6a440f09501..a1e5a840d47 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsJsonSerializerContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Settings/SettingsJsonSerializerContext.cs @@ -16,6 +16,7 @@ namespace HotChocolate.Fusion; Converters = [ typeof(JsonStringEnumConverter), + typeof(JsonStringEnumConverter), typeof(JsonStringEnumConverter), typeof(JsonStringEnumConverter) ], diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs index 1925862c1d8..853bc750973 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/AspireCompositionHelperTests.cs @@ -75,11 +75,11 @@ public void CreateCompositionSettings_Should_MapAllUserFacingSettings() AllowNonResolvableInterfaceObjects = true, CacheControlMergeBehavior = DirectiveMergeBehavior.IncludePrivate, EnableGlobalObjectIdentification = true, + EnumValuesMergeBehavior = EnumValuesMergeBehavior.Union, ExcludeByTag = new HashSet { "internal" }, IncludeSatisfiabilityPaths = false, NodeResolution = NodeResolution.SourceSchema, - ShareableFieldRuntimeTypeRouting = - ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, + ShareableFieldRuntimeTypeRouting = ShareableFieldRuntimeTypeRouting.CommonRuntimeTypes, TagMergeBehavior = DirectiveMergeBehavior.Include }; var compositionSettings = AspireCompositionHelper.CreateCompositionSettings(settings); @@ -102,6 +102,7 @@ public void CreateCompositionSettings_Should_MapAllUserFacingSettings() "addFusionDefinitions": null, "cacheControlMergeBehavior": "IncludePrivate", "enableGlobalObjectIdentification": true, + "enumValuesMergeBehavior": "Union", "nodeResolution": "SourceSchema", "removeUnreferencedDefinitions": null, "tagMergeBehavior": "Include" diff --git a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaCompositionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaCompositionTests.cs index 3e871c9d34e..f4391d23037 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaCompositionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Aspire.Tests/Nitro/NitroSchemaCompositionTests.cs @@ -185,6 +185,7 @@ await harness.Composition.ComposeOnGatewayStartAsync( "addFusionDefinitions": null, "cacheControlMergeBehavior": "Include", "enableGlobalObjectIdentification": false, + "enumValuesMergeBehavior": null, "nodeResolution": "Gateway", "removeUnreferencedDefinitions": false, "tagMergeBehavior": "IncludePrivate" diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/ApolloFederation/ApolloFederationEnumValuesMismatchRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/ApolloFederation/ApolloFederationEnumValuesMismatchRuleTests.cs new file mode 100644 index 00000000000..7e4cdfaba83 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/ApolloFederation/ApolloFederationEnumValuesMismatchRuleTests.cs @@ -0,0 +1,507 @@ +using HotChocolate.Fusion.Options; +using HotChocolate.Fusion.PreMergeValidationRules; + +namespace HotChocolate.Fusion.ApolloFederation; + +public sealed class ApolloFederationEnumValuesMismatchRuleTests : RuleTestBase +{ + protected override object Rule { get; } = + new EnumValuesMismatchRule(EnumValuesMergeBehavior.Auto); + + // Here, two Apollo subgraphs define "OrderPriority" with differing values, but the enum is + // only used in output positions, so Apollo composition merges the values by union. + [Fact] + public void Validate_Should_Succeed_When_OutputOnlyEnumValuesDifferAcrossApolloSubgraphs() + { + AssertValid( + [ + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + } + + type Order @key(fields: "id") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """, + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + trackedOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + fulfillmentPriority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """ + ]); + } + + // Here, each Apollo subgraph declares a value for "OrderPriority" that the other lacks, but + // the enum is only used in output positions, so the values are merged by union. + [Fact] + public void Validate_Should_Succeed_When_OutputOnlyEnumValuesDifferSymmetricallyAcrossApolloSubgraphs() + { + AssertValid( + [ + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + } + + type Order @key(fields: "id") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + RUSH + } + """, + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + trackedOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + fulfillmentPriority: OrderPriority + } + + enum OrderPriority { + LOW + EXPRESS + } + """ + ]); + } + + // Here, the enum is used as a field argument type in one Apollo subgraph, so the + // exact-agreement requirement applies and the differing values are reported. + [Fact] + public void Validate_Should_Fail_When_InputUsedEnumValuesDifferAcrossApolloSubgraphs() + { + AssertInvalid( + [ + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + ordersByPriority(priority: OrderPriority!): [Order!] + } + + type Order @key(fields: "id") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """, + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + trackedOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + fulfillmentPriority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """ + ], + [ + """ + { + "message": "The enum type 'OrderPriority' in schema 'B' must define the value 'RUSH'.", + "code": "ENUM_VALUES_MISMATCH", + "severity": "Error", + "coordinate": "OrderPriority", + "member": "OrderPriority", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + // Here, the enum-typed field is part of the entity key, so the generated internal lookup + // field uses the enum as an argument type and the differing values are reported. + [Fact] + public void Validate_Should_Fail_When_KeyFieldEnumValuesDifferAcrossApolloSubgraphs() + { + AssertInvalid( + [ + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + } + + type Order @key(fields: "id priority") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """, + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + trackedOrder: Order + } + + type Order @key(fields: "id priority") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """ + ], + [ + """ + { + "message": "The enum type 'OrderPriority' in schema 'B' must define the value 'RUSH'.", + "code": "ENUM_VALUES_MISMATCH", + "severity": "Error", + "coordinate": "OrderPriority", + "member": "OrderPriority", + "schema": "B", + "extensions": {} + } + """ + ]); + } + + // Here, one Apollo subgraph and one plain subgraph define the enum with differing values. + // The Apollo subgraph resolves the automatic merge behavior to union for the whole + // composition, so the output-only enum composes. + [Fact] + public void Validate_Should_Succeed_When_OutputOnlyEnumValuesDifferAcrossMixedSubgraphs() + { + AssertValid( + [ + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + } + + type Order @key(fields: "id") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """, + """ + type Query { + archivedOrder: Order + } + + type Order { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """ + ]); + } + + // Here, two plain subgraphs define the enum with differing values while a third Apollo + // subgraph does not define it at all. The Apollo subgraph still resolves the automatic + // merge behavior to union for the whole composition. + [Fact] + public void Validate_Should_Succeed_When_OutputOnlyEnumValuesDifferAndAnyApolloSubgraphPresent() + { + AssertValid( + [ + """ + type Query { + orderById: Order + } + + type Order { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """, + """ + type Query { + archivedOrder: Order + } + + type Order { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """, + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + shipmentById: Shipment + } + + type Shipment @key(fields: "id") { + id: ID! + carrier: String + } + """ + ]); + } + + // Here, the explicit strict merge behavior overrules the Apollo subgraph detection, so the + // differing values are reported. + [Fact] + public void Validate_Should_Fail_When_OutputOnlyEnumValuesDifferAndStrictMergeBehavior() + { + AssertInvalid( + [ + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + } + + type Order @key(fields: "id") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """, + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + trackedOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + fulfillmentPriority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """ + ], + [ + """ + { + "message": "The enum type 'OrderPriority' in schema 'B' must define the value 'RUSH'.", + "code": "ENUM_VALUES_MISMATCH", + "severity": "Error", + "coordinate": "OrderPriority", + "member": "OrderPriority", + "schema": "B", + "extensions": {} + } + """ + ], + new EnumValuesMismatchRule(EnumValuesMergeBehavior.Strict)); + } + + // Here, the explicit union merge behavior applies even though no Apollo subgraph is part of + // the composition, so the output-only enum composes. + [Fact] + public void Validate_Should_Succeed_When_OutputOnlyEnumValuesDifferAndUnionMergeBehavior() + { + AssertValid( + [ + """ + type Query { + orderById: Order + } + + type Order { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """, + """ + type Query { + archivedOrder: Order + } + + type Order { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """ + ], + new EnumValuesMismatchRule(EnumValuesMergeBehavior.Union)); + } + + // Here, the enum is used as a field argument type, so the exact-agreement requirement + // applies even under the explicit union merge behavior. + [Fact] + public void Validate_Should_Fail_When_InputUsedEnumValuesDifferAndUnionMergeBehavior() + { + AssertInvalid( + [ + """ + type Query { + orderById: Order + ordersByPriority(priority: OrderPriority!): [Order!] + } + + type Order { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """, + """ + type Query { + archivedOrder: Order + } + + type Order { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """ + ], + [ + """ + { + "message": "The enum type 'OrderPriority' in schema 'B' must define the value 'RUSH'.", + "code": "ENUM_VALUES_MISMATCH", + "severity": "Error", + "coordinate": "OrderPriority", + "member": "OrderPriority", + "schema": "B", + "extensions": {} + } + """ + ], + new EnumValuesMismatchRule(EnumValuesMergeBehavior.Union)); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionApiTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionApiTests.cs index 60210588d0d..ddcb50256bb 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionApiTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionApiTests.cs @@ -24,6 +24,7 @@ public void CompositionAssembly_Should_ExposeSupportedModels_When_InspectingPubl HotChocolate.Fusion.Logging.LogSeverity HotChocolate.Fusion.Options.ApolloFederationCompatibilityOptions HotChocolate.Fusion.Options.DirectiveMergeBehavior + HotChocolate.Fusion.Options.EnumValuesMergeBehavior HotChocolate.Fusion.Options.SatisfiabilityOptions HotChocolate.Fusion.Options.SchemaComposerOptions HotChocolate.Fusion.Options.SourceSchemaMergerOptions diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionSettingsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionSettingsTests.cs index 5f77e774604..0fb1f889b33 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionSettingsTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/CompositionSettingsTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using HotChocolate.Fusion.Options; namespace HotChocolate.Fusion; @@ -115,6 +116,78 @@ public void MergeInto_Should_PreserveExistingNodeResolution_When_OverrideIsUnset Assert.Equal(NodeResolution.SourceSchema, merged.Merger.NodeResolution); } + [Fact] + public void EnumValuesMergeBehavior_Should_RoundTripAsString_When_Serialized() + { + // arrange + var settings = new CompositionSettings + { + Merger = { EnumValuesMergeBehavior = EnumValuesMergeBehavior.Union } + }; + + // act + using var document = JsonSerializer.SerializeToDocument( + settings, + SettingsJsonSerializerContext.Default.CompositionSettings); + var roundTripped = document.Deserialize( + SettingsJsonSerializerContext.Default.CompositionSettings); + + // assert + Assert.Equal( + "Union", + document.RootElement + .GetProperty("merger") + .GetProperty("enumValuesMergeBehavior") + .GetString()); + Assert.Equal( + EnumValuesMergeBehavior.Union, + roundTripped!.Merger.EnumValuesMergeBehavior); + Assert.Equal( + EnumValuesMergeBehavior.Union, + roundTripped.Merger.ToOptions().EnumValuesMergeBehavior); + } + + [Fact] + public void MergeInto_Should_PreserveExistingEnumValuesMergeBehavior_When_OverrideIsUnset() + { + // arrange + var existing = new CompositionSettings + { + Merger = { EnumValuesMergeBehavior = EnumValuesMergeBehavior.Union } + }; + + // act + var merged = new CompositionSettings().MergeInto(existing); + + // assert + Assert.Equal(EnumValuesMergeBehavior.Union, merged.Merger.EnumValuesMergeBehavior); + } + + [Fact] + public void LegacySettings_Should_DefaultEnumValuesMergeBehaviorToAuto() + { + // arrange + const string json = + """ + { + "merger": { + "enableGlobalObjectIdentification": true + } + } + """; + + // act + var settings = JsonSerializer.Deserialize( + json, + SettingsJsonSerializerContext.Default.CompositionSettings); + + // assert + Assert.Null(settings!.Merger.EnumValuesMergeBehavior); + Assert.Equal( + EnumValuesMergeBehavior.Auto, + settings.Merger.ToOptions().EnumValuesMergeBehavior); + } + [Fact] public void LegacySettings_Should_DefaultNodeResolutionToGateway() { diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/EnumValuesMismatchRuleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/EnumValuesMismatchRuleTests.cs index cde0ac9344d..303469853be 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/EnumValuesMismatchRuleTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/EnumValuesMismatchRuleTests.cs @@ -1,8 +1,11 @@ +using HotChocolate.Fusion.Options; + namespace HotChocolate.Fusion.PreMergeValidationRules; public sealed class EnumValuesMismatchRuleTests : RuleTestBase { - protected override object Rule { get; } = new EnumValuesMismatchRule(); + protected override object Rule { get; } = + new EnumValuesMismatchRule(EnumValuesMergeBehavior.Auto); // In this example, both source schemas define "Genre" with the same value "FANTASY", satisfying // the rule. diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/RuleTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/RuleTestBase.cs index 50b01deafc2..c484dd03cc5 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/RuleTestBase.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/PreMergeValidationRules/RuleTestBase.cs @@ -10,10 +10,15 @@ public abstract class RuleTestBase private readonly CompositionLog _log = new(); protected void AssertValid([StringSyntax("graphql")] string[] sdl) + { + AssertValid(sdl, Rule); + } + + protected void AssertValid([StringSyntax("graphql")] string[] sdl, object rule) { // arrange var schemas = CreateSchemaDefinitions(sdl); - var validator = new PreMergeValidator(schemas, [Rule], _log); + var validator = new PreMergeValidator(schemas, [rule], _log); // act var result = validator.Validate(); @@ -24,10 +29,18 @@ protected void AssertValid([StringSyntax("graphql")] string[] sdl) } protected void AssertInvalid([StringSyntax("graphql")] string[] sdl, string[] errorMessages) + { + AssertInvalid(sdl, errorMessages, Rule); + } + + protected void AssertInvalid( + [StringSyntax("graphql")] string[] sdl, + string[] errorMessages, + object rule) { // arrange var schemas = CreateSchemaDefinitions(sdl); - var validator = new PreMergeValidator(schemas, [Rule], _log); + var validator = new PreMergeValidator(schemas, [rule], _log); // act validator.Validate(); diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs index 3f645f0cbc0..2e9fd237d26 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SchemaComposerTests.cs @@ -806,6 +806,243 @@ type Product """); } + [Fact] + public void Compose_Should_UnionEnumValues_When_OutputOnlyEnumDiffersAcrossApolloSubgraphs() + { + // arrange + // Both Apollo subgraphs define "OrderPriority" with differing values, but the enum is + // only used in output positions, so the values are merged by union. + var schemaComposer = new SchemaComposer( + [ + new SourceSchemaText( + "catalog", + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + } + + type Order @key(fields: "id") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """), + new SourceSchemaText( + "warehouse", + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + trackedOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + fulfillmentPriority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """) + ], + new SchemaComposerOptions(), + new CompositionLog()); + + // act + var result = schemaComposer.Compose(); + + // assert + Assert.True(result.IsSuccess); + var enumType = result.Value.ToSyntaxNode().Definitions + .OfType() + .Single(definition => definition.Name.Value == "OrderPriority"); + + enumType.ToString().MatchInlineSnapshot( + """ + enum OrderPriority + @fusion__type(schema: CATALOG) + @fusion__type(schema: WAREHOUSE) { + LOW @fusion__enumValue(schema: CATALOG) @fusion__enumValue(schema: WAREHOUSE) + HIGH @fusion__enumValue(schema: CATALOG) @fusion__enumValue(schema: WAREHOUSE) + RUSH @fusion__enumValue(schema: CATALOG) + } + """); + } + + [Fact] + public void Compose_Should_UnionEnumValues_When_OutputOnlyEnumValuesDifferSymmetricallyAcrossApolloSubgraphs() + { + // arrange + // Each Apollo subgraph declares a value for "OrderPriority" that the other lacks, but + // the enum is only used in output positions, so the values are merged by union. + var schemaComposer = new SchemaComposer( + [ + new SourceSchemaText( + "catalog", + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + } + + type Order @key(fields: "id") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + RUSH + } + """), + new SourceSchemaText( + "warehouse", + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + trackedOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + fulfillmentPriority: OrderPriority + } + + enum OrderPriority { + LOW + EXPRESS + } + """) + ], + new SchemaComposerOptions(), + new CompositionLog()); + + // act + var result = schemaComposer.Compose(); + + // assert + Assert.True(result.IsSuccess); + var enumType = result.Value.ToSyntaxNode().Definitions + .OfType() + .Single(definition => definition.Name.Value == "OrderPriority"); + + enumType.ToString().MatchInlineSnapshot( + """ + enum OrderPriority + @fusion__type(schema: CATALOG) + @fusion__type(schema: WAREHOUSE) { + LOW @fusion__enumValue(schema: CATALOG) @fusion__enumValue(schema: WAREHOUSE) + RUSH @fusion__enumValue(schema: CATALOG) + EXPRESS @fusion__enumValue(schema: WAREHOUSE) + } + """); + } + + [Fact] + public void Compose_Should_ReportEnumValuesMismatch_When_StrictMergeBehaviorConfigured() + { + // arrange + // The strict merge behavior overrules the Apollo subgraph detection, so the differing + // output-only enum values are reported. + var log = new CompositionLog(); + var schemaComposer = new SchemaComposer( + [ + new SourceSchemaText( + "catalog", + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + orderById: Order + } + + type Order @key(fields: "id") { + id: ID! + priority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + RUSH + } + """), + new SourceSchemaText( + "warehouse", + """ + extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key"]) + + type Query { + trackedOrder: Order + } + + type Order @key(fields: "id") { + id: ID! + fulfillmentPriority: OrderPriority + } + + enum OrderPriority { + LOW + HIGH + } + """) + ], + new SchemaComposerOptions + { + Merger = { EnumValuesMergeBehavior = EnumValuesMergeBehavior.Strict } + }, + log); + + // act + var result = schemaComposer.Compose(); + + // assert + Assert.True(result.IsFailure); + log.Select(e => e.ToString()).MatchInlineSnapshots( + [ + """ + { + "message": "The enum type 'OrderPriority' in schema 'warehouse' must define the value 'RUSH'.", + "code": "ENUM_VALUES_MISMATCH", + "severity": "Error", + "coordinate": "OrderPriority", + "member": "OrderPriority", + "schema": "warehouse", + "extensions": {} + } + """ + ]); + } + [Fact] public void SourceSchemaRules_Should_ContainTheRegisteredRuleSet() { @@ -860,7 +1097,9 @@ public void SourceSchemaRules_Should_ContainTheRegisteredRuleSet() public void PreMergeRules_Should_ContainTheRegisteredRuleSet() { // act - var rules = SchemaComposer.PreMergeRules.Select(r => r.GetType().Name); + var rules = SchemaComposer + .CreatePreMergeRules(new SchemaComposerOptions()) + .Select(r => r.GetType().Name); // assert Assert.Equal( diff --git a/src/Nitro/CommandLine/src/CommandLine/Arguments/FusionSettingsNameArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Arguments/FusionSettingsNameArgument.cs index 8286d2d4967..fbbaba94bc1 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Arguments/FusionSettingsNameArgument.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Arguments/FusionSettingsNameArgument.cs @@ -3,9 +3,9 @@ namespace ChilliCream.Nitro.CommandLine.Arguments; internal sealed class FusionSettingsNameArgument : Argument { public const string ArgumentName = "SETTING_NAME"; - public const string AllowNonResolvableInterfaceObjects = - "allow-non-resolvable-interface-objects"; + public const string AllowNonResolvableInterfaceObjects = "allow-non-resolvable-interface-objects"; public const string CacheControlMergeBehavior = "cache-control-merge-behavior"; + public const string EnumValuesMergeBehavior = "enum-values-merge-behavior"; public const string ExcludeByTag = "exclude-by-tag"; public const string GlobalObjectIdentification = "global-object-identification"; public const string IncludeSatisfiabilityPaths = "include-satisfiability-paths"; @@ -17,6 +17,7 @@ internal sealed class FusionSettingsNameArgument : Argument [ AllowNonResolvableInterfaceObjects, CacheControlMergeBehavior, + EnumValuesMergeBehavior, ExcludeByTag, GlobalObjectIdentification, IncludeSatisfiabilityPaths, diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs index 3c709c6932e..688eafde016 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs @@ -21,6 +21,7 @@ public FusionComposeCommand() : base("compose") 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); @@ -90,6 +91,11 @@ private static async Task ExecuteAsync( : null; var enableGlobalObjectIdentification = parseResult.GetValue( Opt.Instance); + var enumValuesMergeBehaviorOption = Opt.Instance; + var enumValuesMergeBehavior = parseResult.Tokens.Any( + static token => token.Value == EnumValuesMergeBehaviorOption.OptionName) + ? parseResult.GetValue(enumValuesMergeBehaviorOption) + : null; var nodeResolutionOption = Opt.Instance; var nodeResolution = parseResult.Tokens.Any( static token => token.Value == NodeResolutionOption.OptionName) @@ -149,6 +155,7 @@ private static async Task ExecuteAsync( { CacheControlMergeBehavior = cacheControlMergeBehavior, EnableGlobalObjectIdentification = enableGlobalObjectIdentification, + EnumValuesMergeBehavior = enumValuesMergeBehavior, NodeResolution = nodeResolution, TagMergeBehavior = tagMergeBehavior }, diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSettingsSetCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSettingsSetCommand.cs index c1eb3c39941..3dd761270d0 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSettingsSetCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionSettingsSetCommand.cs @@ -79,9 +79,22 @@ private static async Task ExecuteAsync( compositionSettings.Merger.CacheControlMergeBehavior = cacheControlMergeBehavior; break; + case FusionSettingsNameArgument.EnumValuesMergeBehavior: + if (!EnumValuesMergeBehaviorParser.TryParse(settingValue, out var enumValuesMergeBehavior)) + { + throw new ExitException( + $"Expected one of the following values for setting '{settingName}': " + + $"{string.Join(", ", EnumValuesMergeBehaviorParser.Values)}"); + } + + compositionSettings.Merger.EnumValuesMergeBehavior = enumValuesMergeBehavior; + break; + case FusionSettingsNameArgument.ExcludeByTag: - var tags = settingValue - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var tags = settingValue.Split( + ',', + StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries); compositionSettings.Preprocessor.ExcludeByTag = tags.ToHashSet(); break; diff --git a/src/Nitro/CommandLine/src/CommandLine/Helpers/EnumValuesMergeBehaviorParser.cs b/src/Nitro/CommandLine/src/CommandLine/Helpers/EnumValuesMergeBehaviorParser.cs new file mode 100644 index 00000000000..f056ec737a5 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Helpers/EnumValuesMergeBehaviorParser.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.Fusion.Options; + +namespace ChilliCream.Nitro.CommandLine.Helpers; + +internal static class EnumValuesMergeBehaviorParser +{ + public const string Auto = "auto"; + public const string Strict = "strict"; + public const string Union = "union"; + + public static readonly string[] Values = + [ + Auto, + Strict, + Union + ]; + + public static EnumValuesMergeBehavior? Parse(string value) + => value switch + { + Auto => EnumValuesMergeBehavior.Auto, + Strict => EnumValuesMergeBehavior.Strict, + Union => EnumValuesMergeBehavior.Union, + _ => null + }; + + public static bool TryParse(string value, [NotNullWhen(true)] out EnumValuesMergeBehavior? behavior) + { + behavior = Parse(value); + return behavior is not null; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Options/EnumValuesMergeBehaviorOption.cs b/src/Nitro/CommandLine/src/CommandLine/Options/EnumValuesMergeBehaviorOption.cs new file mode 100644 index 00000000000..c9afd87abf0 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Options/EnumValuesMergeBehaviorOption.cs @@ -0,0 +1,17 @@ +using HotChocolate.Fusion.Options; +using ChilliCream.Nitro.CommandLine.Helpers; + +namespace ChilliCream.Nitro.CommandLine; + +internal sealed class EnumValuesMergeBehaviorOption : Option +{ + public const string OptionName = "--enum-values-merge-behavior"; + + public EnumValuesMergeBehaviorOption() + : base(OptionName) + { + Description = "Choose how enum values are merged across source schemas"; + AcceptOnlyFromAmong(EnumValuesMergeBehaviorParser.Values); + CustomParser = result => EnumValuesMergeBehaviorParser.Parse(result.Tokens.Single().Value); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs index 48a8d04ecdb..bb33f4cde93 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs @@ -46,6 +46,7 @@ nitro fusion compose [options] -e, --env, --environment The name of the environment used for value substitution in the schema-settings.json files --cache-control-merge-behavior Choose how @cacheControl directives are merged --enable-global-object-identification Add the 'Query.node' field for global object identification + --enum-values-merge-behavior Choose how enum values are merged across source schemas --node-resolution Choose whether Query.node identifiers are resolved by the gateway or a source schema --tag-merge-behavior Choose how @tag directives are merged --shareable-field-runtime-type-routing Choose how runtime types are routed for Apollo Federation shareable abstract fields @@ -905,6 +906,7 @@ public async Task Compose_Should_PersistEveryUserFacingCompositionOption() "addFusionDefinitions": null, "cacheControlMergeBehavior": "Ignore", "enableGlobalObjectIdentification": true, + "enumValuesMergeBehavior": "Union", "nodeResolution": "Gateway", "removeUnreferencedDefinitions": null, "tagMergeBehavior": "Include" @@ -1297,6 +1299,8 @@ private static string[] CreateAllCompositionOptionArguments(string archiveFileNa "--cache-control-merge-behavior", "ignore", "--enable-global-object-identification", + "--enum-values-merge-behavior", + "union", "--node-resolution", "gateway", "--tag-merge-behavior", diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs index 9435310a904..7b52378fea1 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs @@ -1338,6 +1338,7 @@ public async Task WithSourceSchemaFile_NullStageCompositionSettings_ArchiveSetti "addFusionDefinitions": null, "cacheControlMergeBehavior": "Include", "enableGlobalObjectIdentification": false, + "enumValuesMergeBehavior": null, "nodeResolution": null, "removeUnreferencedDefinitions": false, "tagMergeBehavior": "Include" @@ -1428,6 +1429,7 @@ public async Task WithSourceSchemaFile_PartialStageCompositionSettings_OnlyOverr "addFusionDefinitions": null, "cacheControlMergeBehavior": "Include", "enableGlobalObjectIdentification": false, + "enumValuesMergeBehavior": null, "nodeResolution": null, "removeUnreferencedDefinitions": false, "tagMergeBehavior": "IncludePrivate" @@ -4045,6 +4047,7 @@ public async Task WithSourceSchema_NullStageCompositionSettings_ArchiveSettingsP "addFusionDefinitions": null, "cacheControlMergeBehavior": "Include", "enableGlobalObjectIdentification": false, + "enumValuesMergeBehavior": null, "nodeResolution": null, "removeUnreferencedDefinitions": false, "tagMergeBehavior": "Include" @@ -4135,6 +4138,7 @@ public async Task WithSourceSchema_PartialStageCompositionSettings_OnlyOverrides "addFusionDefinitions": null, "cacheControlMergeBehavior": "Include", "enableGlobalObjectIdentification": false, + "enumValuesMergeBehavior": null, "nodeResolution": null, "removeUnreferencedDefinitions": false, "tagMergeBehavior": "IncludePrivate" diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSettingsSetCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSettingsSetCommandTests.cs index c3e18b3b87a..ab278c3f5c6 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSettingsSetCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionSettingsSetCommandTests.cs @@ -24,8 +24,8 @@ Set a Fusion composition setting in a Fusion archive. nitro fusion settings set [options] Arguments: - The name of the setting to change - The value to set + The name of the setting to change + The value to set Options: -a, --archive, --configuration (REQUIRED) The path to a Fusion archive file (the '--configuration' alias is deprecated) [env: NITRO_FUSION_CONFIG_FILE] @@ -91,6 +91,7 @@ public async Task InvalidSettingName_ReturnsError(InteractionMode mode) Argument 'nonexistent-setting' not recognized. Must be one of: 'allow-non-resolvable-interface-objects' 'cache-control-merge-behavior' + 'enum-values-merge-behavior' 'exclude-by-tag' 'global-object-identification' 'include-satisfiability-paths' @@ -242,6 +243,12 @@ public async Task Execute_Should_ReturnError_When_BooleanSettingIsInvalid( "merger", "cacheControlMergeBehavior", "\"Ignore\"")] + [InlineData( + "enum-values-merge-behavior", + "union", + "merger", + "enumValuesMergeBehavior", + "\"Union\"")] [InlineData( "exclude-by-tag", "internal,private", diff --git a/website/content/docs/fusion/cli.md b/website/content/docs/fusion/cli.md index 82a09116336..f56675d927b 100644 --- a/website/content/docs/fusion/cli.md +++ b/website/content/docs/fusion/cli.md @@ -423,6 +423,7 @@ nitro fusion settings set [options] | `node-resolution` | `gateway`, `source-schema` | Choose who resolves `Query.node` IDs | | `shareable-field-runtime-type-routing` | `source-local`, `common-runtime-types` | Choose routing for type-conditioned selections on shareable fields | | `cache-control-merge-behavior` | `ignore`, `include`, `include-private` | How to merge `@cacheControl` directives | +| `enum-values-merge-behavior` | `auto`, `strict`, `union` | How to merge enum values across source schemas | | `tag-merge-behavior` | `ignore`, `include`, `include-private` | How to merge `@tag` directives | | `exclude-by-tag` | Comma-separated tags | Exclude fields/types by tag | diff --git a/website/content/docs/fusion/composition.md b/website/content/docs/fusion/composition.md index defe9898fbe..d0210a80282 100644 --- a/website/content/docs/fusion/composition.md +++ b/website/content/docs/fusion/composition.md @@ -116,84 +116,85 @@ The placeholders `{0}`, `{1}`, etc. in the message column are replaced with the ## Errors -| Code | Message | How to resolve | -| ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `CONFLICTING_SOURCE_SCHEMA_NAME` | '\{0\}' conflicts with the existing source schema name '\{1\}'. Either rename '\{0\}' to '\{1\}' if they're the same, or rename '\{0\}' to something else if they're different. | Two source schemas were registered under names that the composition treats as the same identity. Rename one of them in your composition configuration so each source schema has a unique name. | -| [DISALLOWED_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Disallowed-Inaccessible-Elements) | (Message varies by element kind: built-in scalar, introspection type, introspection field, introspection argument, or built-in directive argument.) | Built-in scalars, introspection types and their members, and built-in directive arguments cannot be marked `@inaccessible`. Remove the directive from the offending element in the named source schema. | -| [EMPTY_MERGED_ENUM_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Enum-Type) | The merged enum type '\{0\}' is empty. | Every value of the enum was excluded by `@inaccessible` or tag filters, leaving nothing to merge. Either expose at least one enum value or remove the type entirely. | -| [EMPTY_MERGED_INPUT_OBJECT_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Input-Object-Type) | The merged input object type '\{0\}' is empty. | All fields of the input type were excluded after merging. Expose at least one input field or remove the input type from your source schemas. | -| [EMPTY_MERGED_INTERFACE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Interface-Type) | The merged interface type '\{0\}' is empty. | All fields of the interface were filtered out by `@inaccessible` or tag exclusions. Expose at least one field on the interface, remove the filters that hide them, or drop the interface from the schema. | -| [EMPTY_MERGED_OBJECT_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Object-Type) | The merged object type '\{0\}' is empty. | Every field on the object type was excluded. Expose at least one field, or remove the type if it is no longer needed. | -| [EMPTY_MERGED_UNION_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Union-Type) | The merged union type '\{0\}' is empty. | All members of the union were excluded by `@inaccessible` or tag exclusions. Expose at least one member type, remove the filters that hide them, or remove the union. | -| [ENUM_TYPE_DEFAULT_VALUE_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Enum-Type-Default-Value-Inaccessible) | The default value of '\{0\}' references the inaccessible enum value '\{1\}'. | A default points to an enum value that is hidden from the composed schema. Either change the default to a value that is accessible or remove `@inaccessible` from the referenced enum value. | -| [ENUM_VALUES_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Enum-Values-Mismatch) | The enum type '\{0\}' in schema '\{1\}' must define the value '\{2\}'. | An enum is defined in more than one source schema and one of them is missing a value the others declare. Add the missing value to the named source schema so all definitions agree. | -| [EXTERNAL_ARGUMENT_DEFAULT_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Default-Mismatch) | The default value '\{0\}' of external argument '\{1\}' in schema '\{2\}' differs from the default value of '\{3\}' in schema '\{4\}'. | The same argument has different defaults in the external definition and the owning source schema. Align the default values across both source schemas. | -| [EXTERNAL_ARGUMENT_MISSING](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Missing) | The external field '\{0\}' in schema '\{1\}' must define the argument '\{2\}'. | An `@external` field declaration is missing an argument that exists on the canonical definition. Add the argument to the external declaration with a matching type. | -| [EXTERNAL_ARGUMENT_TYPE_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Type-Mismatch) | The argument '\{0\}' on external field '\{1\}' in schema '\{2\}' has a different type (\{3\}) than it does in schema '\{4\}' (\{5\}). | The argument types of an `@external` declaration and the owning source schema disagree. Change the argument type in one of the source schemas so the signatures match exactly, including nullability. | -| [EXTERNAL_MISSING_ON_BASE](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Missing-on-Base) | The external field '\{0\}' in schema '\{1\}' is not defined (non-external) in any other schema. | A field marked `@external` has no non-external definition anywhere. Add the canonical definition in another source schema, or remove `@external` if this source schema is meant to own the field. See [Field Ownership](./field-ownership-and-sharing.md). | -| [EXTERNAL_ON_INTERFACE](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-on-Interface) | The interface field '\{0\}' in schema '\{1\}' must not be marked as external. | `@external` is not valid on interface fields. Remove `@external` from the interface field; if you need to mark concrete implementations as external, place the directive on the implementing object types instead. | -| [EXTERNAL_OVERRIDE_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Override-Collision) | The external field '\{0\}' in schema '\{1\}' must not be annotated with the @override directive. | A field cannot be both `@external` and `@override`. Decide which source schema owns the field and apply only the appropriate directive. | -| [EXTERNAL_PROVIDES_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Provides-Collision) | The external field '\{0\}' in schema '\{1\}' must not be annotated with the @provides directive. | `@external` declares a field as not owned here, while `@provides` declares it as supplied here. Remove one of the directives so the ownership story is unambiguous. | -| [EXTERNAL_REQUIRE_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Require-Collision) | The external field '\{0\}' in schema '\{1\}' must not have arguments that are annotated with the @require directive. | An `@external` field cannot consume `@require` arguments because it is not actually executed in this subgraph. Remove `@require` from the arguments, or move the field to a source schema where it is owned. | -| [EXTERNAL_TYPE_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Type-Mismatch) | The external field '\{0\}' in schema '\{1\}' has a different type (\{2\}) than it does in schema '\{3\}' (\{4\}). | The return type of the `@external` declaration differs from the canonical definition. Align the field type and nullability in both source schemas. | -| [EXTERNAL_UNUSED](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Unused) | The external field '\{0\}' in schema '\{1\}' is not referenced by a @provides directive in the schema. | An `@external` field is declared but no `@provides` in this schema references it. Remove the unused declaration, or add a `@provides` that references the field. See [Field Ownership](./field-ownership-and-sharing.md). | -| `FEDERATION_DIRECTIVE_NOT_SUPPORTED` | The @\{0\} directive is not supported. | The schema uses an Apollo Federation directive that Fusion does not support. Remove the directive or replace it with the equivalent Fusion construct. | -| `FEDERATION_V1_NOT_SUPPORTED` | Federation v1 is not supported. | Upgrade the source schema to Federation v2 (or to native Fusion directives) before running composition. | -| [FIELD_ARGUMENT_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Field-Argument-Types-Mergeable) | The argument '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | The argument has a fundamentally different type (different named type or different list/non-null structure) across source schemas. Pick one canonical type and update the other source schema to match. | -| [FIELD_WITH_MISSING_REQUIRED_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Field-With-Missing-Required-Arguments) | The argument '\{0\}' must be defined as required in schema '\{1\}'. Arguments marked with @require are treated as non-required. | The argument is required in some source schemas and optional or `@require`-driven in another. Mark the argument as required in the named source schema, or align all source schemas on the same nullability. | -| [IMPLEMENTED_BY_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Implemented-by-Inaccessible) | The field '\{0\}' implementing interface field '\{1\}' is inaccessible in the composed schema. | An object type implements an interface field, but its implementation is hidden. Either expose the implementing field or hide the interface field as well. | -| `IMPLEMENT_WITHOUT_DEFAULT` | The field '\{0\}' is marked with @implement in schema '\{1\}', but no applicable default implementation exists for it. | Remove `@implement`, or add an applicable default through an interface-object stand-in. Use `@implement` only when the declaration intentionally replaces that projected default. See [Replace a Projected Default](./interface-objects.md#replace-a-projected-default). | -| [INPUT_FIELD_DEFAULT_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-Field-Default-Mismatch) | The default value '\{0\}' of input field '\{1\}' in schema '\{2\}' differs from the default value of '\{3\}' in schema '\{4\}'. | The same input field has different defaults in two source schemas. Align the default value across all definitions of the input type. | -| [INPUT_FIELD_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-Field-Types-mergeable) | The input field '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | An input field has a different type structure (named type, list, or nullability) across source schemas. Decide on the canonical type and update the other source schema. | -| [INPUT_WITH_MISSING_REQUIRED_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-With-Missing-Required-Fields) | The input type '\{0\}' in schema '\{1\}' must define the required field '\{2\}'. | One source schema requires a field on an input type that another source schema omits. Add the field (with the same type) to the named source schema so the input shape is consistent. | -| `INPUT_WITH_MISSING_ONEOF` | The input type '\{0\}' in schema '\{1\}' must be annotated with the '@oneOf' directive, since the same type has been annotated with it in schema '\{2\}'. | The same input type is `@oneOf` in one source schema but not in another. Add `@oneOf` to the input type in the named source schema (or remove it from the other) so all definitions agree. | -| [INTERFACE_FIELD_NO_IMPLEMENTATION](https://graphql.github.io/composite-schemas-spec/draft/#sec-Interface-Field-No-Implementation) | The merged object type '\{0\}' must implement the field '\{1\}' on interface '\{2\}'. | After merging, an object type declares it implements the interface but does not provide every field the interface requires. Add the missing field to the object type, or stop implementing the interface. | -| `INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT` | The field '\{0\}' provided by schema '\{1\}' replaces the default implementation contributed to interface '\{2\}'. Mark it with @implement to keep this implementation, or remove it to adopt the default. | Add `@implement` when this source intentionally replaces the projected default. Otherwise, remove the field declaration so the type inherits the default. See [Replace a Projected Default](./interface-objects.md#replace-a-projected-default). | -| `INTERFACE_OBJECT_KEY_MISMATCH` | The @interfaceObject stand-in '\{0\}' in schema '\{1\}' declares the key '\{2\}', which does not match any key declared on the interface it stands in for. | Change the stand-in key to one declared or inferred on the same-named interface. The key fields and selection shape must match. See [Interface Objects](./interface-objects.md). | -| `INTERFACE_OBJECT_KEY_MISSING` | The @interfaceObject stand-in '\{0\}' in schema '\{1\}' must declare at least one @key directive. | Add at least one `@key` that matches a key on the same-named interface and can populate the stand-in lookup. See [Interface Objects](./interface-objects.md). | -| `INTERFACE_OBJECT_NO_INTERFACE` | The type '\{0\}' is declared as an @interfaceObject stand-in, but no source schema defines '\{0\}' as an interface. | Define the same-named interface in a concrete-aware source schema, or remove `@interfaceObject` if the type is not a stand-in. See [Interface Objects](./interface-objects.md). | -| [INVALID_FIELD_SHARING](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-Field-Sharing) | The field '\{0\}' in schema '\{1\}' must be shareable. | The same non-key field is owned by more than one source schema without an explicit sharing contract. Add `@shareable` to the field (or to the enclosing type, which applies to all of its fields) in every source schema that defines it, or move ownership to a single source schema. See [Field Ownership](./field-ownership-and-sharing.md). | -| [INVALID_GRAPHQL](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-GraphQL) | Invalid GraphQL in source schema. Exception message: \{0\}. | The source schema does not parse as valid GraphQL. Read the included parser exception, fix the SDL in the offending source schema, and re-export. See [Getting Started](./getting-started.md) for how to export a source schema. | -| `INVALID_NODE_RESOLUTION` | Source-schema node resolution requires global object identification to be enabled. Or: The node resolution mode '\{0\}' is invalid. | Enable Global Object Identification when you select source-schema resolution. For programmatic composition, use `NodeResolution.Gateway` or `NodeResolution.SourceSchema`. See [Configure node resolution](./connectors/apollofederation.md#configure-node-resolution). | -| `INVALID_PROJECTED_FIELD_SHARING` | The default field '\{0\}' on type '\{1\}' is contributed by unrelated interfaces \{2\}; every contributing @interfaceObject declaration must be marked @shareable. | Mark every contributing stand-in field `@shareable` only when its type and semantics match, or remove the conflicting default from all but one interface. See [Share Defaults from Unrelated Interfaces](./interface-objects.md#share-defaults-from-unrelated-interfaces). | -| `INVALID_SHAREABLE_FIELD_RUNTIME_TYPE_ROUTING` | The shareable field runtime type routing mode '\{0\}' is invalid. | Set `ShareableFieldRuntimeTypeRouting` to `SourceLocal` or `CommonRuntimeTypes`. See [Composition Settings](./local-development.md#composition-settings) for the Aspire setting. | -| [INVALID_SHAREABLE_USAGE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-Shareable-Usage) | The field '\{0\}' in schema '\{1\}' must not be marked as shareable. | `@shareable` was applied where it is not allowed (for example, on a field that is already governed by another ownership directive). Remove `@shareable` from the field. See [Field Ownership](./field-ownership-and-sharing.md). | -| [IS_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Fields) | The @is directive on argument '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The field selection map in `@is(field: ...)` does not resolve against the entity's fields. Update it to reference fields that actually exist on the parent type. See [Entities and Lookups](./entities-and-lookups.md). | -| [IS_INVALID_FIELD_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Field-Type) | The @is directive on argument '\{0\}' in schema '\{1\}' must specify a string value for the 'field' argument. | The `field` argument was given as a non-string literal. Pass it as a quoted string, for example `@is(field: "id")`. | -| [IS_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Syntax) | The @is directive on argument '\{0\}' in schema '\{1\}' contains invalid syntax in the 'field' argument. | The string value for `field` is not a valid field selection map. Rewrite it as a syntactically valid field selection map. | -| [IS_INVALID_USAGE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Usage) | The @is directive on argument '\{0\}' in schema '\{1\}' is invalid because the declaring field is not a lookup field. | `@is` is only valid on arguments of lookup fields. Move `@is` to a lookup, or annotate the declaring field with `@lookup`. See [Entities and Lookups](./entities-and-lookups.md). | -| [KEY_DIRECTIVE_IN_FIELDS_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Directive-in-Fields-Argument) | A @key directive on type '\{0\}' in schema '\{1\}' references field '\{2\}', which must not include directive applications. | The `fields` selection of `@key` cannot apply directives to the selected fields. Remove the directive applications from the selection set. See [Entities and Lookups](./entities-and-lookups.md). | -| [KEY_FIELDS_SELECT_INVALID_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Fields-Select-Invalid-Type) | A @key directive on type '\{0\}' in schema '\{1\}' references field '\{2\}', which must not be a list, interface, or union type. | Key fields must resolve to scalar, enum, or object types, not lists, interfaces, or unions. Pick a different field for the key, or model the key as a scalar identifier. | -| [KEY_INVALID_ARGUMENTS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Arguments) | A @key directive on type '\{0\}' in schema '\{1\}' specifies invalid arguments. \{2\} | Key fields support constant arguments. Remove or correct any argument that is unknown, has an incompatible value type, or leaves a required argument unsupplied. | -| [KEY_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Fields) | A @key directive on type '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The `fields` selection refers to fields that do not exist on the type. Add the missing fields to the type or correct the selection set. See [Entities and Lookups](./entities-and-lookups.md). | -| [KEY_INVALID_FIELDS_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Fields-Type) | A @key directive on type '\{0\}' in schema '\{1\}' must specify a string value for the 'fields' argument. | The `fields` argument was given as a non-string literal. Pass it as a quoted string, for example `@key(fields: "id")`. | -| [KEY_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Syntax) | A @key directive on type '\{0\}' in schema '\{1\}' contains invalid syntax in the 'fields' argument. | The string value for `fields` is not a valid GraphQL selection set. Rewrite it as a syntactically valid selection. | -| [LOOKUP_RETURNS_LIST](https://graphql.github.io/composite-schemas-spec/draft/#sec-Lookup-Returns-List) | The lookup field '\{0\}' in schema '\{1\}' must not return a list. | A `@lookup` field returned a list, which prevents the gateway from mapping a key to a single entity. Change the return type to a single nullable entity. See [Entities and Lookups](./entities-and-lookups.md). | -| [NON_NULL_INPUT_FIELD_IS_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Non-Null-Input-Fields-cannot-be-inaccessible) | The non-null input field '\{0\}' in schema '\{1\}' must be accessible in the composed schema. | A required input field was hidden by `@inaccessible`, which would make the input impossible to construct from the gateway. Either make the field nullable or expose it. | -| [NO_QUERIES](https://graphql.github.io/composite-schemas-spec/draft/#sec-No-Queries) | The merged query type has no accessible fields. | After merging and applying accessibility filters, no query fields remain. Expose at least one query field, or remove `@inaccessible` from the queries you intend to publish. | -| `OPT_IN_FEATURE_STABILITY_MISMATCH` | The opt-in feature '\{0\}' has a different stability in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | Two or more source schemas declare the same opt-in feature with different `stability` values. Align the stability for that feature across all subgraphs that declare it using `OptInFeatureStability("featureName", "stability")` so every declaration uses the same string. | -| [OUTPUT_FIELD_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Output-Field-Types-Mergeable) | The output field '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | The field's return type cannot be unified across source schemas: leaf types (scalar or enum) differ in name or kind, the list shape differs, or composite types (object, interface, or union) share no common supertype. Nullability differences and composite types with a common supertype merge automatically; otherwise align the return types. | -| [OVERRIDE_FROM_SELF](https://graphql.github.io/composite-schemas-spec/draft/#sec-Override-from-Self) | The @override directive on field '\{0\}' in schema '\{1\}' must not reference the same schema. | `@override(from: "...")` cannot point at the source schema that owns the directive. Set `from` to the name of the source schema the field is being taken from. See [Field Ownership](./field-ownership-and-sharing.md). | -| [OVERRIDE_ON_INTERFACE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Override-on-Interface) | The interface field '\{0\}' in schema '\{1\}' must not be annotated with the @override directive. | `@override` cannot be applied to interface fields. Move the directive to the implementing object types if you need to take ownership of a specific implementation. See [Field Ownership](./field-ownership-and-sharing.md). | -| [PROVIDES_DIRECTIVE_IN_FIELDS_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Directive-in-Fields-Argument) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must not include directive applications. | The selection in `@provides(fields: ...)` cannot apply directives. Remove the directive applications from the selection. See [Field Ownership](./field-ownership-and-sharing.md). | -| [PROVIDES_FIELDS_HAS_ARGUMENTS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Fields-Has-Arguments) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must not have arguments. | A field selected by `@provides` is being called with arguments, which is not allowed. Pick an argument-less field or restructure the schema so the provided field needs no arguments. | -| [PROVIDES_FIELDS_MISSING_EXTERNAL](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Fields-Missing-External) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must be marked as external. | `@provides` only makes sense when the referenced field is `@external` here. Add `@external` to the referenced field in the same source schema. See [Field Ownership](./field-ownership-and-sharing.md). | -| [PROVIDES_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Fields) | The @provides directive on field '\{0\}' in schema '\{1\}' specifies an invalid field selection. | The selection points to fields that do not exist on the returned type. Update the selection so it matches fields that the returned type actually defines. | -| [PROVIDES_INVALID_FIELDS_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Fields-Type) | The @provides directive on field '\{0\}' in schema '\{1\}' must specify a string value for the 'fields' argument. | The `fields` argument was given as a non-string literal. Pass it as a quoted string. | -| [PROVIDES_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Syntax) | The @provides directive on field '\{0\}' in schema '\{1\}' contains invalid syntax in the 'fields' argument. | The selection set syntax in `fields` is invalid. Rewrite it as a valid GraphQL selection set. | -| [PROVIDES_ON_NON_COMPOSITE_FIELD](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-on-Non-Composite-Field) | The field '\{0\}' in schema '\{1\}' includes a @provides directive, but does not return a composite type. | `@provides` only applies when a field returns an object, interface, or union, because the directive describes which sub-fields are also delivered. Remove `@provides`, or change the field to return a composite type. | -| [QUERY_ROOT_TYPE_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Query-Root-Type-Inaccessible) | The root query type in schema '\{0\}' must be accessible. | The query root cannot be marked `@inaccessible`. Remove the directive from the root query type. | -| `REFERENCE_TO_DEPRECATED_TYPE` | The merged field '\{0\}' in type '\{1\}' cannot reference the deprecated type '\{2\}'. Either deprecate the field or change its return type. | A field that is not deprecated returns a deprecated object type. Merging propagates an object type's deprecation from every source schema that declares it, so the composed schema can pair a deprecated type with a field that no source schema deprecated. Either deprecate the field as well, or change its return type. | -| [REFERENCE_TO_INACCESSIBLE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Reference-To-Inaccessible-Type) | (Message varies by reference site: field argument, input field, or output field.) | A publicly accessible position (a field argument, input field, or output field) references a type that has been hidden from the composed schema. Either expose the referenced type or change the position to use a publicly accessible type. | -| [REFERENCE_TO_INTERNAL_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Reference-To-Internal-Type) | The merged field '\{0\}' in type '\{1\}' cannot reference the internal type '\{2\}'. | A public field returns an internal type, which would leak the internal type into the composed schema. Either mark the field as internal as well, or change the return type to a non-internal one. | -| [REQUIRE_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Fields) | The @require directive on argument '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The field selection map in `@require(field: ...)` references fields that the parent type does not expose. Update it to reference fields that exist on the parent. See [Entities and Lookups](./entities-and-lookups.md). | -| [REQUIRE_INVALID_FIELD_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Field-Type) | The @require directive on argument '\{0\}' in schema '\{1\}' must specify a string value for the 'field' argument. | The `field` argument must be a string literal. Pass it as a quoted string. | -| [REQUIRE_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Syntax) | The @require directive on argument '\{0\}' in schema '\{1\}' contains invalid syntax in the 'field' argument. | The `field` value is not a valid field selection map. Rewrite it as a syntactically valid field selection map. | -| [ROOT_MUTATION_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Mutation-Used) | The root mutation type in schema '\{0\}' must be named 'Mutation'. | Fusion requires the root mutation type to use the canonical name `Mutation`. Rename the type in the named source schema. A type named `Mutation` must not exist unless it is the root mutation type. | -| [ROOT_QUERY_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Query-Used) | The root query type in schema '\{0\}' must be named 'Query'. | Fusion requires the root query type to use the canonical name `Query`. Rename the type in the named source schema. A type named `Query` must not exist unless it is the root query type. See [Getting Started](./getting-started.md) for the expected schema layout. | -| [ROOT_SUBSCRIPTION_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Subscription-Used) | The root subscription type in schema '\{0\}' must be named 'Subscription'. | Fusion requires the root subscription type to use the canonical name `Subscription`. Rename the type in the named source schema. A type named `Subscription` must not exist unless it is the root subscription type. | -| [TYPE_KIND_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Type-Kind-Mismatch) | The type '\{0\}' has a different kind in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | The same type name was used for different kinds (for example, an object in one source schema and an interface in another). Decide which kind is correct and update the other source schema, or rename one of the types so they no longer collide. | -| [UNSATISFIABLE_QUERY_PATH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Unsatisfiable-Query-Path) | (Message varies. Includes the unreachable field, the path the validator tried, and the lookups it considered.) | Some reachable field cannot be resolved through the available `@lookup` and `@key` paths. This is an error unless the source-schema node-resolution exception described under Warnings applies. See [Diagnosing UNSATISFIABLE_QUERY_PATH Diagnostics](#diagnosing-unsatisfiable_query_path-diagnostics) for how to read the message and fix the underlying gap. | +| Code | Message | How to resolve | +| ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `CONFLICTING_SOURCE_SCHEMA_NAME` | '\{0\}' conflicts with the existing source schema name '\{1\}'. Either rename '\{0\}' to '\{1\}' if they're the same, or rename '\{0\}' to something else if they're different. | Two source schemas were registered under names that the composition treats as the same identity. Rename one of them in your composition configuration so each source schema has a unique name. | +| [DISALLOWED_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Disallowed-Inaccessible-Elements) | (Message varies by element kind: built-in scalar, introspection type, introspection field, introspection argument, or built-in directive argument.) | Built-in scalars, introspection types and their members, and built-in directive arguments cannot be marked `@inaccessible`. Remove the directive from the offending element in the named source schema. | +| [EMPTY_MERGED_ENUM_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Enum-Type) | The merged enum type '\{0\}' is empty. | Every value of the enum was excluded by `@inaccessible` or tag filters, leaving nothing to merge. Either expose at least one enum value or remove the type entirely. | +| [EMPTY_MERGED_INPUT_OBJECT_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Input-Object-Type) | The merged input object type '\{0\}' is empty. | All fields of the input type were excluded after merging. Expose at least one input field or remove the input type from your source schemas. | +| [EMPTY_MERGED_INTERFACE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Interface-Type) | The merged interface type '\{0\}' is empty. | All fields of the interface were filtered out by `@inaccessible` or tag exclusions. Expose at least one field on the interface, remove the filters that hide them, or drop the interface from the schema. | +| [EMPTY_MERGED_OBJECT_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Object-Type) | The merged object type '\{0\}' is empty. | Every field on the object type was excluded. Expose at least one field, or remove the type if it is no longer needed. | +| [EMPTY_MERGED_UNION_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Empty-Merged-Union-Type) | The merged union type '\{0\}' is empty. | All members of the union were excluded by `@inaccessible` or tag exclusions. Expose at least one member type, remove the filters that hide them, or remove the union. | +| [ENUM_TYPE_DEFAULT_VALUE_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Enum-Type-Default-Value-Inaccessible) | The default value of '\{0\}' references the inaccessible enum value '\{1\}'. | A default points to an enum value that is hidden from the composed schema. Either change the default to a value that is accessible or remove `@inaccessible` from the referenced enum value. | +| [ENUM_VALUES_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Enum-Values-Mismatch) | The enum type '\{0\}' in schema '\{1\}' must define the value '\{2\}'. | An enum is defined in more than one source schema and one of them is missing a value the others declare. Add the missing value to the named source schema so all definitions agree. By default composition is strict per the composite schema spec; when at least one Apollo Federation connector source schema is part of the composition, enums used only in output positions merge their values by union and do not raise this error. The `enumValuesMergeBehavior` merger setting (`auto`, `strict`, `union`) overrides the detection in either direction; enums used in any input position (including through `@key` or `@requires`) always require identical value sets. | +| [EXTERNAL_ARGUMENT_DEFAULT_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Default-Mismatch) | The default value '\{0\}' of external argument '\{1\}' in schema '\{2\}' differs from the default value of '\{3\}' in schema '\{4\}'. | The same argument has different defaults in the external definition and the owning source schema. Align the default values across both source schemas. | +| [EXTERNAL_ARGUMENT_MISSING](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Missing) | The external field '\{0\}' in schema '\{1\}' must define the argument '\{2\}'. | An `@external` field declaration is missing an argument that exists on the canonical definition. Add the argument to the external declaration with a matching type. | +| [EXTERNAL_ARGUMENT_TYPE_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Argument-Type-Mismatch) | The argument '\{0\}' on external field '\{1\}' in schema '\{2\}' has a different type (\{3\}) than it does in schema '\{4\}' (\{5\}). | The argument types of an `@external` declaration and the owning source schema disagree. Change the argument type in one of the source schemas so the signatures match exactly, including nullability. | +| [EXTERNAL_MISSING_ON_BASE](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Missing-on-Base) | The external field '\{0\}' in schema '\{1\}' is not defined (non-external) in any other schema. | A field marked `@external` has no non-external definition anywhere. Add the canonical definition in another source schema, or remove `@external` if this source schema is meant to own the field. See [Field Ownership](./field-ownership-and-sharing.md). | +| [EXTERNAL_ON_INTERFACE](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-on-Interface) | The interface field '\{0\}' in schema '\{1\}' must not be marked as external. | `@external` is not valid on interface fields. Remove `@external` from the interface field; if you need to mark concrete implementations as external, place the directive on the implementing object types instead. | +| [EXTERNAL_OVERRIDE_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Override-Collision) | The external field '\{0\}' in schema '\{1\}' must not be annotated with the @override directive. | A field cannot be both `@external` and `@override`. Decide which source schema owns the field and apply only the appropriate directive. | +| [EXTERNAL_PROVIDES_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Provides-Collision) | The external field '\{0\}' in schema '\{1\}' must not be annotated with the @provides directive. | `@external` declares a field as not owned here, while `@provides` declares it as supplied here. Remove one of the directives so the ownership story is unambiguous. | +| [EXTERNAL_REQUIRE_COLLISION](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Require-Collision) | The external field '\{0\}' in schema '\{1\}' must not have arguments that are annotated with the @require directive. | An `@external` field cannot consume `@require` arguments because it is not actually executed in this subgraph. Remove `@require` from the arguments, or move the field to a source schema where it is owned. | +| [EXTERNAL_TYPE_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Type-Mismatch) | The external field '\{0\}' in schema '\{1\}' has a different type (\{2\}) than it does in schema '\{3\}' (\{4\}). | The return type of the `@external` declaration differs from the canonical definition. Align the field type and nullability in both source schemas. | +| [EXTERNAL_UNUSED](https://graphql.github.io/composite-schemas-spec/draft/#sec-External-Unused) | The external field '\{0\}' in schema '\{1\}' is not referenced by a @provides directive in the schema. | An `@external` field is declared but no `@provides` in this schema references it. Remove the unused declaration, or add a `@provides` that references the field. See [Field Ownership](./field-ownership-and-sharing.md). | +| `FEDERATION_DIRECTIVE_NOT_SUPPORTED` | The @\{0\} directive is not supported. | The schema uses an Apollo Federation directive that Fusion does not support. Remove the directive or replace it with the equivalent Fusion construct. | +| `FEDERATION_V1_NOT_SUPPORTED` | Federation v1 is not supported. | Upgrade the source schema to Federation v2 (or to native Fusion directives) before running composition. | +| [FIELD_ARGUMENT_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Field-Argument-Types-Mergeable) | The argument '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | The argument has a fundamentally different type (different named type or different list/non-null structure) across source schemas. Pick one canonical type and update the other source schema to match. | +| [FIELD_WITH_MISSING_REQUIRED_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Field-With-Missing-Required-Arguments) | The argument '\{0\}' must be defined as required in schema '\{1\}'. Arguments marked with @require are treated as non-required. | The argument is required in some source schemas and optional or `@require`-driven in another. Mark the argument as required in the named source schema, or align all source schemas on the same nullability. | +| [IMPLEMENTED_BY_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Implemented-by-Inaccessible) | The field '\{0\}' implementing interface field '\{1\}' is inaccessible in the composed schema. | An object type implements an interface field, but its implementation is hidden. Either expose the implementing field or hide the interface field as well. | +| `IMPLEMENT_WITHOUT_DEFAULT` | The field '\{0\}' is marked with @implement in schema '\{1\}', but no applicable default implementation exists for it. | Remove `@implement`, or add an applicable default through an interface-object stand-in. Use `@implement` only when the declaration intentionally replaces that projected default. See [Replace a Projected Default](./interface-objects.md#replace-a-projected-default). | +| [INPUT_FIELD_DEFAULT_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-Field-Default-Mismatch) | The default value '\{0\}' of input field '\{1\}' in schema '\{2\}' differs from the default value of '\{3\}' in schema '\{4\}'. | The same input field has different defaults in two source schemas. Align the default value across all definitions of the input type. | +| [INPUT_FIELD_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-Field-Types-mergeable) | The input field '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | An input field has a different type structure (named type, list, or nullability) across source schemas. Decide on the canonical type and update the other source schema. | +| [INPUT_WITH_MISSING_REQUIRED_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Input-With-Missing-Required-Fields) | The input type '\{0\}' in schema '\{1\}' must define the required field '\{2\}'. | One source schema requires a field on an input type that another source schema omits. Add the field (with the same type) to the named source schema so the input shape is consistent. | +| `INPUT_WITH_MISSING_ONEOF` | The input type '\{0\}' in schema '\{1\}' must be annotated with the '@oneOf' directive, since the same type has been annotated with it in schema '\{2\}'. | The same input type is `@oneOf` in one source schema but not in another. Add `@oneOf` to the input type in the named source schema (or remove it from the other) so all definitions agree. | +| [INTERFACE_FIELD_NO_IMPLEMENTATION](https://graphql.github.io/composite-schemas-spec/draft/#sec-Interface-Field-No-Implementation) | The merged object type '\{0\}' must implement the field '\{1\}' on interface '\{2\}'. | After merging, an object type declares it implements the interface but does not provide every field the interface requires. Add the missing field to the object type, or stop implementing the interface. | +| `INTERFACE_OBJECT_FIELD_REQUIRES_IMPLEMENT` | The field '\{0\}' provided by schema '\{1\}' replaces the default implementation contributed to interface '\{2\}'. Mark it with @implement to keep this implementation, or remove it to adopt the default. | Add `@implement` when this source intentionally replaces the projected default. Otherwise, remove the field declaration so the type inherits the default. See [Replace a Projected Default](./interface-objects.md#replace-a-projected-default). | +| `INTERFACE_OBJECT_KEY_MISMATCH` | The @interfaceObject stand-in '\{0\}' in schema '\{1\}' declares the key '\{2\}', which does not match any key declared on the interface it stands in for. | Change the stand-in key to one declared or inferred on the same-named interface. The key fields and selection shape must match. See [Interface Objects](./interface-objects.md). | +| `INTERFACE_OBJECT_KEY_MISSING` | The @interfaceObject stand-in '\{0\}' in schema '\{1\}' must declare at least one @key directive. | Add at least one `@key` that matches a key on the same-named interface and can populate the stand-in lookup. See [Interface Objects](./interface-objects.md). | +| `INTERFACE_OBJECT_NO_INTERFACE` | The type '\{0\}' is declared as an @interfaceObject stand-in, but no source schema defines '\{0\}' as an interface. | Define the same-named interface in a concrete-aware source schema, or remove `@interfaceObject` if the type is not a stand-in. See [Interface Objects](./interface-objects.md). | +| `INVALID_ENUM_VALUES_MERGE_BEHAVIOR` | The enum values merge behavior '\{0\}' is invalid. | Set the `enumValuesMergeBehavior` merger setting in the composition settings to `Auto`, `Strict`, or `Union`. | +| [INVALID_FIELD_SHARING](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-Field-Sharing) | The field '\{0\}' in schema '\{1\}' must be shareable. | The same non-key field is owned by more than one source schema without an explicit sharing contract. Add `@shareable` to the field (or to the enclosing type, which applies to all of its fields) in every source schema that defines it, or move ownership to a single source schema. See [Field Ownership](./field-ownership-and-sharing.md). | +| [INVALID_GRAPHQL](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-GraphQL) | Invalid GraphQL in source schema. Exception message: \{0\}. | The source schema does not parse as valid GraphQL. Read the included parser exception, fix the SDL in the offending source schema, and re-export. See [Getting Started](./getting-started.md) for how to export a source schema. | +| `INVALID_NODE_RESOLUTION` | Source-schema node resolution requires global object identification to be enabled. Or: The node resolution mode '\{0\}' is invalid. | Enable Global Object Identification when you select source-schema resolution. For programmatic composition, use `NodeResolution.Gateway` or `NodeResolution.SourceSchema`. See [Configure node resolution](./connectors/apollofederation.md#configure-node-resolution). | +| `INVALID_PROJECTED_FIELD_SHARING` | The default field '\{0\}' on type '\{1\}' is contributed by unrelated interfaces \{2\}; every contributing @interfaceObject declaration must be marked @shareable. | Mark every contributing stand-in field `@shareable` only when its type and semantics match, or remove the conflicting default from all but one interface. See [Share Defaults from Unrelated Interfaces](./interface-objects.md#share-defaults-from-unrelated-interfaces). | +| `INVALID_SHAREABLE_FIELD_RUNTIME_TYPE_ROUTING` | The shareable field runtime type routing mode '\{0\}' is invalid. | Set `ShareableFieldRuntimeTypeRouting` to `SourceLocal` or `CommonRuntimeTypes`. See [Composition Settings](./local-development.md#composition-settings) for the Aspire setting. | +| [INVALID_SHAREABLE_USAGE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Invalid-Shareable-Usage) | The field '\{0\}' in schema '\{1\}' must not be marked as shareable. | `@shareable` was applied where it is not allowed (for example, on a field that is already governed by another ownership directive). Remove `@shareable` from the field. See [Field Ownership](./field-ownership-and-sharing.md). | +| [IS_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Fields) | The @is directive on argument '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The field selection map in `@is(field: ...)` does not resolve against the entity's fields. Update it to reference fields that actually exist on the parent type. See [Entities and Lookups](./entities-and-lookups.md). | +| [IS_INVALID_FIELD_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Field-Type) | The @is directive on argument '\{0\}' in schema '\{1\}' must specify a string value for the 'field' argument. | The `field` argument was given as a non-string literal. Pass it as a quoted string, for example `@is(field: "id")`. | +| [IS_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Syntax) | The @is directive on argument '\{0\}' in schema '\{1\}' contains invalid syntax in the 'field' argument. | The string value for `field` is not a valid field selection map. Rewrite it as a syntactically valid field selection map. | +| [IS_INVALID_USAGE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Is-Invalid-Usage) | The @is directive on argument '\{0\}' in schema '\{1\}' is invalid because the declaring field is not a lookup field. | `@is` is only valid on arguments of lookup fields. Move `@is` to a lookup, or annotate the declaring field with `@lookup`. See [Entities and Lookups](./entities-and-lookups.md). | +| [KEY_DIRECTIVE_IN_FIELDS_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Directive-in-Fields-Argument) | A @key directive on type '\{0\}' in schema '\{1\}' references field '\{2\}', which must not include directive applications. | The `fields` selection of `@key` cannot apply directives to the selected fields. Remove the directive applications from the selection set. See [Entities and Lookups](./entities-and-lookups.md). | +| [KEY_FIELDS_SELECT_INVALID_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Fields-Select-Invalid-Type) | A @key directive on type '\{0\}' in schema '\{1\}' references field '\{2\}', which must not be a list, interface, or union type. | Key fields must resolve to scalar, enum, or object types, not lists, interfaces, or unions. Pick a different field for the key, or model the key as a scalar identifier. | +| [KEY_INVALID_ARGUMENTS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Arguments) | A @key directive on type '\{0\}' in schema '\{1\}' specifies invalid arguments. \{2\} | Key fields support constant arguments. Remove or correct any argument that is unknown, has an incompatible value type, or leaves a required argument unsupplied. | +| [KEY_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Fields) | A @key directive on type '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The `fields` selection refers to fields that do not exist on the type. Add the missing fields to the type or correct the selection set. See [Entities and Lookups](./entities-and-lookups.md). | +| [KEY_INVALID_FIELDS_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Fields-Type) | A @key directive on type '\{0\}' in schema '\{1\}' must specify a string value for the 'fields' argument. | The `fields` argument was given as a non-string literal. Pass it as a quoted string, for example `@key(fields: "id")`. | +| [KEY_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Key-Invalid-Syntax) | A @key directive on type '\{0\}' in schema '\{1\}' contains invalid syntax in the 'fields' argument. | The string value for `fields` is not a valid GraphQL selection set. Rewrite it as a syntactically valid selection. | +| [LOOKUP_RETURNS_LIST](https://graphql.github.io/composite-schemas-spec/draft/#sec-Lookup-Returns-List) | The lookup field '\{0\}' in schema '\{1\}' must not return a list. | A `@lookup` field returned a list, which prevents the gateway from mapping a key to a single entity. Change the return type to a single nullable entity. See [Entities and Lookups](./entities-and-lookups.md). | +| [NON_NULL_INPUT_FIELD_IS_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Non-Null-Input-Fields-cannot-be-inaccessible) | The non-null input field '\{0\}' in schema '\{1\}' must be accessible in the composed schema. | A required input field was hidden by `@inaccessible`, which would make the input impossible to construct from the gateway. Either make the field nullable or expose it. | +| [NO_QUERIES](https://graphql.github.io/composite-schemas-spec/draft/#sec-No-Queries) | The merged query type has no accessible fields. | After merging and applying accessibility filters, no query fields remain. Expose at least one query field, or remove `@inaccessible` from the queries you intend to publish. | +| `OPT_IN_FEATURE_STABILITY_MISMATCH` | The opt-in feature '\{0\}' has a different stability in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | Two or more source schemas declare the same opt-in feature with different `stability` values. Align the stability for that feature across all subgraphs that declare it using `OptInFeatureStability("featureName", "stability")` so every declaration uses the same string. | +| [OUTPUT_FIELD_TYPES_NOT_MERGEABLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Output-Field-Types-Mergeable) | The output field '\{0\}' has a different type shape in schema '\{1\}' than it does in schema '\{2\}'. | The field's return type cannot be unified across source schemas: leaf types (scalar or enum) differ in name or kind, the list shape differs, or composite types (object, interface, or union) share no common supertype. Nullability differences and composite types with a common supertype merge automatically; otherwise align the return types. | +| [OVERRIDE_FROM_SELF](https://graphql.github.io/composite-schemas-spec/draft/#sec-Override-from-Self) | The @override directive on field '\{0\}' in schema '\{1\}' must not reference the same schema. | `@override(from: "...")` cannot point at the source schema that owns the directive. Set `from` to the name of the source schema the field is being taken from. See [Field Ownership](./field-ownership-and-sharing.md). | +| [OVERRIDE_ON_INTERFACE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Override-on-Interface) | The interface field '\{0\}' in schema '\{1\}' must not be annotated with the @override directive. | `@override` cannot be applied to interface fields. Move the directive to the implementing object types if you need to take ownership of a specific implementation. See [Field Ownership](./field-ownership-and-sharing.md). | +| [PROVIDES_DIRECTIVE_IN_FIELDS_ARGUMENT](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Directive-in-Fields-Argument) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must not include directive applications. | The selection in `@provides(fields: ...)` cannot apply directives. Remove the directive applications from the selection. See [Field Ownership](./field-ownership-and-sharing.md). | +| [PROVIDES_FIELDS_HAS_ARGUMENTS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Fields-Has-Arguments) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must not have arguments. | A field selected by `@provides` is being called with arguments, which is not allowed. Pick an argument-less field or restructure the schema so the provided field needs no arguments. | +| [PROVIDES_FIELDS_MISSING_EXTERNAL](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Fields-Missing-External) | The @provides directive on field '\{0\}' in schema '\{1\}' references field '\{2\}', which must be marked as external. | `@provides` only makes sense when the referenced field is `@external` here. Add `@external` to the referenced field in the same source schema. See [Field Ownership](./field-ownership-and-sharing.md). | +| [PROVIDES_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Fields) | The @provides directive on field '\{0\}' in schema '\{1\}' specifies an invalid field selection. | The selection points to fields that do not exist on the returned type. Update the selection so it matches fields that the returned type actually defines. | +| [PROVIDES_INVALID_FIELDS_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Fields-Type) | The @provides directive on field '\{0\}' in schema '\{1\}' must specify a string value for the 'fields' argument. | The `fields` argument was given as a non-string literal. Pass it as a quoted string. | +| [PROVIDES_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-Invalid-Syntax) | The @provides directive on field '\{0\}' in schema '\{1\}' contains invalid syntax in the 'fields' argument. | The selection set syntax in `fields` is invalid. Rewrite it as a valid GraphQL selection set. | +| [PROVIDES_ON_NON_COMPOSITE_FIELD](https://graphql.github.io/composite-schemas-spec/draft/#sec-Provides-on-Non-Composite-Field) | The field '\{0\}' in schema '\{1\}' includes a @provides directive, but does not return a composite type. | `@provides` only applies when a field returns an object, interface, or union, because the directive describes which sub-fields are also delivered. Remove `@provides`, or change the field to return a composite type. | +| [QUERY_ROOT_TYPE_INACCESSIBLE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Query-Root-Type-Inaccessible) | The root query type in schema '\{0\}' must be accessible. | The query root cannot be marked `@inaccessible`. Remove the directive from the root query type. | +| `REFERENCE_TO_DEPRECATED_TYPE` | The merged field '\{0\}' in type '\{1\}' cannot reference the deprecated type '\{2\}'. Either deprecate the field or change its return type. | A field that is not deprecated returns a deprecated object type. Merging propagates an object type's deprecation from every source schema that declares it, so the composed schema can pair a deprecated type with a field that no source schema deprecated. Either deprecate the field as well, or change its return type. | +| [REFERENCE_TO_INACCESSIBLE_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Reference-To-Inaccessible-Type) | (Message varies by reference site: field argument, input field, or output field.) | A publicly accessible position (a field argument, input field, or output field) references a type that has been hidden from the composed schema. Either expose the referenced type or change the position to use a publicly accessible type. | +| [REFERENCE_TO_INTERNAL_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Reference-To-Internal-Type) | The merged field '\{0\}' in type '\{1\}' cannot reference the internal type '\{2\}'. | A public field returns an internal type, which would leak the internal type into the composed schema. Either mark the field as internal as well, or change the return type to a non-internal one. | +| [REQUIRE_INVALID_FIELDS](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Fields) | The @require directive on argument '\{0\}' in schema '\{1\}' specifies an invalid field selection against the composed schema. | The field selection map in `@require(field: ...)` references fields that the parent type does not expose. Update it to reference fields that exist on the parent. See [Entities and Lookups](./entities-and-lookups.md). | +| [REQUIRE_INVALID_FIELD_TYPE](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Field-Type) | The @require directive on argument '\{0\}' in schema '\{1\}' must specify a string value for the 'field' argument. | The `field` argument must be a string literal. Pass it as a quoted string. | +| [REQUIRE_INVALID_SYNTAX](https://graphql.github.io/composite-schemas-spec/draft/#sec-Require-Invalid-Syntax) | The @require directive on argument '\{0\}' in schema '\{1\}' contains invalid syntax in the 'field' argument. | The `field` value is not a valid field selection map. Rewrite it as a syntactically valid field selection map. | +| [ROOT_MUTATION_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Mutation-Used) | The root mutation type in schema '\{0\}' must be named 'Mutation'. | Fusion requires the root mutation type to use the canonical name `Mutation`. Rename the type in the named source schema. A type named `Mutation` must not exist unless it is the root mutation type. | +| [ROOT_QUERY_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Query-Used) | The root query type in schema '\{0\}' must be named 'Query'. | Fusion requires the root query type to use the canonical name `Query`. Rename the type in the named source schema. A type named `Query` must not exist unless it is the root query type. See [Getting Started](./getting-started.md) for the expected schema layout. | +| [ROOT_SUBSCRIPTION_USED](https://graphql.github.io/composite-schemas-spec/draft/#sec-Root-Subscription-Used) | The root subscription type in schema '\{0\}' must be named 'Subscription'. | Fusion requires the root subscription type to use the canonical name `Subscription`. Rename the type in the named source schema. A type named `Subscription` must not exist unless it is the root subscription type. | +| [TYPE_KIND_MISMATCH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Type-Kind-Mismatch) | The type '\{0\}' has a different kind in schema '\{1\}' (\{2\}) than it does in schema '\{3\}' (\{4\}). | The same type name was used for different kinds (for example, an object in one source schema and an interface in another). Decide which kind is correct and update the other source schema, or rename one of the types so they no longer collide. | +| [UNSATISFIABLE_QUERY_PATH](https://graphql.github.io/composite-schemas-spec/draft/#sec-Unsatisfiable-Query-Path) | (Message varies. Includes the unreachable field, the path the validator tried, and the lookups it considered.) | Some reachable field cannot be resolved through the available `@lookup` and `@key` paths. This is an error unless the source-schema node-resolution exception described under Warnings applies. See [Diagnosing UNSATISFIABLE_QUERY_PATH Diagnostics](#diagnosing-unsatisfiable_query_path-diagnostics) for how to read the message and fix the underlying gap. | ## Warnings diff --git a/website/content/docs/fusion/connectors/apollofederation.md b/website/content/docs/fusion/connectors/apollofederation.md index 5a0ca8fba83..b544530cc0d 100644 --- a/website/content/docs/fusion/connectors/apollofederation.md +++ b/website/content/docs/fusion/connectors/apollofederation.md @@ -395,8 +395,9 @@ The connector is under active development and ships as a preview. Composition re - **Apollo Federation v1 requires explicit source settings.** Set `extensions.chillicream.apolloFederationSupport.version` to exact `"1.0"` for that source. Raw v1 SDL without the marker is rejected with `FEDERATION_V1_NOT_SUPPORTED`. Federation v2 schemas continue to use their `@link`; for a remote source, the optional exact `"2.0"` marker selects `_service.sdl` acquisition without enabling the v1 parser. - **Several Apollo Federation v2 directives are not supported.** Composition rejects `@composeDirective`, `@authenticated`, `@requiresScopes`, and `@policy` with `FEDERATION_DIRECTIVE_NOT_SUPPORTED`. Remove the directive, or express the equivalent with a GraphQL Federation construct. +- **Enums used in input positions must define identical value sets across subgraphs.** When an Apollo Federation subgraph is part of the composition, enum value merging resolves to union, so enums used only in output positions compose even when their values diverge. Enums used in any input position must still agree exactly and are otherwise rejected with `ENUM_VALUES_MISMATCH`; Apollo's input-only intersection merge is not performed. The `enumValuesMergeBehavior` merger setting can force strict merging. -Both error codes are listed in the [Composition log-code reference](../composition.md#log-codes-reference). +These error codes are listed in the [Composition log-code reference](../composition.md#log-codes-reference). Feature support tracks the [GraphQL Hive federation-gateway-audit](https://github.com/graphql-hive/federation-gateway-audit) compliance suite, and the set of supported features grows as the connector passes more of that suite. diff --git a/website/content/docs/nitro/cli/fusion.md b/website/content/docs/nitro/cli/fusion.md index 6d726eb2542..96a8bc24111 100644 --- a/website/content/docs/nitro/cli/fusion.md +++ b/website/content/docs/nitro/cli/fusion.md @@ -282,6 +282,7 @@ nitro fusion compose \ | `-e, --env, --environment ` | | Name of the environment used for value substitution in `schema-settings.json` files. | | `--cache-control-merge-behavior ` | | Choose how `@cacheControl` directives are merged. | | `--enable-global-object-identification` | | Add the `Query.node` field for global object identification. | +| `--enum-values-merge-behavior ` | | Choose how enum values are merged across source schemas. | | `--node-resolution ` | | Choose whether `Query.node` identifiers are resolved by the gateway or a source schema. | | `--tag-merge-behavior ` | | Choose how `@tag` directives are merged. | | `--shareable-field-runtime-type-routing ` | | Choose how runtime types are routed for Apollo Federation shareable abstract fields. | @@ -386,6 +387,7 @@ nitro fusion settings set \ | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------------------- | | `allow-non-resolvable-interface-objects` | `true`, `false` | Allow Apollo interface objects without a resolvable key. | | `cache-control-merge-behavior` | `ignore`, `include`, `include-private` | Choose how `@cacheControl` directives are merged. | +| `enum-values-merge-behavior` | `auto`, `strict`, `union` | Choose how enum values are merged across source schemas. | | `exclude-by-tag` | Comma-separated tags | Exclude fields and types by tag. | | `global-object-identification` | `true`, `false` | Enable global object identification through `Query.node`. | | `include-satisfiability-paths` | `true`, `false` | Include paths in satisfiability diagnostics. |