diff --git a/src/All.slnx b/src/All.slnx
index c0922157e04..39c317bdc08 100644
--- a/src/All.slnx
+++ b/src/All.slnx
@@ -214,9 +214,11 @@
+
+
@@ -227,6 +229,9 @@
+
+
+
diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx
index b9b55fd012a..560ef71f902 100644
--- a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx
+++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx
@@ -6,6 +6,8 @@
+
+
@@ -19,6 +21,9 @@
+
+
+
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/EntityKeyInfo.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/EntityKeyInfo.cs
new file mode 100644
index 00000000000..7e78456fcbd
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/EntityKeyInfo.cs
@@ -0,0 +1,18 @@
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Represents a single @key directive on a federation entity type.
+///
+internal sealed class EntityKeyInfo
+{
+ ///
+ /// Gets the raw field selection string from @key(fields: "...").
+ ///
+ public required string Fields { get; init; }
+
+ ///
+ /// Gets a value indicating whether the key is resolvable.
+ /// Defaults to true when the resolvable argument is omitted.
+ ///
+ public bool Resolvable { get; init; } = true;
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationDirectiveNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationDirectiveNames.cs
new file mode 100644
index 00000000000..eba74e40c74
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationDirectiveNames.cs
@@ -0,0 +1,19 @@
+namespace HotChocolate.Fusion.ApolloFederation;
+
+internal static class FederationDirectiveNames
+{
+ public const string Key = "key";
+ public const string Requires = "requires";
+ public const string Provides = "provides";
+ public const string External = "external";
+ public const string Link = "link";
+ public const string Shareable = "shareable";
+ public const string Inaccessible = "inaccessible";
+ public const string Override = "override";
+ public const string Tag = "tag";
+ public const string InterfaceObject = "interfaceObject";
+ public const string ComposeDirective = "composeDirective";
+ public const string Authenticated = "authenticated";
+ public const string RequiresScopes = "requiresScopes";
+ public const string Policy = "policy";
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationFieldNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationFieldNames.cs
new file mode 100644
index 00000000000..571a037721b
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationFieldNames.cs
@@ -0,0 +1,7 @@
+namespace HotChocolate.Fusion.ApolloFederation;
+
+internal static class FederationFieldNames
+{
+ public const string Entities = "_entities";
+ public const string Service = "_service";
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs
new file mode 100644
index 00000000000..b34dda1ef61
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs
@@ -0,0 +1,105 @@
+using HotChocolate.Fusion.Errors;
+using HotChocolate.Language;
+using HotChocolate.Types.Mutable;
+using static HotChocolate.Fusion.ApolloFederation.Properties.FederationResources;
+
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Validates a for Apollo Federation v2 compatibility
+/// and detects unsupported directives.
+///
+internal static class FederationSchemaAnalyzer
+{
+ private const string FederationUrlPrefix = "specs.apollo.dev/federation";
+
+ private static readonly HashSet s_unsupportedDirectives =
+ [
+ FederationDirectiveNames.ComposeDirective,
+ FederationDirectiveNames.Authenticated,
+ FederationDirectiveNames.RequiresScopes,
+ FederationDirectiveNames.Policy,
+ FederationDirectiveNames.InterfaceObject
+ ];
+
+ ///
+ /// Validates the given federation schema and returns any composition errors.
+ ///
+ ///
+ /// The mutable schema definition to validate.
+ ///
+ ///
+ /// A list of instances. An empty list indicates success.
+ ///
+ public static List Validate(MutableSchemaDefinition schema)
+ {
+ var errors = new List();
+
+ ValidateFederationVersion(schema, errors);
+ ValidateUnsupportedDirectives(schema, errors);
+
+ return errors;
+ }
+
+ private static void ValidateFederationVersion(
+ MutableSchemaDefinition schema,
+ List errors)
+ {
+ var federationVersion = FindFederationVersion(schema);
+
+ if (federationVersion is null)
+ {
+ errors.Add(new CompositionError(FederationSchemaAnalyzer_FederationV1NotSupported));
+ }
+ }
+
+ private static string? FindFederationVersion(MutableSchemaDefinition schema)
+ {
+ foreach (var directive in schema.Directives)
+ {
+ if (!directive.Name.Equals(FederationDirectiveNames.Link, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (!directive.Arguments.TryGetValue("url", out var urlValue)
+ || urlValue is not StringValueNode urlString)
+ {
+ continue;
+ }
+
+ var url = urlString.Value;
+
+ if (!url.Contains(FederationUrlPrefix, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ // Extract version from URL like
+ // "https://specs.apollo.dev/federation/v2.5"
+ var lastSlash = url.LastIndexOf('/');
+
+ if (lastSlash >= 0 && lastSlash < url.Length - 1)
+ {
+ return url[(lastSlash + 1)..];
+ }
+ }
+
+ return null;
+ }
+
+ private static void ValidateUnsupportedDirectives(
+ MutableSchemaDefinition schema,
+ List errors)
+ {
+ foreach (var name in s_unsupportedDirectives)
+ {
+ if (schema.DirectiveDefinitions.ContainsName(name))
+ {
+ errors.Add(new CompositionError(string.Format(
+ FederationSchemaAnalyzer_DirectiveNotSupported,
+ name)));
+ }
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs
new file mode 100644
index 00000000000..7d62015057d
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs
@@ -0,0 +1,58 @@
+using System.Collections.Immutable;
+using HotChocolate.Fusion.Errors;
+using HotChocolate.Fusion.Results;
+using HotChocolate.Language;
+using HotChocolate.Types.Mutable;
+using HotChocolate.Types.Mutable.Serialization;
+using static HotChocolate.Fusion.ApolloFederation.Properties.FederationResources;
+
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Transforms an Apollo Federation v2 subgraph SDL into a Composite Schema Spec
+/// source schema SDL suitable for the HotChocolate Fusion composition pipeline.
+///
+public static class FederationSchemaTransformer
+{
+ ///
+ /// Transforms the given Apollo Federation v2 subgraph SDL.
+ ///
+ ///
+ /// The Apollo Federation v2 subgraph SDL to transform.
+ ///
+ ///
+ /// A containing the transformed SDL string
+ /// on success, or composition errors on failure.
+ ///
+ public static CompositionResult Transform(string federationSdl)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(federationSdl);
+
+ MutableSchemaDefinition schema;
+
+ try
+ {
+ schema = SchemaParser.Parse(federationSdl);
+ }
+ catch (SyntaxException ex)
+ {
+ return new CompositionError(
+ string.Format(FederationSchemaTransformer_ParseFailed, ex.Message));
+ }
+
+ var errors = FederationSchemaAnalyzer.Validate(schema);
+
+ if (errors.Count > 0)
+ {
+ return errors.ToImmutableArray();
+ }
+
+ RemoveFederationInfrastructure.Apply(schema);
+ GenerateLookupFields.Apply(schema);
+ RewriteKeyDirectives.Apply(schema);
+ TransformRequiresToRequire.Apply(schema);
+ RemoveExternalFields.Apply(schema);
+
+ return SchemaFormatter.FormatAsString(schema);
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationTypeNames.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationTypeNames.cs
new file mode 100644
index 00000000000..a5da21cd8ce
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationTypeNames.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.ApolloFederation;
+
+internal static class FederationTypeNames
+{
+ public const string Any = "_Any";
+ public const string Entity = "_Entity";
+ public const string Service = "_Service";
+ public const string FieldSet = "FieldSet";
+ public const string LegacyFieldSet = "_FieldSet";
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs
new file mode 100644
index 00000000000..69546fab0e0
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs
@@ -0,0 +1,610 @@
+using HotChocolate.Language;
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Generates @lookup query fields for each resolvable entity key.
+///
+/// Flat scalar keys (@key(fields: "id"), @key(fields: "sku package"))
+/// produce a lookup field whose arguments mirror the key fields one-to-one.
+///
+///
+/// Nested object and list keys (@key(fields: "metadata { id }"),
+/// @key(fields: "products { id }"),
+/// @key(fields: "products { id pid category { id tag } } selected { id }"))
+/// produce a lookup field with a single key argument of a freshly
+/// generated input object type. The input type exposes one field per top-level
+/// segment of the @key selection and mirrors the entity-representation
+/// shape so the Apollo Federation connector can copy the variable value
+/// straight into the _entities(representations: ...) payload.
+///
+///
+internal static class GenerateLookupFields
+{
+ private const string NestedLookupArgumentName = "key";
+
+ ///
+ /// Applies the lookup field generation to the schema.
+ ///
+ ///
+ /// The mutable schema definition to transform in place.
+ ///
+ public static void Apply(MutableSchemaDefinition schema)
+ {
+ if (schema.QueryType is null)
+ {
+ return;
+ }
+
+ var internalDef = new MutableDirectiveDefinition("internal");
+ var lookupDef = new MutableDirectiveDefinition("lookup");
+ var isDef = new MutableDirectiveDefinition("is");
+ var shareableDef = new MutableDirectiveDefinition("shareable");
+
+ // Generated input object types are cached by name so that repeated
+ // references to the same nested shape share a single input type.
+ var inputTypeCache = new Dictionary(
+ StringComparer.Ordinal);
+
+ // Snapshot the owning types up front: generating lookup fields for
+ // nested keys appends new input object types to 'schema.Types', which
+ // would otherwise invalidate an in-flight enumerator.
+ var complexTypes = schema.Types
+ .OfType()
+ .ToArray();
+
+ foreach (var complexType in complexTypes)
+ {
+ // Keys whose selection set references a list-typed field on the
+ // owner (e.g. '@key(fields: "products { id }")' where 'products'
+ // is '[Product!]!') are surfaced in the composite schema purely
+ // via the generated '@lookup' field + '@is' metadata. The '@key'
+ // directive itself is dropped from the type because the Composite
+ // Schema Spec disallows list, interface, or union top-level fields
+ // in '@key' selections.
+ var keysToRemove = new List();
+ var fieldsToMarkShareable = new HashSet(StringComparer.Ordinal);
+
+ foreach (var keyDirective in complexType.Directives["key"])
+ {
+ if (!keyDirective.Arguments.TryGetValue("fields", out var fieldsValue)
+ || fieldsValue is not StringValueNode fieldsString)
+ {
+ continue;
+ }
+
+ var resolvable = true;
+
+ if (keyDirective.Arguments.TryGetValue("resolvable", out var resolvableValue)
+ && resolvableValue is BooleanValueNode boolValue)
+ {
+ resolvable = boolValue.Value;
+ }
+
+ if (!resolvable)
+ {
+ continue;
+ }
+
+ var result = GenerateLookupField(
+ schema,
+ complexType,
+ fieldsString.Value,
+ internalDef,
+ lookupDef,
+ isDef,
+ inputTypeCache);
+
+ if (result is null)
+ {
+ continue;
+ }
+
+ schema.QueryType.Fields.Add(result.Field);
+
+ if (result.KeyHasListSegment)
+ {
+ keysToRemove.Add(keyDirective);
+
+ foreach (var fieldName in result.TopLevelKeyFieldNames)
+ {
+ fieldsToMarkShareable.Add(fieldName);
+ }
+ }
+ }
+
+ foreach (var directive in keysToRemove)
+ {
+ complexType.Directives.Remove(directive);
+ }
+
+ // Apollo Federation treats fields referenced by a '@key' directive
+ // as implicitly shareable. Once the list-typed '@key' is dropped
+ // we must surface that sharing intent explicitly so the Composite
+ // Schema Spec's field-sharing rule does not reject the field
+ // being produced by multiple source schemas.
+ if (fieldsToMarkShareable.Count > 0)
+ {
+ foreach (var fieldName in fieldsToMarkShareable)
+ {
+ if (complexType.Fields.TryGetField(fieldName, out var keyField)
+ && !keyField.Directives.ContainsName("shareable"))
+ {
+ keyField.Directives.Add(new Directive(shareableDef));
+ }
+ }
+ }
+ }
+ }
+
+ private static GenerateLookupFieldResult? GenerateLookupField(
+ MutableSchemaDefinition schema,
+ MutableComplexTypeDefinition complexType,
+ string fieldsSelection,
+ MutableDirectiveDefinition internalDef,
+ MutableDirectiveDefinition lookupDef,
+ MutableDirectiveDefinition isDef,
+ Dictionary inputTypeCache)
+ {
+ SelectionSetNode selectionSet;
+
+ try
+ {
+ selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet(
+ "{ " + fieldsSelection + " }");
+ }
+ catch (SyntaxException)
+ {
+ return null;
+ }
+
+ var topLevelFieldNames = new List();
+ var hasNestedSegment = false;
+ var keyHasListSegment = false;
+
+ foreach (var selection in selectionSet.Selections)
+ {
+ if (selection is not FieldNode topLevelField)
+ {
+ continue;
+ }
+
+ var keyFieldName = topLevelField.Name.Value;
+
+ if (!complexType.Fields.TryGetField(keyFieldName, out var ownerField))
+ {
+ return null;
+ }
+
+ topLevelFieldNames.Add(keyFieldName);
+
+ if (topLevelField.SelectionSet is null
+ || topLevelField.SelectionSet.Selections.Count == 0)
+ {
+ continue;
+ }
+
+ hasNestedSegment = true;
+
+ if (IsListType(ownerField.Type))
+ {
+ keyHasListSegment = true;
+ }
+ }
+
+ if (topLevelFieldNames.Count == 0)
+ {
+ return null;
+ }
+
+ // Build a temporary field to set as DeclaringMember on arguments.
+ var lookupField = new MutableOutputFieldDefinition(
+ "placeholder",
+ complexType)
+ {
+ DeclaringMember = schema.QueryType
+ };
+
+ var fieldName = hasNestedSegment
+ ? BuildNestedLookupField(
+ schema,
+ complexType,
+ selectionSet,
+ lookupField,
+ isDef,
+ inputTypeCache)
+ : BuildFlatLookupField(complexType, selectionSet, lookupField);
+
+ if (fieldName is null)
+ {
+ return null;
+ }
+
+ lookupField.Name = fieldName;
+ lookupField.Directives.Add(new Directive(internalDef));
+ lookupField.Directives.Add(new Directive(lookupDef));
+
+ return new GenerateLookupFieldResult(lookupField, keyHasListSegment, topLevelFieldNames);
+ }
+
+ private static string? BuildFlatLookupField(
+ MutableComplexTypeDefinition complexType,
+ SelectionSetNode selectionSet,
+ MutableOutputFieldDefinition lookupField)
+ {
+ var nameParts = new List();
+
+ foreach (var selection in selectionSet.Selections)
+ {
+ if (selection is not FieldNode topLevelField)
+ {
+ continue;
+ }
+
+ var keyFieldName = topLevelField.Name.Value;
+
+ if (!complexType.Fields.TryGetField(keyFieldName, out var ownerField))
+ {
+ return null;
+ }
+
+ if (ownerField.Type is not IInputType inputType)
+ {
+ continue;
+ }
+
+ var scalarArgumentType = EnsureNonNull(inputType);
+
+ if (scalarArgumentType is not IInputType nonNullScalar)
+ {
+ continue;
+ }
+
+ var scalarArgument = new MutableInputFieldDefinition(keyFieldName, nonNullScalar)
+ {
+ DeclaringMember = lookupField
+ };
+
+ lookupField.Arguments.Add(scalarArgument);
+ nameParts.Add(ToPascalCase(keyFieldName));
+ }
+
+ if (lookupField.Arguments.Count == 0)
+ {
+ return null;
+ }
+
+ return ToCamelCase(complexType.Name) + "By" + string.Join("And", nameParts);
+ }
+
+ private static string? BuildNestedLookupField(
+ MutableSchemaDefinition schema,
+ MutableComplexTypeDefinition complexType,
+ SelectionSetNode selectionSet,
+ MutableOutputFieldDefinition lookupField,
+ MutableDirectiveDefinition isDef,
+ Dictionary inputTypeCache)
+ {
+ // For nested/list keys we collapse the whole '@key' selection into a
+ // single input object type whose shape mirrors the Apollo Federation
+ // representation root. The connector then copies the variable value
+ // directly into the '_entities' representation (under the entity
+ // '__typename'), without needing to split across multiple arguments.
+ // The input type name encodes the full selection shape so that two
+ // source schemas with different sub-selections on the same top-level
+ // path generate distinct input types that survive composition merge.
+ var wrapperBaseName = complexType.Name
+ + "By"
+ + BuildSelectionFingerprint(selectionSet)
+ + "Input";
+
+ var wrapperInput = BuildInputTypeFromSelection(
+ schema,
+ complexType,
+ selectionSet,
+ wrapperBaseName,
+ inputTypeCache);
+
+ if (wrapperInput is null)
+ {
+ return null;
+ }
+
+ IType argumentType = new NonNullType(wrapperInput);
+
+ if (argumentType is not IInputType argumentInputType)
+ {
+ return null;
+ }
+
+ var keyArgument = new MutableInputFieldDefinition(NestedLookupArgumentName, argumentInputType)
+ {
+ DeclaringMember = lookupField
+ };
+
+ // Emit '@is(field: "{ ... }")' in Fusion FSM so downstream composer
+ // stages can discover the key shape. The expression describes how each
+ // top-level input field populates the corresponding output path on
+ // the entity type.
+ var fsm = BuildRootFieldSelectionMap(complexType, selectionSet);
+
+ keyArgument.Directives.Add(
+ new Directive(isDef, new ArgumentAssignment("field", fsm)));
+
+ lookupField.Arguments.Add(keyArgument);
+
+ return ToCamelCase(complexType.Name) + "By" + BuildSelectionFingerprint(selectionSet);
+ }
+
+ private static MutableInputObjectTypeDefinition? BuildInputTypeFromSelection(
+ MutableSchemaDefinition schema,
+ ITypeDefinition ownerType,
+ SelectionSetNode selectionSet,
+ string baseName,
+ Dictionary inputTypeCache)
+ {
+ if (ownerType is not MutableComplexTypeDefinition ownerComplex)
+ {
+ return null;
+ }
+
+ var inputType = new MutableInputObjectTypeDefinition(baseName);
+
+ foreach (var selection in selectionSet.Selections)
+ {
+ if (selection is not FieldNode fieldNode)
+ {
+ continue;
+ }
+
+ var fieldName = fieldNode.Name.Value;
+
+ if (!ownerComplex.Fields.TryGetField(fieldName, out var childField))
+ {
+ return null;
+ }
+
+ IInputType inputFieldType;
+
+ if (fieldNode.SelectionSet is null
+ || fieldNode.SelectionSet.Selections.Count == 0)
+ {
+ // Leaf scalar inside the nested selection.
+ if (childField.Type is not IInputType leafInputType)
+ {
+ return null;
+ }
+
+ inputFieldType = leafInputType;
+ }
+ else
+ {
+ // Deeper nested object/list: recurse into another input type.
+ var childIsList = IsListType(childField.Type);
+ var nestedBaseName = baseName + "_" + ToPascalCase(fieldName);
+
+ var nestedInput = BuildInputTypeFromSelection(
+ schema,
+ childField.Type.NamedType(),
+ fieldNode.SelectionSet,
+ nestedBaseName,
+ inputTypeCache);
+
+ if (nestedInput is null)
+ {
+ return null;
+ }
+
+ IType nestedType = nestedInput;
+
+ if (childIsList)
+ {
+ nestedType = new NonNullType(new ListType(new NonNullType(nestedType)));
+ }
+
+ if (nestedType is not IInputType nestedInputFieldType)
+ {
+ return null;
+ }
+
+ inputFieldType = nestedInputFieldType;
+ }
+
+ var inputField = new MutableInputFieldDefinition(fieldName, inputFieldType)
+ {
+ DeclaringMember = inputType
+ };
+
+ inputType.Fields.Add(inputField);
+ }
+
+ if (inputType.Fields.Count == 0)
+ {
+ return null;
+ }
+
+ // Deduplicate: an identically shaped input type may already have been
+ // generated for a prior key that referenced the same owner type.
+ if (inputTypeCache.TryGetValue(inputType.Name, out var existing))
+ {
+ return existing;
+ }
+
+ inputTypeCache[inputType.Name] = inputType;
+ schema.Types.Add(inputType);
+
+ return inputType;
+ }
+
+ private static bool IsListType(IType type)
+ {
+ while (true)
+ {
+ switch (type)
+ {
+ case ListType:
+ return true;
+ case NonNullType nonNull:
+ type = nonNull.NullableType;
+ continue;
+ default:
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// Builds a PascalCase fingerprint of a key's selection set so that two
+ /// source schemas with divergent sub-selections on the same top-level path
+ /// generate distinct, non-colliding input type names.
+ ///
+ private static string BuildSelectionFingerprint(SelectionSetNode selectionSet)
+ {
+ var parts = new List();
+
+ foreach (var selection in selectionSet.Selections)
+ {
+ if (selection is not FieldNode fieldNode)
+ {
+ continue;
+ }
+
+ parts.Add(ToPascalCase(fieldNode.Name.Value));
+
+ if (fieldNode.SelectionSet?.Selections.Count > 0)
+ {
+ parts.Add(BuildSelectionFingerprint(fieldNode.SelectionSet));
+ }
+ }
+
+ return string.Join("And", parts);
+ }
+
+ ///
+ /// Builds the '@is(field: "{ ... }")' expression for a nested/list lookup
+ /// argument. Each top-level selection in the '@key' is emitted as an
+ /// object-field in Fusion FSM syntax that names the entity-side field and
+ /// the value-selection that derives it from the input.
+ ///
+ private static string BuildRootFieldSelectionMap(
+ MutableComplexTypeDefinition complexType,
+ SelectionSetNode selectionSet)
+ {
+ var parts = new List();
+
+ foreach (var selection in selectionSet.Selections)
+ {
+ if (selection is not FieldNode topLevelField)
+ {
+ continue;
+ }
+
+ var keyFieldName = topLevelField.Name.Value;
+
+ if (!complexType.Fields.TryGetField(keyFieldName, out var ownerField))
+ {
+ continue;
+ }
+
+ if (topLevelField.SelectionSet is null
+ || topLevelField.SelectionSet.Selections.Count == 0)
+ {
+ parts.Add(keyFieldName);
+ continue;
+ }
+
+ var innerBody = BuildInnerObjectBody(topLevelField.SelectionSet);
+ var ownerIsList = IsListType(ownerField.Type);
+
+ var valueExpression = ownerIsList
+ ? $"{keyFieldName}[{{ {innerBody} }}]"
+ : $"{keyFieldName}.{{ {innerBody} }}";
+
+ parts.Add($"{keyFieldName}: {valueExpression}");
+ }
+
+ return "{ " + string.Join(", ", parts) + " }";
+ }
+
+ ///
+ /// Renders the contents of the '{ ... }' object selection for a Fusion
+ /// field-selection-map value. Leaf fields are emitted as bare names; nested
+ /// object selections use name: path.{ ... }; nested list selections
+ /// currently also use name: path.{ ... } since we cannot resolve the
+ /// child field's list-ness from a alone at
+ /// deeper levels.
+ ///
+ private static string BuildInnerObjectBody(SelectionSetNode selectionSet)
+ {
+ var parts = new List();
+
+ foreach (var selection in selectionSet.Selections)
+ {
+ if (selection is not FieldNode fieldNode)
+ {
+ continue;
+ }
+
+ var fieldName = fieldNode.Name.Value;
+
+ if (fieldNode.SelectionSet is null
+ || fieldNode.SelectionSet.Selections.Count == 0)
+ {
+ parts.Add(fieldName);
+ continue;
+ }
+
+ var innerBody = BuildInnerObjectBody(fieldNode.SelectionSet);
+ parts.Add($"{fieldName}: {fieldName}.{{ {innerBody} }}");
+ }
+
+ return string.Join(", ", parts);
+ }
+
+ private static IType EnsureNonNull(IType type)
+ {
+ if (type.Kind is TypeKind.NonNull)
+ {
+ return type;
+ }
+
+ return new NonNullType(type);
+ }
+
+ private static string ToCamelCase(string value)
+ {
+ if (value.Length == 0)
+ {
+ return value;
+ }
+
+ if (char.IsLower(value[0]))
+ {
+ return value;
+ }
+
+ return char.ToLowerInvariant(value[0]) + value[1..];
+ }
+
+ private static string ToPascalCase(string value)
+ {
+ if (value.Length == 0)
+ {
+ return value;
+ }
+
+ if (char.IsUpper(value[0]))
+ {
+ return value;
+ }
+
+ return char.ToUpperInvariant(value[0]) + value[1..];
+ }
+
+ private sealed record GenerateLookupFieldResult(
+ MutableOutputFieldDefinition Field,
+ bool KeyHasListSegment,
+ IReadOnlyList TopLevelKeyFieldNames);
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/HotChocolate.Fusion.Composition.ApolloFederation.csproj b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/HotChocolate.Fusion.Composition.ApolloFederation.csproj
new file mode 100644
index 00000000000..01abf1e6865
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/HotChocolate.Fusion.Composition.ApolloFederation.csproj
@@ -0,0 +1,33 @@
+
+
+
+ HotChocolate.Fusion.Composition.ApolloFederation
+ HotChocolate.Fusion.ApolloFederation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ResXFileCodeGenerator
+ FederationResources.Designer.cs
+
+
+
+
+
+ True
+ True
+ FederationResources.resx
+
+
+
+
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/Properties/FederationResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/Properties/FederationResources.Designer.cs
new file mode 100644
index 00000000000..e9060eef903
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/Properties/FederationResources.Designer.cs
@@ -0,0 +1,89 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace HotChocolate.Fusion.ApolloFederation.Properties {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ // This class was auto-generated by the StronglyTypedResourceBuilder
+ // class via a tool like ResGen or Visual Studio.
+ // To add or remove a member, edit your .ResX file then rerun ResGen
+ // with the /str option, or rebuild your VS project.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class FederationResources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal FederationResources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("HotChocolate.Fusion.ApolloFederation.Properties.FederationResources", typeof(FederationResources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The @{0} directive is not supported..
+ ///
+ internal static string FederationSchemaAnalyzer_DirectiveNotSupported {
+ get {
+ return ResourceManager.GetString("FederationSchemaAnalyzer_DirectiveNotSupported", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Federation v1 is not supported..
+ ///
+ internal static string FederationSchemaAnalyzer_FederationV1NotSupported {
+ get {
+ return ResourceManager.GetString("FederationSchemaAnalyzer_FederationV1NotSupported", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Failed to parse federation SDL: {0}.
+ ///
+ internal static string FederationSchemaTransformer_ParseFailed {
+ get {
+ return ResourceManager.GetString("FederationSchemaTransformer_ParseFailed", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/Properties/FederationResources.resx b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/Properties/FederationResources.resx
new file mode 100644
index 00000000000..8f0f053a869
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/Properties/FederationResources.resx
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Federation v1 is not supported.
+
+
+ The @{0} directive is not supported.
+
+
+ Failed to parse federation SDL: {0}
+
+
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveExternalFields.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveExternalFields.cs
new file mode 100644
index 00000000000..ba4a8218bc6
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveExternalFields.cs
@@ -0,0 +1,41 @@
+using HotChocolate.Types.Mutable;
+
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Removes fields marked with @external from complex types.
+///
+internal static class RemoveExternalFields
+{
+ ///
+ /// Removes all @external fields from the schema.
+ ///
+ ///
+ /// The mutable schema definition to transform in place.
+ ///
+ public static void Apply(MutableSchemaDefinition schema)
+ {
+ foreach (var type in schema.Types)
+ {
+ if (type is not MutableComplexTypeDefinition complexType)
+ {
+ continue;
+ }
+
+ var externalFields = new List();
+
+ foreach (var field in complexType.Fields)
+ {
+ if (field.Directives.ContainsName(FederationDirectiveNames.External))
+ {
+ externalFields.Add(field);
+ }
+ }
+
+ foreach (var field in externalFields)
+ {
+ complexType.Fields.Remove(field);
+ }
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs
new file mode 100644
index 00000000000..790ff2a9432
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs
@@ -0,0 +1,75 @@
+using HotChocolate.Types.Mutable;
+
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Removes Apollo Federation infrastructure types, directives, and fields
+/// from a mutable schema definition.
+///
+internal static class RemoveFederationInfrastructure
+{
+ private static readonly HashSet _federationDirectiveNames = new(StringComparer.Ordinal)
+ {
+ FederationDirectiveNames.Key,
+ FederationDirectiveNames.Requires,
+ FederationDirectiveNames.Provides,
+ FederationDirectiveNames.External,
+ FederationDirectiveNames.Link,
+ FederationDirectiveNames.Shareable,
+ FederationDirectiveNames.Inaccessible,
+ FederationDirectiveNames.Override,
+ FederationDirectiveNames.Tag,
+ FederationDirectiveNames.InterfaceObject,
+ FederationDirectiveNames.ComposeDirective,
+ FederationDirectiveNames.Authenticated,
+ FederationDirectiveNames.RequiresScopes,
+ FederationDirectiveNames.Policy
+ };
+
+ private static readonly HashSet _federationScalarNames = new(StringComparer.Ordinal)
+ {
+ FederationTypeNames.Any,
+ FederationTypeNames.FieldSet,
+ FederationTypeNames.LegacyFieldSet
+ };
+
+ ///
+ /// Applies the transformation to remove federation infrastructure from the schema.
+ ///
+ ///
+ /// The mutable schema definition to transform in place.
+ ///
+ public static void Apply(MutableSchemaDefinition schema)
+ {
+ // Remove federation directive definitions.
+ foreach (var name in _federationDirectiveNames)
+ {
+ schema.DirectiveDefinitions.Remove(name);
+ }
+
+ // Remove federation scalar types.
+ foreach (var name in _federationScalarNames)
+ {
+ schema.Types.Remove(name);
+ }
+
+ // Remove _Service type and _Entity union.
+ schema.Types.Remove(FederationTypeNames.Service);
+ schema.Types.Remove(FederationTypeNames.Entity);
+
+ // Remove _entities and _service fields from query type.
+ if (schema.QueryType is not null)
+ {
+ schema.QueryType.Fields.Remove(FederationFieldNames.Entities);
+ schema.QueryType.Fields.Remove(FederationFieldNames.Service);
+ }
+
+ // Remove @link directives from schema.
+ var linkDirectives = schema.Directives[FederationDirectiveNames.Link].ToList();
+
+ foreach (var directive in linkDirectives)
+ {
+ schema.Directives.Remove(directive);
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs
new file mode 100644
index 00000000000..9405de4fba0
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs
@@ -0,0 +1,51 @@
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Rewrites @key directives on entity types to strip the resolvable
+/// argument, keeping only the fields argument.
+///
+internal static class RewriteKeyDirectives
+{
+ ///
+ /// Applies the key directive rewrite to all type definitions in the schema.
+ ///
+ ///
+ /// The mutable schema definition to transform in place.
+ ///
+ public static void Apply(MutableSchemaDefinition schema)
+ {
+ foreach (var type in schema.Types)
+ {
+ if (type is not MutableComplexTypeDefinition complexType)
+ {
+ continue;
+ }
+
+ var keyDirectives = complexType.Directives["key"].ToList();
+
+ foreach (var directive in keyDirectives)
+ {
+ // Check if resolvable argument exists.
+ var hasResolvable = directive.Arguments.ContainsName("resolvable");
+
+ if (!hasResolvable)
+ {
+ continue;
+ }
+
+ // Replace with directive containing only fields argument.
+ if (directive.Arguments.TryGetValue("fields", out var fieldsValue))
+ {
+ var newDirective = new Directive(
+ directive.Definition,
+ new ArgumentAssignment("fields", fieldsValue));
+
+ complexType.Directives.Replace(directive, newDirective);
+ }
+ }
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs
new file mode 100644
index 00000000000..f2976447126
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs
@@ -0,0 +1,195 @@
+using HotChocolate.Language;
+using HotChocolate.Types;
+using HotChocolate.Types.Mutable;
+namespace HotChocolate.Fusion.ApolloFederation;
+
+///
+/// Transforms @requires directives into @require field arguments
+/// per the Composite Schema specification.
+///
+internal static class TransformRequiresToRequire
+{
+ ///
+ /// Applies the requires-to-require transformation on the schema.
+ ///
+ ///
+ /// The mutable schema definition to transform in place.
+ ///
+ public static void Apply(MutableSchemaDefinition schema)
+ {
+ var requireDef = new MutableDirectiveDefinition("require");
+
+ foreach (var type in schema.Types.OfType())
+ {
+ foreach (var field in type.Fields)
+ {
+ var requiresDirective = field.Directives.FirstOrDefault(
+ FederationDirectiveNames.Requires);
+
+ if (requiresDirective is null)
+ {
+ continue;
+ }
+
+ if (!requiresDirective.Arguments.TryGetValue("fields", out var fieldsValue)
+ || fieldsValue is not StringValueNode fieldsString)
+ {
+ continue;
+ }
+
+ SelectionSetNode selectionSet;
+
+ try
+ {
+ selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet(
+ "{ " + fieldsString.Value + " }");
+ }
+ catch (SyntaxException)
+ {
+ continue;
+ }
+
+ ExtractRequireArguments(
+ selectionSet,
+ [],
+ type,
+ schema,
+ field,
+ requireDef);
+
+ // Remove the @requires directive from the field.
+ field.Directives.Remove(requiresDirective);
+ }
+ }
+ }
+
+ private static void ExtractRequireArguments(
+ SelectionSetNode selectionSet,
+ List parentPath,
+ MutableComplexTypeDefinition currentType,
+ MutableSchemaDefinition schema,
+ MutableOutputFieldDefinition targetField,
+ MutableDirectiveDefinition requireDef)
+ {
+ foreach (var selection in selectionSet.Selections)
+ {
+ if (selection is not FieldNode fieldNode)
+ {
+ continue;
+ }
+
+ var fieldName = fieldNode.Name.Value;
+
+ if (fieldNode.SelectionSet?.Selections.Count > 0)
+ {
+ // Nested selection: recurse.
+ if (!currentType.Fields.TryGetField(fieldName, out var pathField))
+ {
+ continue;
+ }
+
+ var namedType = pathField.Type.NamedType();
+
+ if (!schema.Types.TryGetType(
+ namedType.Name, out var nestedType))
+ {
+ continue;
+ }
+
+ var nestedPath = new List(parentPath) { fieldName };
+
+ ExtractRequireArguments(
+ fieldNode.SelectionSet!,
+ nestedPath,
+ nestedType,
+ schema,
+ targetField,
+ requireDef);
+ }
+ else
+ {
+ // Leaf field: generate an argument.
+ if (!currentType.Fields.TryGetField(fieldName, out var sourceField))
+ {
+ continue;
+ }
+
+ var fieldType = sourceField.Type;
+ var nonNullType = EnsureNonNull(StripNonNull(fieldType));
+
+ if (nonNullType is not IInputType inputType)
+ {
+ continue;
+ }
+
+ string requireFieldValue;
+
+ if (parentPath.Count == 0)
+ {
+ requireFieldValue = fieldName;
+ }
+ else
+ {
+ requireFieldValue = BuildFieldPath(parentPath, fieldName);
+ }
+
+ var argument = new MutableInputFieldDefinition(fieldName, inputType)
+ {
+ DeclaringMember = targetField
+ };
+
+ argument.Directives.Add(
+ new Directive(
+ requireDef,
+ new ArgumentAssignment("field", requireFieldValue)));
+
+ targetField.Arguments.Add(argument);
+ }
+ }
+ }
+
+ private static string BuildFieldPath(List path, string fieldName)
+ {
+ // Build something like "dimension { height }"
+ var result = string.Empty;
+
+ for (var i = 0; i < path.Count; i++)
+ {
+ if (i > 0)
+ {
+ result += " { ";
+ }
+
+ result += path[i];
+ }
+
+ result += " { " + fieldName + " }";
+
+ for (var i = 1; i < path.Count; i++)
+ {
+ result += " }";
+ }
+
+ return result;
+ }
+
+ private static IType StripNonNull(IType type)
+ {
+ if (type is NonNullType nonNull)
+ {
+ return nonNull.NullableType;
+ }
+
+ return type;
+ }
+
+ private static IType EnsureNonNull(IType type)
+ {
+ if (type.Kind is TypeKind.NonNull)
+ {
+ return type;
+ }
+
+ return new NonNullType(type);
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationClientConfigurationParser.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationClientConfigurationParser.cs
new file mode 100644
index 00000000000..38fde097b4d
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationClientConfigurationParser.cs
@@ -0,0 +1,159 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using HotChocolate.Fusion.Configuration;
+using HotChocolate.Fusion.Execution.Clients;
+
+namespace HotChocolate.Fusion.Connectors.ApolloFederation;
+
+///
+/// Parser that claims the http transport for source schemas that carry an
+/// extensions.apolloFederation block and produces an
+/// .
+///
+internal sealed class ApolloFederationClientConfigurationParser : ISourceSchemaClientConfigurationParser
+{
+ public bool TryParse(
+ JsonProperty sourceSchema,
+ JsonProperty transport,
+ [NotNullWhen(true)] out ISourceSchemaClientConfiguration? configuration)
+ {
+ if (!sourceSchema.Value.TryGetProperty("extensions", out var extensions)
+ || extensions.ValueKind != JsonValueKind.Object
+ || !extensions.TryGetProperty("apolloFederation", out var federation)
+ || federation.ValueKind != JsonValueKind.Object)
+ {
+ configuration = null;
+ return false;
+ }
+
+ if (!transport.Name.Equals("http", StringComparison.OrdinalIgnoreCase))
+ {
+ configuration = null;
+ return false;
+ }
+
+ configuration = CreateConfiguration(sourceSchema.Name, transport.Value, federation);
+ return true;
+ }
+
+ private static ApolloFederationSourceSchemaClientConfiguration CreateConfiguration(
+ string schemaName,
+ JsonElement http,
+ JsonElement federation)
+ {
+ if (!http.TryGetProperty("url", out var urlProperty)
+ || urlProperty.ValueKind != JsonValueKind.String
+ || urlProperty.GetString() is not { Length: > 0 } url)
+ {
+ throw new InvalidOperationException(
+ $"Source schema '{schemaName}' has no 'transports.http.url' value.");
+ }
+
+ var clientName = schemaName;
+
+ if (http.TryGetProperty("clientName", out var clientNameProperty)
+ && clientNameProperty.ValueKind == JsonValueKind.String
+ && clientNameProperty.GetString() is { Length: > 0 } customClientName)
+ {
+ clientName = customClientName;
+ }
+
+ var lookups = ParseLookups(schemaName, federation);
+
+ return new ApolloFederationSourceSchemaClientConfiguration(
+ schemaName,
+ clientName,
+ new Uri(url),
+ lookups);
+ }
+
+ private static Dictionary ParseLookups(
+ string schemaName,
+ JsonElement federation)
+ {
+ if (!federation.TryGetProperty("lookups", out var lookupsElement)
+ || lookupsElement.ValueKind != JsonValueKind.Object)
+ {
+ return [];
+ }
+
+ var lookups = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var lookup in lookupsElement.EnumerateObject())
+ {
+ if (lookup.Value.ValueKind != JsonValueKind.Object)
+ {
+ throw new InvalidOperationException(
+ $"Source schema '{schemaName}' lookup '{lookup.Name}' must be a JSON object.");
+ }
+
+ if (!lookup.Value.TryGetProperty("entityType", out var entityTypeProperty)
+ || entityTypeProperty.ValueKind != JsonValueKind.String
+ || entityTypeProperty.GetString() is not { Length: > 0 } entityType)
+ {
+ throw new InvalidOperationException(
+ $"Source schema '{schemaName}' lookup '{lookup.Name}' is missing an 'entityType' string.");
+ }
+
+ var argumentToKeyFieldMap = ParseArguments(schemaName, lookup.Name, lookup.Value);
+
+ lookups[lookup.Name] = new LookupFieldInfo
+ {
+ EntityTypeName = entityType,
+ ArgumentToKeyFieldMap = argumentToKeyFieldMap
+ };
+ }
+
+ return lookups;
+ }
+
+ private static Dictionary ParseArguments(
+ string schemaName,
+ string lookupName,
+ JsonElement lookup)
+ {
+ if (!lookup.TryGetProperty("arguments", out var argumentsElement)
+ || argumentsElement.ValueKind != JsonValueKind.Object)
+ {
+ return [];
+ }
+
+ var arguments = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var argument in argumentsElement.EnumerateObject())
+ {
+ arguments[argument.Name] = ReadArgumentPath(schemaName, lookupName, argument);
+ }
+
+ return arguments;
+ }
+
+ private static string ReadArgumentPath(
+ string schemaName,
+ string lookupName,
+ JsonProperty argument)
+ {
+ switch (argument.Value.ValueKind)
+ {
+ case JsonValueKind.String:
+ // An empty-string path is the "splat" marker that tells the
+ // connector to spread the variable value's object fields into
+ // the representation root (used for wrapper-shape arguments on
+ // nested '@key' lookups). Any other string is a path segment.
+ return argument.Value.GetString() ?? string.Empty;
+
+ case JsonValueKind.Object:
+ if (argument.Value.TryGetProperty("path", out var pathProperty)
+ && pathProperty.ValueKind == JsonValueKind.String)
+ {
+ return pathProperty.GetString() ?? string.Empty;
+ }
+
+ break;
+ }
+
+ throw new InvalidOperationException(
+ $"Source schema '{schemaName}' lookup '{lookupName}' argument '{argument.Name}' "
+ + "must map to a string key field path or an object with a 'path' string property.");
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs
new file mode 100644
index 00000000000..f2bb00ef7a1
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs
@@ -0,0 +1,887 @@
+using System.Collections.Immutable;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+using HotChocolate.Buffers;
+using HotChocolate.Fusion.Text.Json;
+using HotChocolate.Fusion.Transport;
+using HotChocolate.Fusion.Transport.Http;
+using HotChocolate.Language;
+
+namespace HotChocolate.Fusion.Execution.Clients;
+
+///
+/// An implementation that translates Fusion's
+/// composite-schema-spec queries into Apollo Federation _entities queries
+/// and sends them to an Apollo subgraph over HTTP.
+///
+public sealed class ApolloFederationSourceSchemaClient : ISourceSchemaClient
+{
+ private static readonly Uri s_unknownUri = new("http://unknown");
+
+ private readonly GraphQLHttpClient _httpClient;
+ private readonly FederationQueryRewriter _queryRewriter;
+ private bool _disposed;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The underlying GraphQL HTTP client.
+ /// The query rewriter for this source schema.
+ internal ApolloFederationSourceSchemaClient(
+ GraphQLHttpClient httpClient,
+ FederationQueryRewriter queryRewriter)
+ {
+ ArgumentNullException.ThrowIfNull(httpClient);
+ ArgumentNullException.ThrowIfNull(queryRewriter);
+
+ _httpClient = httpClient;
+ _queryRewriter = queryRewriter;
+ }
+
+ ///
+ public SourceSchemaClientCapabilities Capabilities
+ => SourceSchemaClientCapabilities.VariableBatching
+ | SourceSchemaClientCapabilities.RequestBatching;
+
+ ///
+ public async ValueTask ExecuteAsync(
+ OperationPlanContext context,
+ SourceSchemaClientRequest request,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var rewritten = _queryRewriter.GetOrRewrite(
+ request.OperationSourceText,
+ request.OperationHash);
+
+ if (!rewritten.IsEntityLookup)
+ {
+ return await ExecutePassthroughAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ return await ExecuteEntityLookupAsync(request, rewritten, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ public async IAsyncEnumerable ExecuteBatchStreamAsync(
+ OperationPlanContext context,
+ ImmutableArray requests,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ // Single request: use the simple path (no aliasing needed).
+ if (requests.Length == 1)
+ {
+ var response = await ExecuteAsync(context, requests[0], cancellationToken)
+ .ConfigureAwait(false);
+
+ await foreach (var result in response.ReadAsResultStreamAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return new BatchStreamResult(0, result);
+ }
+
+ yield break;
+ }
+
+ // Rewrite each request and classify as entity lookup or passthrough.
+ var rewrittenOps = new RewrittenOperation[requests.Length];
+ var allEntityLookups = true;
+
+ for (var i = 0; i < requests.Length; i++)
+ {
+ var rewritten = _queryRewriter.GetOrRewrite(
+ requests[i].OperationSourceText,
+ requests[i].OperationHash);
+ rewrittenOps[i] = rewritten;
+
+ if (!rewritten.IsEntityLookup)
+ {
+ allEntityLookups = false;
+ }
+ }
+
+ // If any request is a passthrough, fall back to sequential execution
+ // for all requests. Batching passthrough queries with entity lookups
+ // requires variable namespace merging which is deferred for now.
+ if (!allEntityLookups)
+ {
+ for (var i = 0; i < requests.Length; i++)
+ {
+ var response = await ExecuteAsync(context, requests[i], cancellationToken)
+ .ConfigureAwait(false);
+
+ await foreach (var result in response.ReadAsResultStreamAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ yield return new BatchStreamResult(i, result);
+ }
+ }
+
+ yield break;
+ }
+
+ // All requests are entity lookups: build one combined aliased query.
+ await foreach (var batchResult in ExecuteBatchedEntityLookupsAsync(
+ requests, rewrittenOps, cancellationToken).ConfigureAwait(false))
+ {
+ yield return batchResult;
+ }
+ }
+
+ private async IAsyncEnumerable ExecuteBatchedEntityLookupsAsync(
+ ImmutableArray requests,
+ RewrittenOperation[] rewrittenOps,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ // 1. Build the combined query AST and variables JSON.
+ var (combinedQueryText, combinedVariablesJson) =
+ BuildCombinedEntityQuery(requests, rewrittenOps);
+
+ // 2. Build the variable segment for the HTTP request.
+ var variablesBytes = Encoding.UTF8.GetBytes(combinedVariablesJson);
+ var buffer = new ChunkedArrayWriter();
+ var span = buffer.GetSpan(variablesBytes.Length);
+ variablesBytes.CopyTo(span);
+ buffer.Advance(variablesBytes.Length);
+ var variableSegment = JsonSegment.Create(buffer, 0, variablesBytes.Length);
+ var variableValues = new VariableValues(CompactPath.Root, variableSegment);
+
+ // 3. Send the combined request.
+ var operationRequest = new OperationRequest(
+ combinedQueryText,
+ id: null,
+ operationName: null,
+ onError: null,
+ variables: variableValues,
+ extensions: JsonSegment.Empty);
+
+ var httpRequest = new GraphQLHttpRequest(operationRequest);
+ var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken)
+ .ConfigureAwait(false);
+
+ // 4. Parse the response and yield per-request results.
+ SourceResultDocument? sourceDocument = null;
+
+ try
+ {
+ sourceDocument = await httpResponse.ReadAsResultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!sourceDocument.Root.TryGetProperty("data"u8, out var dataElement)
+ || dataElement.ValueKind != JsonValueKind.Object)
+ {
+ // No data in response: yield the raw result for the first request.
+ var path = requests[0].Variables.IsDefaultOrEmpty
+ ? CompactPath.Root
+ : requests[0].Variables[0].Path;
+ yield return new BatchStreamResult(0, new SourceSchemaResult(path, sourceDocument));
+ sourceDocument = null; // ownership transferred
+ yield break;
+ }
+
+ for (var i = 0; i < requests.Length; i++)
+ {
+ var aliasName = $"____request{i}";
+ var aliasNameBytes = Encoding.UTF8.GetBytes(aliasName);
+ var lookupFieldName = rewrittenOps[i].LookupFieldName!;
+ var variables = requests[i].Variables;
+
+ if (!dataElement.TryGetProperty(aliasNameBytes, out var aliasElement)
+ || aliasElement.ValueKind != JsonValueKind.Array)
+ {
+ // Alias not found or not an array: yield an empty-data result.
+ var emptyJson = $"{{\"data\":{{\"{lookupFieldName}\":null}}}}";
+ var emptyBytes = Encoding.UTF8.GetBytes(emptyJson);
+ var emptyDoc = SourceResultDocument.Parse(emptyBytes, emptyBytes.Length);
+
+ var path = variables.IsDefaultOrEmpty
+ ? CompactPath.Root
+ : variables[0].Path;
+ yield return new BatchStreamResult(i, new SourceSchemaResult(path, emptyDoc));
+ continue;
+ }
+
+ var entityCount = aliasElement.GetArrayLength();
+
+ for (var j = 0; j < entityCount; j++)
+ {
+ var entity = aliasElement[j];
+ var entityJson = BuildWrappedEntityJson(lookupFieldName, entity);
+ var entityBytes = Encoding.UTF8.GetBytes(entityJson);
+ var entityDocument = SourceResultDocument.Parse(entityBytes, entityBytes.Length);
+
+ CompactPath resultPath;
+ CompactPathSegment additionalPaths;
+
+ if (variables.IsDefaultOrEmpty || j >= variables.Length)
+ {
+ resultPath = CompactPath.Root;
+ additionalPaths = default;
+ }
+ else
+ {
+ resultPath = variables[j].Path;
+ additionalPaths = variables[j].AdditionalPaths;
+ }
+
+ yield return additionalPaths.IsDefaultOrEmpty
+ ? new BatchStreamResult(i, new SourceSchemaResult(resultPath, entityDocument))
+ : new BatchStreamResult(i, new SourceSchemaResult(resultPath, entityDocument, additionalPaths: additionalPaths));
+ }
+ }
+ }
+ finally
+ {
+ sourceDocument?.Dispose();
+ httpResponse.Dispose();
+ buffer.Dispose();
+ }
+ }
+
+ ///
+ /// Builds a combined aliased _entities query and variables JSON from
+ /// multiple entity lookup requests. Each request gets a unique alias
+ /// (____request0, ____request1, ...) and a unique variable
+ /// name ($r0, $r1, ...).
+ ///
+ internal static (string QueryText, string VariablesJson) BuildCombinedEntityQuery(
+ ImmutableArray requests,
+ RewrittenOperation[] rewrittenOps)
+ {
+ var variableDefinitions = new List(requests.Length);
+ var fieldNodes = new List(requests.Length);
+
+ for (var i = 0; i < requests.Length; i++)
+ {
+ var rewritten = rewrittenOps[i];
+ var varName = $"r{i}";
+ var aliasName = $"____request{i}";
+
+ // Variable definition: $r{i}: [_Any!]!
+ variableDefinitions.Add(new VariableDefinitionNode(
+ location: null,
+ new VariableNode(varName),
+ description: null,
+ type: new NonNullTypeNode(
+ new ListTypeNode(
+ new NonNullTypeNode(
+ new NamedTypeNode("_Any")))),
+ defaultValue: null,
+ directives: []));
+
+ // Field: ____request{i}: _entities(representations: $r{i}) { ... on EntityType { ... } }
+ var inlineFragment = rewritten.InlineFragment
+ ?? new InlineFragmentNode(
+ location: null,
+ typeCondition: new NamedTypeNode(rewritten.EntityTypeName!),
+ directives: [],
+ selectionSet: new SelectionSetNode(Array.Empty()));
+
+ fieldNodes.Add(new FieldNode(
+ location: null,
+ new NameNode("_entities"),
+ alias: new NameNode(aliasName),
+ directives: [],
+ arguments: [new ArgumentNode("representations", new VariableNode(varName))],
+ selectionSet: new SelectionSetNode([inlineFragment])));
+ }
+
+ var operation = new OperationDefinitionNode(
+ location: null,
+ name: null,
+ description: null,
+ operation: OperationType.Query,
+ variableDefinitions: variableDefinitions,
+ directives: [],
+ selectionSet: new SelectionSetNode(fieldNodes));
+
+ var document = new DocumentNode([operation]);
+ var queryText = document.ToString(indented: true);
+
+ // Build the combined variables JSON.
+ var variablesJson = BuildCombinedVariablesJson(requests, rewrittenOps);
+
+ return (queryText, variablesJson);
+ }
+
+ ///
+ /// Builds the combined variables JSON object for a batched entity query.
+ /// Each request's representations are written under a r{i} key.
+ ///
+ private static string BuildCombinedVariablesJson(
+ ImmutableArray requests,
+ RewrittenOperation[] rewrittenOps)
+ {
+ using var stream = new MemoryStream();
+ using var writer = new Utf8JsonWriter(stream);
+
+ writer.WriteStartObject();
+
+ for (var i = 0; i < requests.Length; i++)
+ {
+ var varName = $"r{i}";
+ var rewritten = rewrittenOps[i];
+
+ writer.WritePropertyName(varName);
+
+ // Write the representations array for this request.
+ WriteRepresentationsArray(
+ writer,
+ requests[i].Variables,
+ rewritten.EntityTypeName!,
+ rewritten.VariableToKeyFieldMap);
+ }
+
+ writer.WriteEndObject();
+ writer.Flush();
+
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ ///
+ /// Writes a JSON array of entity representations for the given variable sets.
+ /// Extracted from to allow reuse
+ /// in the combined variables builder.
+ ///
+ /// Each lookup argument's mapped path either names a representation field
+ /// (flat or single-segment nested keys) or is empty. An empty path signals
+ /// a "spread" argument: the variable value is itself an object whose
+ /// fields are merged into the representation root. Apollo Federation v2
+ /// nested-path keys (e.g. @key(fields: "products { id }")) use the
+ /// spread form so the wrapper input's fields map to the entity's
+ /// top-level key fields 1-to-1.
+ ///
+ ///
+ private static void WriteRepresentationsArray(
+ Utf8JsonWriter writer,
+ ImmutableArray variableSets,
+ string entityTypeName,
+ IReadOnlyDictionary variableToKeyFieldMap)
+ {
+ writer.WriteStartArray();
+
+ if (variableSets.IsDefaultOrEmpty)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("__typename", entityTypeName);
+ writer.WriteEndObject();
+ }
+ else
+ {
+ for (var i = 0; i < variableSets.Length; i++)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("__typename", entityTypeName);
+
+ var values = variableSets[i].Values;
+
+ if (!values.IsEmpty)
+ {
+ var sequence = values.AsSequence();
+ var reader = new Utf8JsonReader(sequence);
+
+ if (reader.Read() && reader.TokenType == JsonTokenType.StartObject)
+ {
+ while (reader.Read() && reader.TokenType == JsonTokenType.PropertyName)
+ {
+ var propertyName = reader.GetString()!;
+ reader.Read();
+
+ if (!variableToKeyFieldMap.TryGetValue(propertyName, out var keyFieldName))
+ {
+ reader.Skip();
+ continue;
+ }
+
+ if (keyFieldName.Length == 0)
+ {
+ // Spread semantics: the variable value is an
+ // object shaped like the representation body
+ // itself. Copy each of its fields verbatim so
+ // that list and nested-object values flow
+ // through with their structure intact.
+ SpreadObjectValue(writer, ref reader);
+ }
+ else
+ {
+ writer.WritePropertyName(keyFieldName);
+ WriteCurrentValue(writer, ref reader);
+ }
+ }
+ }
+ }
+
+ writer.WriteEndObject();
+ }
+ }
+
+ writer.WriteEndArray();
+ }
+
+ ///
+ /// Writes every property of the current object value directly into
+ /// , preserving nested object and array structure.
+ /// When the reader does not sit on an object start token the value is
+ /// skipped; the representation is left unchanged for that argument.
+ ///
+ private static void SpreadObjectValue(Utf8JsonWriter writer, ref Utf8JsonReader reader)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ reader.Skip();
+ return;
+ }
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ return;
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ continue;
+ }
+
+ var propertyName = reader.GetString()!;
+ reader.Read();
+
+ writer.WritePropertyName(propertyName);
+ WriteCurrentValue(writer, ref reader);
+ }
+ }
+
+ ///
+ /// Builds a JSON wrapper for a single entity result:
+ /// {"data": {"<fieldName>": <entity>}}.
+ ///
+ private static string BuildWrappedEntityJson(string fieldName, SourceResultElement entity)
+ {
+ using var stream = new MemoryStream();
+ using var writer = new Utf8JsonWriter(stream);
+
+ writer.WriteStartObject();
+ writer.WritePropertyName("data");
+ writer.WriteStartObject();
+ writer.WritePropertyName(fieldName);
+ WriteSourceResultElement(writer, entity);
+ writer.WriteEndObject();
+ writer.WriteEndObject();
+
+ writer.Flush();
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ private static void WriteSourceResultElement(Utf8JsonWriter writer, SourceResultElement element)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ writer.WriteStartObject();
+ foreach (var property in element.EnumerateObject())
+ {
+ writer.WritePropertyName(property.Name);
+ WriteSourceResultElement(writer, property.Value);
+ }
+ writer.WriteEndObject();
+ break;
+
+ case JsonValueKind.Array:
+ writer.WriteStartArray();
+ foreach (var item in element.EnumerateArray())
+ {
+ WriteSourceResultElement(writer, item);
+ }
+ writer.WriteEndArray();
+ break;
+
+ case JsonValueKind.String:
+ writer.WriteStringValue(element.GetString());
+ break;
+
+ case JsonValueKind.Number:
+ writer.WriteRawValue(element.GetRawText());
+ break;
+
+ case JsonValueKind.True:
+ writer.WriteBooleanValue(true);
+ break;
+
+ case JsonValueKind.False:
+ writer.WriteBooleanValue(false);
+ break;
+
+ case JsonValueKind.Null:
+ case JsonValueKind.Undefined:
+ default:
+ writer.WriteNullValue();
+ break;
+ }
+ }
+
+ private async ValueTask ExecutePassthroughAsync(
+ SourceSchemaClientRequest request,
+ CancellationToken cancellationToken)
+ {
+ var operationRequest = new OperationRequest(
+ request.OperationSourceText,
+ id: null,
+ operationName: null,
+ onError: null,
+ variables: request.Variables.IsDefaultOrEmpty
+ ? VariableValues.Empty
+ : request.Variables[0],
+ extensions: JsonSegment.Empty);
+
+ var httpRequest = new GraphQLHttpRequest(operationRequest);
+ var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken)
+ .ConfigureAwait(false);
+
+ return new PassthroughResponse(
+ httpRequest.Uri ?? s_unknownUri,
+ request.Variables,
+ httpResponse);
+ }
+
+ private async ValueTask ExecuteEntityLookupAsync(
+ SourceSchemaClientRequest request,
+ RewrittenOperation rewritten,
+ CancellationToken cancellationToken)
+ {
+ // Build the representations JSON and send as a single _entities query.
+ var representationsJson = BuildRepresentationsJson(
+ request.Variables,
+ rewritten.EntityTypeName!,
+ rewritten.VariableToKeyFieldMap);
+
+ // Build the variable JSON: {"representations": [...]}
+ var variablesJson = $"{{\"representations\":{representationsJson}}}";
+ var variablesBytes = Encoding.UTF8.GetBytes(variablesJson);
+
+ var buffer = new ChunkedArrayWriter();
+ var span = buffer.GetSpan(variablesBytes.Length);
+ variablesBytes.CopyTo(span);
+ buffer.Advance(variablesBytes.Length);
+ var variableSegment = JsonSegment.Create(buffer, 0, variablesBytes.Length);
+
+ var variableValues = new VariableValues(CompactPath.Root, variableSegment);
+
+ var operationRequest = new OperationRequest(
+ rewritten.OperationText,
+ id: null,
+ operationName: null,
+ onError: null,
+ variables: variableValues,
+ extensions: JsonSegment.Empty);
+
+ var httpRequest = new GraphQLHttpRequest(operationRequest);
+ var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken)
+ .ConfigureAwait(false);
+
+ return new EntityLookupResponse(
+ httpRequest.Uri ?? s_unknownUri,
+ request.Variables,
+ rewritten.LookupFieldName!,
+ httpResponse,
+ buffer);
+ }
+
+ ///
+ /// Builds the JSON array of representations for the _entities query.
+ /// Each representation is: {"__typename": "Product", "id": <value>, ...}
+ ///
+ private static string BuildRepresentationsJson(
+ ImmutableArray variableSets,
+ string entityTypeName,
+ IReadOnlyDictionary variableToKeyFieldMap)
+ {
+ using var stream = new MemoryStream();
+ using var writer = new Utf8JsonWriter(stream);
+
+ WriteRepresentationsArray(writer, variableSets, entityTypeName, variableToKeyFieldMap);
+
+ writer.Flush();
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ private static void WriteCurrentValue(Utf8JsonWriter writer, ref Utf8JsonReader reader)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.String:
+ writer.WriteStringValue(reader.GetString());
+ break;
+
+ case JsonTokenType.Number:
+ if (reader.TryGetInt64(out var longValue))
+ {
+ writer.WriteNumberValue(longValue);
+ }
+ else
+ {
+ writer.WriteNumberValue(reader.GetDouble());
+ }
+ break;
+
+ case JsonTokenType.True:
+ writer.WriteBooleanValue(true);
+ break;
+
+ case JsonTokenType.False:
+ writer.WriteBooleanValue(false);
+ break;
+
+ case JsonTokenType.Null:
+ writer.WriteNullValue();
+ break;
+
+ case JsonTokenType.StartObject:
+ case JsonTokenType.StartArray:
+ // Write complex values using the built-in copy mechanism.
+ WriteComplexValue(writer, ref reader);
+ break;
+ }
+ }
+
+ private static void WriteComplexValue(Utf8JsonWriter writer, ref Utf8JsonReader reader)
+ {
+ var depth = reader.CurrentDepth;
+ var isArray = reader.TokenType == JsonTokenType.StartArray;
+
+ if (isArray)
+ {
+ writer.WriteStartArray();
+ }
+ else
+ {
+ writer.WriteStartObject();
+ }
+
+ while (reader.Read())
+ {
+ if (reader.CurrentDepth == depth)
+ {
+ if (isArray)
+ {
+ writer.WriteEndArray();
+ }
+ else
+ {
+ writer.WriteEndObject();
+ }
+ return;
+ }
+
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.PropertyName:
+ writer.WritePropertyName(reader.GetString()!);
+ break;
+
+ case JsonTokenType.String:
+ writer.WriteStringValue(reader.GetString());
+ break;
+
+ case JsonTokenType.Number:
+ if (reader.TryGetInt64(out var l))
+ {
+ writer.WriteNumberValue(l);
+ }
+ else
+ {
+ writer.WriteNumberValue(reader.GetDouble());
+ }
+ break;
+
+ case JsonTokenType.True:
+ writer.WriteBooleanValue(true);
+ break;
+
+ case JsonTokenType.False:
+ writer.WriteBooleanValue(false);
+ break;
+
+ case JsonTokenType.Null:
+ writer.WriteNullValue();
+ break;
+
+ case JsonTokenType.StartObject:
+ writer.WriteStartObject();
+ break;
+
+ case JsonTokenType.StartArray:
+ writer.WriteStartArray();
+ break;
+
+ case JsonTokenType.EndObject:
+ writer.WriteEndObject();
+ break;
+
+ case JsonTokenType.EndArray:
+ writer.WriteEndArray();
+ break;
+ }
+ }
+ }
+
+ ///
+ public ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ _httpClient.Dispose();
+ _disposed = true;
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ /// Response for passthrough (non-entity-lookup) queries.
+ /// Delegates directly to the underlying HTTP response.
+ ///
+ private sealed class PassthroughResponse(
+ Uri uri,
+ ImmutableArray variables,
+ GraphQLHttpResponse response)
+ : SourceSchemaClientResponse
+ {
+ public override Uri Uri => uri;
+
+ public override string ContentType => response.RawContentType ?? "unknown";
+
+ public override bool IsSuccessful => response.IsSuccessStatusCode;
+
+ public override async IAsyncEnumerable ReadAsResultStreamAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var result = await response.ReadAsResultAsync(cancellationToken).ConfigureAwait(false);
+
+ if (variables.IsDefaultOrEmpty || variables.Length <= 1)
+ {
+ var path = variables.IsDefaultOrEmpty
+ ? CompactPath.Root
+ : variables[0].Path;
+ var additionalPaths = variables.IsDefaultOrEmpty
+ ? default
+ : variables[0].AdditionalPaths;
+
+ yield return additionalPaths.IsDefaultOrEmpty
+ ? new SourceSchemaResult(path, result)
+ : new SourceSchemaResult(path, result, additionalPaths: additionalPaths);
+ }
+ else
+ {
+ for (var i = 0; i < variables.Length; i++)
+ {
+ var variable = variables[i];
+ yield return variable.AdditionalPaths.IsDefaultOrEmpty
+ ? new SourceSchemaResult(variable.Path, result)
+ : new SourceSchemaResult(variable.Path, result, additionalPaths: variable.AdditionalPaths);
+ }
+ }
+ }
+
+ public override void Dispose() => response.Dispose();
+ }
+
+ ///
+ /// Response for entity lookup queries. Reads the _entities array from the
+ /// subgraph response and yields one per entity,
+ /// wrapping each entity as if it were the direct result of the lookup field.
+ ///
+ private sealed class EntityLookupResponse(
+ Uri uri,
+ ImmutableArray variables,
+ string lookupFieldName,
+ GraphQLHttpResponse response,
+ ChunkedArrayWriter? buffer)
+ : SourceSchemaClientResponse
+ {
+ public override Uri Uri => uri;
+
+ public override string ContentType => response.RawContentType ?? "unknown";
+
+ public override bool IsSuccessful => response.IsSuccessStatusCode;
+
+ public override async IAsyncEnumerable ReadAsResultStreamAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var sourceDocument = await response.ReadAsResultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ // The subgraph response looks like:
+ // {"data": {"_entities": [{"id":"1","name":"Widget"}, ...]}}
+ //
+ // We need to yield per-entity results that look like:
+ // {"data": {"productById": {"id":"1","name":"Widget"}}}
+ //
+ // For each entity in the _entities array, we build a wrapper document.
+
+ if (!sourceDocument.Root.TryGetProperty("data"u8, out var dataElement)
+ || dataElement.ValueKind != JsonValueKind.Object)
+ {
+ // If there's no data or an error, yield the raw result.
+ var path = variables.IsDefaultOrEmpty ? CompactPath.Root : variables[0].Path;
+ yield return new SourceSchemaResult(path, sourceDocument);
+ yield break;
+ }
+
+ if (!dataElement.TryGetProperty("_entities"u8, out var entitiesElement)
+ || entitiesElement.ValueKind != JsonValueKind.Array)
+ {
+ // No _entities array, yield raw result.
+ var path = variables.IsDefaultOrEmpty ? CompactPath.Root : variables[0].Path;
+ yield return new SourceSchemaResult(path, sourceDocument);
+ yield break;
+ }
+
+ var entityCount = entitiesElement.GetArrayLength();
+
+ for (var i = 0; i < entityCount; i++)
+ {
+ var entity = entitiesElement[i];
+
+ // Build a wrapper: {"data": {"": }}
+ var entityJson = BuildWrappedEntityJson(lookupFieldName, entity);
+ var entityBytes = Encoding.UTF8.GetBytes(entityJson);
+ var entityDocument = SourceResultDocument.Parse(entityBytes, entityBytes.Length);
+
+ CompactPath resultPath;
+ CompactPathSegment additionalPaths;
+
+ if (variables.IsDefaultOrEmpty || i >= variables.Length)
+ {
+ resultPath = CompactPath.Root;
+ additionalPaths = default;
+ }
+ else
+ {
+ resultPath = variables[i].Path;
+ additionalPaths = variables[i].AdditionalPaths;
+ }
+
+ yield return additionalPaths.IsDefaultOrEmpty
+ ? new SourceSchemaResult(resultPath, entityDocument)
+ : new SourceSchemaResult(resultPath, entityDocument, additionalPaths: additionalPaths);
+ }
+
+ sourceDocument.Dispose();
+ }
+
+ public override void Dispose()
+ {
+ response.Dispose();
+ buffer?.Dispose();
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs
new file mode 100644
index 00000000000..c9f915e9a90
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs
@@ -0,0 +1,65 @@
+namespace HotChocolate.Fusion.Execution.Clients;
+
+///
+/// Configuration for an Apollo Federation subgraph source schema client
+/// that sends queries over HTTP using the _entities protocol.
+///
+public sealed class ApolloFederationSourceSchemaClientConfiguration : ISourceSchemaClientConfiguration
+{
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The name of the source schema.
+ ///
+ /// The name of the to resolve from
+ /// .
+ ///
+ ///
+ /// The base address of the Apollo Federation subgraph endpoint.
+ ///
+ ///
+ /// The lookup field metadata used to rewrite Fusion planner queries into
+ /// Apollo Federation _entities queries.
+ ///
+ /// The supported operation types.
+ internal ApolloFederationSourceSchemaClientConfiguration(
+ string name,
+ string httpClientName,
+ Uri baseAddress,
+ IReadOnlyDictionary lookups,
+ SupportedOperationType supportedOperations = SupportedOperationType.Query | SupportedOperationType.Mutation)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentException.ThrowIfNullOrEmpty(httpClientName);
+ ArgumentNullException.ThrowIfNull(baseAddress);
+ ArgumentNullException.ThrowIfNull(lookups);
+
+ Name = name;
+ HttpClientName = httpClientName;
+ BaseAddress = baseAddress;
+ Lookups = lookups;
+ SupportedOperations = supportedOperations;
+ }
+
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the name of the underlying HTTP client.
+ ///
+ public string HttpClientName { get; }
+
+ ///
+ /// Gets the base address of the Apollo Federation subgraph endpoint.
+ ///
+ public Uri BaseAddress { get; }
+
+ ///
+ /// Gets the lookup field metadata used to rewrite Fusion planner queries
+ /// into Apollo Federation _entities queries.
+ ///
+ internal IReadOnlyDictionary Lookups { get; }
+
+ ///
+ public SupportedOperationType SupportedOperations { get; }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientFactory.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientFactory.cs
new file mode 100644
index 00000000000..0a1d6465277
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientFactory.cs
@@ -0,0 +1,42 @@
+using System.Collections.Concurrent;
+using HotChocolate.Fusion.Transport.Http;
+
+namespace HotChocolate.Fusion.Execution.Clients;
+
+///
+/// A factory that creates instances
+/// for source schemas configured with .
+///
+public sealed class ApolloFederationSourceSchemaClientFactory
+ : SourceSchemaClientFactory
+{
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ConcurrentDictionary _rewritersBySchema = new();
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The HTTP client factory.
+ public ApolloFederationSourceSchemaClientFactory(IHttpClientFactory httpClientFactory)
+ {
+ ArgumentNullException.ThrowIfNull(httpClientFactory);
+
+ _httpClientFactory = httpClientFactory;
+ }
+
+ ///
+ protected override ISourceSchemaClient CreateClient(
+ ApolloFederationSourceSchemaClientConfiguration configuration)
+ {
+ var httpClient = _httpClientFactory.CreateClient(configuration.HttpClientName);
+ httpClient.BaseAddress = configuration.BaseAddress;
+
+ var queryRewriter = _rewritersBySchema.GetOrAdd(
+ configuration.Name,
+ static (_, config) => new FederationQueryRewriter(config.Lookups),
+ configuration);
+
+ var graphQLClient = GraphQLHttpClient.Create(httpClient, disposeHttpClient: true);
+ return new ApolloFederationSourceSchemaClient(graphQLClient, queryRewriter);
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/DependencyInjection/ApolloFederationFusionGatewayBuilderExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/DependencyInjection/ApolloFederationFusionGatewayBuilderExtensions.cs
new file mode 100644
index 00000000000..4eff3b95ca9
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/DependencyInjection/ApolloFederationFusionGatewayBuilderExtensions.cs
@@ -0,0 +1,41 @@
+using HotChocolate.Fusion.Configuration;
+using HotChocolate.Fusion.Connectors.ApolloFederation;
+using HotChocolate.Fusion.Execution.Clients;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// Extension methods for registering the Apollo Federation connector on
+/// .
+///
+public static class ApolloFederationFusionGatewayBuilderExtensions
+{
+ ///
+ /// Adds Apollo Federation support to the Fusion gateway. Source schemas whose
+ /// settings carry an extensions.apolloFederation block will be served by
+ /// the Apollo Federation _entities connector.
+ ///
+ /// The Fusion gateway builder.
+ /// The Fusion gateway builder for chaining.
+ public static IFusionGatewayBuilder AddApolloFederationSupport(
+ this IFusionGatewayBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.Services.TryAddEnumerable(
+ ServiceDescriptor.Singleton());
+
+ return FusionSetupUtilities.Configure(
+ builder,
+ static setup =>
+ {
+ if (!setup.SourceSchemaClientConfigurationParsers.Any(
+ static p => p is ApolloFederationClientConfigurationParser))
+ {
+ setup.SourceSchemaClientConfigurationParsers.Add(
+ new ApolloFederationClientConfigurationParser());
+ }
+ });
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs
new file mode 100644
index 00000000000..79074b4b1a6
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs
@@ -0,0 +1,163 @@
+using System.Collections.Concurrent;
+using HotChocolate.Language;
+
+namespace HotChocolate.Fusion.Execution.Clients;
+
+///
+/// Rewrites Fusion planner queries into Apollo Federation _entities queries.
+///
+/// The Fusion planner emits queries against lookup fields (e.g. productById(id: $__fusion_1_id)).
+/// This rewriter detects those lookup fields, extracts the variable-to-key-field mapping,
+/// and produces an _entities(representations: $representations) query with the
+/// appropriate inline fragment.
+///
+///
+/// Non-lookup fields are passed through unchanged.
+///
+///
+internal sealed class FederationQueryRewriter
+{
+ private readonly ConcurrentDictionary _cache = new();
+ private readonly IReadOnlyDictionary _lookupFields;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// A dictionary mapping query field names (e.g. "productById") to their
+ /// describing the entity type and key argument mappings.
+ ///
+ public FederationQueryRewriter(IReadOnlyDictionary lookupFields)
+ {
+ ArgumentNullException.ThrowIfNull(lookupFields);
+ _lookupFields = lookupFields;
+ }
+
+ ///
+ /// Returns a cached rewritten operation for the given hash, or rewrites the
+ /// operation source text and caches the result.
+ ///
+ /// The GraphQL operation text from the Fusion planner.
+ /// A precomputed hash used as the cache key.
+ /// The rewritten operation.
+ public RewrittenOperation GetOrRewrite(string operationSourceText, ulong operationHash)
+ {
+ return _cache.GetOrAdd(operationHash, _ => Rewrite(operationSourceText));
+ }
+
+ private RewrittenOperation Rewrite(string operationSourceText)
+ {
+ var document = Utf8GraphQLParser.Parse(operationSourceText);
+
+ var operationDefinition = GetOperationDefinition(document);
+ var selections = operationDefinition.SelectionSet.Selections;
+
+ // Check if the first top-level field is a lookup field.
+ if (selections.Count > 0
+ && selections[0] is FieldNode lookupField
+ && _lookupFields.TryGetValue(lookupField.Name.Value, out var lookupInfo))
+ {
+ return RewriteEntityLookup(lookupField, lookupInfo);
+ }
+
+ // Not an entity lookup, pass through unchanged.
+ return new RewrittenOperation
+ {
+ OperationText = operationSourceText,
+ IsEntityLookup = false,
+ EntityTypeName = null,
+ VariableToKeyFieldMap = new Dictionary(),
+ LookupFieldName = null
+ };
+ }
+
+ private static RewrittenOperation RewriteEntityLookup(
+ FieldNode lookupField,
+ LookupFieldInfo lookupInfo)
+ {
+ // 1. Build the variable-to-key-field mapping by inspecting the lookup field's arguments.
+ // The planner passes arguments like: productById(id: $__fusion_1_id)
+ // We map variable name "__fusion_1_id" → key field "id".
+ var variableToKeyFieldMap = new Dictionary();
+
+ foreach (var argument in lookupField.Arguments)
+ {
+ if (argument.Value is VariableNode variable
+ && lookupInfo.ArgumentToKeyFieldMap.TryGetValue(argument.Name.Value, out var keyFieldName))
+ {
+ variableToKeyFieldMap[variable.Name.Value] = keyFieldName;
+ }
+ }
+
+ // 2. Build the _entities query AST.
+ // query($representations: [_Any!]!) {
+ // _entities(representations: $representations) {
+ // ... on EntityType { }
+ // }
+ // }
+
+ // The $representations variable definition: $representations: [_Any!]!
+ var representationsVarDef = new VariableDefinitionNode(
+ location: null,
+ new VariableNode("representations"),
+ description: null,
+ type: new NonNullTypeNode(
+ new ListTypeNode(
+ new NonNullTypeNode(
+ new NamedTypeNode("_Any")))),
+ defaultValue: null,
+ directives: []);
+
+ // The inline fragment: ... on Product { id name price }
+ var inlineFragment = new InlineFragmentNode(
+ location: null,
+ typeCondition: new NamedTypeNode(lookupInfo.EntityTypeName),
+ directives: [],
+ selectionSet: lookupField.SelectionSet
+ ?? new SelectionSetNode(Array.Empty()));
+
+ // The _entities field: _entities(representations: $representations) { ... on Product { ... } }
+ var entitiesField = new FieldNode(
+ location: null,
+ new NameNode("_entities"),
+ alias: null,
+ directives: [],
+ arguments: [new ArgumentNode("representations", new VariableNode("representations"))],
+ selectionSet: new SelectionSetNode([inlineFragment]));
+
+ // The operation: query($representations: [_Any!]!) { _entities(...) { ... } }
+ var rewrittenOperation = new OperationDefinitionNode(
+ location: null,
+ name: null,
+ description: null,
+ operation: OperationType.Query,
+ variableDefinitions: [representationsVarDef],
+ directives: [],
+ selectionSet: new SelectionSetNode([entitiesField]));
+
+ var rewrittenDocument = new DocumentNode([rewrittenOperation]);
+
+ return new RewrittenOperation
+ {
+ OperationText = rewrittenDocument.ToString(indented: true),
+ IsEntityLookup = true,
+ EntityTypeName = lookupInfo.EntityTypeName,
+ VariableToKeyFieldMap = variableToKeyFieldMap,
+ LookupFieldName = lookupField.Name.Value,
+ InlineFragment = inlineFragment
+ };
+ }
+
+ private static OperationDefinitionNode GetOperationDefinition(DocumentNode document)
+ {
+ for (var i = 0; i < document.Definitions.Count; i++)
+ {
+ if (document.Definitions[i] is OperationDefinitionNode operation)
+ {
+ return operation;
+ }
+ }
+
+ throw new InvalidOperationException("The document does not contain an operation definition.");
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/HotChocolate.Fusion.Connectors.ApolloFederation.csproj b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/HotChocolate.Fusion.Connectors.ApolloFederation.csproj
new file mode 100644
index 00000000000..b74701e94e7
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/HotChocolate.Fusion.Connectors.ApolloFederation.csproj
@@ -0,0 +1,22 @@
+
+
+
+ HotChocolate.Fusion.Connectors.ApolloFederation
+ HotChocolate.Fusion
+ preview
+ $(DefineConstants);FUSION
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs
new file mode 100644
index 00000000000..b3a2783457e
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs
@@ -0,0 +1,19 @@
+namespace HotChocolate.Fusion.Execution.Clients;
+
+///
+/// Describes a single lookup field on the composite schema that maps
+/// to an Apollo Federation entity type. Used by
+/// to detect entity lookups and rewrite them into _entities queries.
+///
+internal sealed class LookupFieldInfo
+{
+ ///
+ /// Gets the entity type name this lookup resolves (e.g. "Product").
+ ///
+ public required string EntityTypeName { get; init; }
+
+ ///
+ /// Maps argument name (e.g. "id") to entity key field name (e.g. "id").
+ ///
+ public required IReadOnlyDictionary ArgumentToKeyFieldMap { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs
new file mode 100644
index 00000000000..d9fefc84034
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs
@@ -0,0 +1,52 @@
+using HotChocolate.Language;
+
+namespace HotChocolate.Fusion.Execution.Clients;
+
+///
+/// Represents a Fusion planner query that has been rewritten for an
+/// Apollo Federation subgraph. The rewritten text may contain an
+/// _entities query (for entity lookups) or the original query
+/// text unchanged (for passthrough fields).
+///
+internal sealed class RewrittenOperation
+{
+ ///
+ /// Gets the rewritten GraphQL query string. For entity lookups this
+ /// contains the _entities(representations: $representations) query;
+ /// for passthrough queries this is the original operation text.
+ ///
+ public required string OperationText { get; init; }
+
+ ///
+ /// Gets whether this operation is an entity lookup (true) or
+ /// a passthrough query (false).
+ ///
+ public required bool IsEntityLookup { get; init; }
+
+ ///
+ /// Gets the entity type name for the __typename value in
+ /// representations (e.g. "Product"). null for passthrough queries.
+ ///
+ public required string? EntityTypeName { get; init; }
+
+ ///
+ /// Maps variable names from the planner query (e.g. "__fusion_1_id")
+ /// to entity key field names (e.g. "id"). Empty for passthrough queries.
+ ///
+ public required IReadOnlyDictionary VariableToKeyFieldMap { get; init; }
+
+ ///
+ /// Gets the name of the lookup field in the original planner query
+ /// (e.g. "productById"). Used to wrap individual entity results
+ /// back into the shape the Fusion execution pipeline expects.
+ /// null for passthrough queries.
+ ///
+ public required string? LookupFieldName { get; init; }
+
+ ///
+ /// Gets the inline fragment for this entity type
+ /// (e.g. ... on Product { id name }). Used when building batched
+ /// aliased queries. null for passthrough queries.
+ ///
+ public InlineFragmentNode? InlineFragment { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/FusionGatewaySetup.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/FusionGatewaySetup.cs
index 76ef525618b..f955e779fde 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/FusionGatewaySetup.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/FusionGatewaySetup.cs
@@ -29,4 +29,6 @@ public sealed class FusionGatewaySetup
public List> DocumentValidatorBuilderModifiers { get; } = [];
public List> ClientConfigurationModifiers { get; } = [];
+
+ public List SourceSchemaClientConfigurationParsers { get; } = [];
}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/ISourceSchemaClientConfigurationParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/ISourceSchemaClientConfigurationParser.cs
new file mode 100644
index 00000000000..e57e1c0215a
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/ISourceSchemaClientConfigurationParser.cs
@@ -0,0 +1,37 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using HotChocolate.Fusion.Execution.Clients;
+
+namespace HotChocolate.Fusion.Configuration;
+
+///
+/// Parses a source-schema settings element into an .
+///
+public interface ISourceSchemaClientConfigurationParser
+{
+ ///
+ /// Attempts to build a configuration for the specified source schema and transport.
+ ///
+ ///
+ /// The source-schema JSON property. sourceSchema.Name is the schema name;
+ /// sourceSchema.Value is the per-schema settings object (including transports
+ /// and optional extensions).
+ ///
+ ///
+ /// The specific transport JSON property currently being offered. transport.Name is the
+ /// transport kind ("http", "websockets", ...) and transport.Value is the
+ /// transport element.
+ ///
+ ///
+ /// When this method returns , contains the parsed
+ /// ; otherwise, .
+ ///
+ ///
+ /// if the parser claimed the transport and produced a configuration;
+ /// otherwise, .
+ ///
+ bool TryParse(
+ JsonProperty sourceSchema,
+ JsonProperty transport,
+ [NotNullWhen(true)] out ISourceSchemaClientConfiguration? configuration);
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/Parsers/HttpSourceSchemaClientConfigurationParser.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/Parsers/HttpSourceSchemaClientConfigurationParser.cs
new file mode 100644
index 00000000000..6834861d732
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Configuration/Parsers/HttpSourceSchemaClientConfigurationParser.cs
@@ -0,0 +1,125 @@
+using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using HotChocolate.Fusion.Execution.Clients;
+
+namespace HotChocolate.Fusion.Configuration.Parsers;
+
+///
+/// Built-in parser that claims the http transport and produces a
+/// .
+///
+internal sealed class HttpSourceSchemaClientConfigurationParser : ISourceSchemaClientConfigurationParser
+{
+ public bool TryParse(
+ JsonProperty sourceSchema,
+ JsonProperty transport,
+ [NotNullWhen(true)] out ISourceSchemaClientConfiguration? configuration)
+ {
+ if (transport.Name.Equals("http", StringComparison.OrdinalIgnoreCase))
+ {
+ configuration = CreateHttpClientConfiguration(sourceSchema.Name, transport.Value);
+ return true;
+ }
+
+ configuration = null;
+ return false;
+ }
+
+ private static SourceSchemaHttpClientConfiguration CreateHttpClientConfiguration(
+ string schemaName,
+ JsonElement http)
+ {
+ var clientName = SourceSchemaHttpClientConfiguration.DefaultClientName;
+ var capabilities = SourceSchemaClientCapabilities.All;
+ var supportedOperations = SupportedOperationType.All;
+ ImmutableArray? defaultAcceptHeaderValues = null;
+ ImmutableArray? batchingAcceptHeaderValues = null;
+ ImmutableArray? subscriptionAcceptHeaderValues = null;
+
+ if (http.TryGetProperty("clientName", out var clientNameProperty)
+ && clientNameProperty.ValueKind is JsonValueKind.String
+ && clientNameProperty.GetString() is { } customClientName
+ && !string.IsNullOrEmpty(customClientName))
+ {
+ clientName = customClientName;
+ }
+
+ if (http.TryGetProperty("capabilities", out var capabilitiesElement))
+ {
+ if (capabilitiesElement.TryGetProperty("standard", out var standard))
+ {
+ if (standard.TryGetProperty("formats", out var formats))
+ {
+ var builder = ImmutableArray.CreateBuilder();
+
+ foreach (var format in formats.EnumerateArray())
+ {
+ builder.Add(MediaTypeWithQualityHeaderValue.Parse(format.GetString()!));
+ }
+
+ defaultAcceptHeaderValues = builder.ToImmutable();
+ }
+ }
+
+ if (capabilitiesElement.TryGetProperty("batching", out var batchingElement))
+ {
+ if (batchingElement.TryGetProperty("variableBatching", out var supported)
+ && !supported.GetBoolean())
+ {
+ capabilities &= ~SourceSchemaClientCapabilities.VariableBatching;
+ }
+
+ if (batchingElement.TryGetProperty("requestBatching", out supported)
+ && !supported.GetBoolean())
+ {
+ capabilities &= ~SourceSchemaClientCapabilities.RequestBatching;
+ }
+
+ if (batchingElement.TryGetProperty("formats", out var formats))
+ {
+ var builder = ImmutableArray.CreateBuilder();
+
+ foreach (var format in formats.EnumerateArray())
+ {
+ builder.Add(MediaTypeWithQualityHeaderValue.Parse(format.GetString()!));
+ }
+
+ batchingAcceptHeaderValues = builder.ToImmutable();
+ }
+ }
+
+ if (capabilitiesElement.TryGetProperty("subscriptions", out var subscriptionsElement))
+ {
+ if (subscriptionsElement.TryGetProperty("supported", out var supported)
+ && !supported.GetBoolean())
+ {
+ supportedOperations &= ~SupportedOperationType.Subscription;
+ }
+
+ if (subscriptionsElement.TryGetProperty("formats", out var formats))
+ {
+ var builder = ImmutableArray.CreateBuilder();
+
+ foreach (var format in formats.EnumerateArray())
+ {
+ builder.Add(MediaTypeWithQualityHeaderValue.Parse(format.GetString()!));
+ }
+
+ subscriptionAcceptHeaderValues = builder.ToImmutable();
+ }
+ }
+ }
+
+ return new SourceSchemaHttpClientConfiguration(
+ name: schemaName,
+ httpClientName: clientName,
+ baseAddress: new Uri(http.GetProperty("url").GetString()!),
+ supportedOperations: supportedOperations,
+ capabilities: capabilities,
+ defaultAcceptHeaderValues: defaultAcceptHeaderValues,
+ batchingAcceptHeaderValues: batchingAcceptHeaderValues,
+ subscriptionAcceptHeaderValues: subscriptionAcceptHeaderValues);
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaClientRequest.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaClientRequest.cs
index b548d6d1dca..d377959b2a1 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaClientRequest.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaClientRequest.cs
@@ -29,6 +29,12 @@ public readonly record struct SourceSchemaClientRequest()
///
public required string OperationSourceText { get; init; }
+ ///
+ /// Gets the xxhash64 of the operation source text.
+ /// Precomputed during planning for use as a cache key by connectors.
+ ///
+ public required ulong OperationHash { get; init; }
+
///
/// Gets the variable value sets for this operation. Multiple entries indicate
/// that the operation should be executed once per variable set (variable batching).
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/StringExtensions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/StringExtensions.cs
new file mode 100644
index 00000000000..e1d445e286a
--- /dev/null
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Extensions/StringExtensions.cs
@@ -0,0 +1,35 @@
+using System.Buffers;
+using System.IO.Hashing;
+using System.Text;
+
+namespace HotChocolate.Fusion.Execution;
+
+internal static class FusionStringExtensions
+{
+ private static readonly Encoding s_utf8 = Encoding.UTF8;
+ private static readonly ArrayPool s_arrayPool = ArrayPool.Shared;
+
+ public static ulong ComputeHash(this string s)
+ {
+ var maxByteCount = s_utf8.GetMaxByteCount(s.Length);
+ byte[]? rentedBuffer = null;
+ var buffer = maxByteCount < 256
+ ? stackalloc byte[256]
+ : s_arrayPool.Rent(maxByteCount);
+
+ try
+ {
+ var byteCount = s_utf8.GetBytes(s, buffer);
+ buffer = buffer[..byteCount];
+ return XxHash64.HashToUInt64(buffer);
+ }
+ finally
+ {
+ if (rentedBuffer is not null)
+ {
+ buffer.Clear();
+ s_arrayPool.Return(rentedBuffer);
+ }
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs
index 1614b1f1ebe..1a6870ca865 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs
@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
+using System.Diagnostics.CodeAnalysis;
using System.IO.Hashing;
-using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
@@ -12,6 +12,7 @@
using HotChocolate.Execution.Instrumentation;
using HotChocolate.Features;
using HotChocolate.Fusion.Configuration;
+using HotChocolate.Fusion.Configuration.Parsers;
using HotChocolate.Fusion.Diagnostics;
using HotChocolate.Fusion.Execution.Clients;
using HotChocolate.Fusion.Execution.Introspection;
@@ -40,6 +41,8 @@ internal sealed class FusionRequestExecutorManager
private readonly IOptionsMonitor _optionsMonitor;
private readonly EventObservable _events = new();
private readonly IServiceProvider _applicationServices;
+ private readonly ImmutableArray _builtInParsers =
+ [new HttpSourceSchemaClientConfigurationParser()];
private bool _disposed;
private ulong _version;
@@ -310,13 +313,27 @@ private SourceSchemaClientConfigurations CreateClientConfigurations(
{
foreach (var sourceSchema in sourceSchemas.EnumerateObject())
{
- if (sourceSchema.Value.TryGetProperty("transports", out var transports))
+ if (!sourceSchema.Value.TryGetProperty("transports", out var transports))
{
- if (transports.TryGetProperty("http", out var http))
+ throw new InvalidOperationException(
+ $"Source schema '{sourceSchema.Name}' has no 'transports' section.");
+ }
+
+ var anyConfig = false;
+ foreach (var transport in transports.EnumerateObject())
+ {
+ if (TryParseTransport(sourceSchema, transport, setup, out var config))
{
- configurations.Add(CreateHttpClientConfiguration(sourceSchema.Name, http));
+ configurations.Add(config);
+ anyConfig = true;
}
}
+
+ if (!anyConfig)
+ {
+ throw new InvalidOperationException(
+ $"No parser claimed any transport for source schema '{sourceSchema.Name}'.");
+ }
}
}
@@ -328,100 +345,30 @@ private SourceSchemaClientConfigurations CreateClientConfigurations(
return new SourceSchemaClientConfigurations(configurations);
}
- private static SourceSchemaHttpClientConfiguration CreateHttpClientConfiguration(
- string schemaName,
- JsonElement http)
+ private bool TryParseTransport(
+ JsonProperty sourceSchema,
+ JsonProperty transport,
+ FusionGatewaySetup setup,
+ [NotNullWhen(true)] out ISourceSchemaClientConfiguration? configuration)
{
- var clientName = SourceSchemaHttpClientConfiguration.DefaultClientName;
- var capabilities = SourceSchemaClientCapabilities.All;
- var supportedOperations = SupportedOperationType.All;
- ImmutableArray? defaultAcceptHeaderValues = null;
- ImmutableArray? batchingAcceptHeaderValues = null;
- ImmutableArray? subscriptionAcceptHeaderValues = null;
-
- if (http.TryGetProperty("clientName", out var clientNameProperty)
- && clientNameProperty.ValueKind is JsonValueKind.String
- && clientNameProperty.GetString() is { } customClientName
- && !string.IsNullOrEmpty(customClientName))
- {
- clientName = customClientName;
- }
-
- if (http.TryGetProperty("capabilities", out var capabilitiesElement))
+ foreach (var parser in setup.SourceSchemaClientConfigurationParsers)
{
- if (capabilitiesElement.TryGetProperty("standard", out var standard))
+ if (parser.TryParse(sourceSchema, transport, out configuration))
{
- if (standard.TryGetProperty("formats", out var formats))
- {
- var builder = ImmutableArray.CreateBuilder();
-
- foreach (var format in formats.EnumerateArray())
- {
- builder.Add(MediaTypeWithQualityHeaderValue.Parse(format.GetString()!));
- }
-
- defaultAcceptHeaderValues = builder.ToImmutable();
- }
- }
-
- if (capabilitiesElement.TryGetProperty("batching", out var batchingElement))
- {
- if (batchingElement.TryGetProperty("variableBatching", out var supported)
- && !supported.GetBoolean())
- {
- capabilities &= ~SourceSchemaClientCapabilities.VariableBatching;
- }
-
- if (batchingElement.TryGetProperty("requestBatching", out supported)
- && !supported.GetBoolean())
- {
- capabilities &= ~SourceSchemaClientCapabilities.RequestBatching;
- }
-
- if (batchingElement.TryGetProperty("formats", out var formats))
- {
- var builder = ImmutableArray.CreateBuilder();
-
- foreach (var format in formats.EnumerateArray())
- {
- builder.Add(MediaTypeWithQualityHeaderValue.Parse(format.GetString()!));
- }
-
- batchingAcceptHeaderValues = builder.ToImmutable();
- }
+ return true;
}
+ }
- if (capabilitiesElement.TryGetProperty("subscriptions", out var subscriptionsElement))
+ foreach (var parser in _builtInParsers)
+ {
+ if (parser.TryParse(sourceSchema, transport, out configuration))
{
- if (subscriptionsElement.TryGetProperty("supported", out var supported)
- && !supported.GetBoolean())
- {
- supportedOperations &= ~SupportedOperationType.Subscription;
- }
-
- if (subscriptionsElement.TryGetProperty("formats", out var formats))
- {
- var builder = ImmutableArray.CreateBuilder();
-
- foreach (var format in formats.EnumerateArray())
- {
- builder.Add(MediaTypeWithQualityHeaderValue.Parse(format.GetString()!));
- }
-
- subscriptionAcceptHeaderValues = builder.ToImmutable();
- }
+ return true;
}
}
- return new SourceSchemaHttpClientConfiguration(
- name: schemaName,
- httpClientName: clientName,
- baseAddress: new Uri(http.GetProperty("url").GetString()!),
- supportedOperations: supportedOperations,
- capabilities: capabilities,
- defaultAcceptHeaderValues: defaultAcceptHeaderValues,
- batchingAcceptHeaderValues: batchingAcceptHeaderValues,
- subscriptionAcceptHeaderValues: subscriptionAcceptHeaderValues);
+ configuration = null;
+ return false;
}
private FeatureCollection CreateSchemaFeatures(
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs
index 78b30327c62..e4f3010d215 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs
@@ -79,7 +79,8 @@ private async ValueTask ExecuteSingleAsync(
OperationType = operation.Operation.Type,
OperationSourceText = operation.Operation.SourceText,
Variables = variables,
- RequiresFileUpload = operation.RequiresFileUpload
+ RequiresFileUpload = operation.RequiresFileUpload,
+ OperationHash = operation.OperationHash
};
var hasSomeErrors = false;
@@ -295,7 +296,8 @@ private int BuildRequests(
OperationType = operation.Operation.Type,
OperationSourceText = operation.Operation.SourceText,
Variables = variables,
- RequiresFileUpload = _requiresFileUpload
+ RequiresFileUpload = _requiresFileUpload,
+ OperationHash = operation.OperationHash
});
operationByIndex[operationCount] = operation;
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs
index 983a84afe02..907a2b927ec 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs
@@ -7,6 +7,7 @@ internal abstract class OperationDefinition : IOperationPlanNode
private readonly OperationRequirement[] _requirements;
private readonly string[] _forwardedVariables;
private readonly ExecutionNodeCondition[] _conditions;
+ private readonly ulong _operationHash;
private IOperationPlanNode[] _dependents = [];
private IOperationPlanNode[] _dependencies = [];
private int _dependentCount;
@@ -25,6 +26,7 @@ protected OperationDefinition(
{
Id = id;
Operation = operation;
+ _operationHash = operation.SourceText.ComputeHash();
SchemaName = schemaName;
Source = source;
_requirements = requirements;
@@ -45,6 +47,12 @@ protected OperationDefinition(
///
public OperationSourceText Operation { get; }
+ ///
+ /// Gets the xxhash64 of the operation source text. Precomputed during
+ /// construction for use as a cache key by connectors.
+ ///
+ public ulong OperationHash => _operationHash;
+
///
/// Gets the name of the source schema that this operation targets,
/// or null when the schema is determined dynamically at runtime.
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs
index 23c1a52b292..52334741b97 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs
@@ -17,6 +17,7 @@ public sealed class OperationExecutionNode : ExecutionNode
private readonly ExecutionNodeCondition[] _conditions;
private readonly bool _requiresFileUpload;
private readonly OperationSourceText _operation;
+ private readonly ulong _operationHash;
private readonly string? _schemaName;
private readonly SelectionPath _target;
private readonly SelectionPath _source;
@@ -35,6 +36,7 @@ internal OperationExecutionNode(
{
Id = id;
_operation = operation;
+ _operationHash = operation.SourceText.ComputeHash();
_schemaName = schemaName;
_target = target;
_source = source;
@@ -119,7 +121,8 @@ protected override async ValueTask OnExecuteAsync(
OperationType = _operation.Type,
OperationSourceText = _operation.SourceText,
Variables = variables,
- RequiresFileUpload = _requiresFileUpload
+ RequiresFileUpload = _requiresFileUpload,
+ OperationHash = _operationHash
};
var index = 0;
@@ -275,7 +278,8 @@ internal async Task SubscribeAsync(
SchemaName = schemaName,
OperationType = _operation.Type,
OperationSourceText = _operation.SourceText,
- Variables = variables
+ Variables = variables,
+ OperationHash = _operationHash
};
var subscriptionId = SubscriptionId.Next();
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj b/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj
index cac2e13eadf..0e5f9d8537b 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj
@@ -15,6 +15,7 @@
+
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs
index 0d879cb07ac..e6fecdecdd1 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs
@@ -9,21 +9,21 @@
namespace HotChocolate.Fusion.Properties {
using System;
-
-
+
+
[System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class FusionExecutionResources {
-
+
private static System.Resources.ResourceManager resourceMan;
-
+
private static System.Globalization.CultureInfo resourceCulture;
-
+
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal FusionExecutionResources() {
}
-
+
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
internal static System.Resources.ResourceManager ResourceManager {
get {
@@ -34,7 +34,7 @@ internal static System.Resources.ResourceManager ResourceManager {
return resourceMan;
}
}
-
+
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
internal static System.Globalization.CultureInfo Culture {
get {
@@ -44,43 +44,43 @@ internal static System.Globalization.CultureInfo Culture {
resourceCulture = value;
}
}
-
+
internal static string CompositeResultElement_GetBoolean_JsonElementHasWrongType {
get {
return ResourceManager.GetString("CompositeResultElement_GetBoolean_JsonElementHasWrongType", resourceCulture);
}
}
-
+
internal static string SourceResultElement_GetBoolean_JsonElementHasWrongType {
get {
return ResourceManager.GetString("SourceResultElement_GetBoolean_JsonElementHasWrongType", resourceCulture);
}
}
-
+
internal static string Rethrowable {
get {
return ResourceManager.GetString("Rethrowable", resourceCulture);
}
}
-
+
internal static string JsonReaderHelper_TranscodeHelper_CannotTranscodeInvalidUtf8 {
get {
return ResourceManager.GetString("JsonReaderHelper_TranscodeHelper_CannotTranscodeInvalidUtf8", resourceCulture);
}
}
-
+
internal static string ThrowHelper_ReadInvalidUTF16 {
get {
return ResourceManager.GetString("ThrowHelper_ReadInvalidUTF16", resourceCulture);
}
}
-
+
internal static string ThrowHelper_ReadIncompleteUTF16 {
get {
return ResourceManager.GetString("ThrowHelper_ReadIncompleteUTF16", resourceCulture);
}
}
-
+
internal static string FixedSizeArrayPool_Return_InvalidArraySize {
get {
return ResourceManager.GetString("FixedSizeArrayPool_Return_InvalidArraySize", resourceCulture);
@@ -153,12 +153,6 @@ internal static string SourceSchemaRequestDispatcher_OperationAborted {
}
}
- internal static string SourceSchemaRequestDispatcher_BatchResponseCountMismatch {
- get {
- return ResourceManager.GetString("SourceSchemaRequestDispatcher_BatchResponseCountMismatch", resourceCulture);
- }
- }
-
internal static string OperationPlan_NodeNotFound {
get {
return ResourceManager.GetString("OperationPlan_NodeNotFound", resourceCulture);
diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx
index 44d9126ac97..b8857db71d4 100644
--- a/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx
+++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx
@@ -72,9 +72,6 @@
The operation execution was aborted.
-
- The client did not return a response for each request in the batch.
-
No execution node with id '{0}' exists in this plan.
diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs
new file mode 100644
index 00000000000..3a41754d08f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs
@@ -0,0 +1,624 @@
+namespace HotChocolate.Fusion.ApolloFederation;
+
+public sealed class FederationSchemaTransformerTests
+{
+ [Fact]
+ public void Transform_SimpleEntity()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+ }
+ type Product @key(fields: "id") {
+ id: ID!
+ name: String
+ }
+ type Query {
+ product(id: ID!): Product
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_CompositeKey()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+ }
+ type Product @key(fields: "sku package") {
+ sku: String!
+ package: String!
+ name: String
+ }
+ type Query {
+ products: [Product]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_MultipleKeys()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+ }
+ type Product @key(fields: "id") @key(fields: "sku package") {
+ id: ID!
+ sku: String!
+ package: String!
+ name: String
+ }
+ type Query {
+ products: [Product]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_RequiresDirective()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@requires", "@external"]) {
+ query: Query
+ }
+ type Product @key(fields: "id") {
+ id: ID!
+ price: Float @external
+ weight: Float @external
+ shippingEstimate: Float @requires(fields: "price weight")
+ }
+ type Query {
+ product(id: ID!): Product
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @requires(fields: FieldSet!) on FIELD_DEFINITION
+ directive @external on FIELD_DEFINITION
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_ProvidesDirective()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@provides"]) {
+ query: Query
+ }
+ type User @key(fields: "id") {
+ id: ID!
+ username: String
+ totalProductsCreated: Int
+ }
+ type Review {
+ body: String
+ author: User @provides(fields: "username")
+ }
+ type Query {
+ reviews: [Review]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = User
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @provides(fields: FieldSet!) on FIELD_DEFINITION
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_ExternalDirective()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@external"]) {
+ query: Query
+ }
+ type Product @key(fields: "id") {
+ id: ID!
+ price: Float @external
+ }
+ type Query {
+ products: [Product]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @external on FIELD_DEFINITION
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_NonResolvableKey()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+ }
+ type Product @key(fields: "id", resolvable: false) {
+ id: ID!
+ name: String
+ }
+ type Query {
+ products: [Product]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_FullIntegration()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@requires", "@provides", "@external"]) {
+ query: Query
+ }
+ type Product @key(fields: "id") @key(fields: "sku package") {
+ id: ID!
+ sku: String!
+ package: String!
+ name: String
+ price: Float
+ weight: Float
+ inStock: Boolean
+ createdBy: User @provides(fields: "totalProductsCreated")
+ }
+ type User @key(fields: "id") {
+ id: ID!
+ username: String @external
+ totalProductsCreated: Int
+ }
+ type Review {
+ body: String
+ author: User
+ }
+ type Query {
+ product(id: ID!): Product
+ reviews: [Review]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product | User
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @requires(fields: FieldSet!) on FIELD_DEFINITION
+ directive @provides(fields: FieldSet!) on FIELD_DEFINITION
+ directive @external on FIELD_DEFINITION
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_KeyResolvableArgument()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+ }
+ type Product @key(fields: "id", resolvable: true) {
+ id: ID!
+ name: String
+ }
+ type Query {
+ products: [Product]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_NonResolvableAndResolvableKeys()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+ }
+ type Product @key(fields: "id") @key(fields: "sku", resolvable: false) {
+ id: ID!
+ sku: String!
+ name: String
+ }
+ type Query {
+ products: [Product]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_InterfaceObject_Should_ReturnError()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@interfaceObject"]) {
+ query: Query
+ }
+ type Product @key(fields: "id") @interfaceObject {
+ id: ID!
+ name: String
+ }
+ type Query {
+ products: [Product]
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @interfaceObject on OBJECT
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsFailure);
+ Assert.Contains(
+ result.Errors,
+ e => e.Message.Contains("@interfaceObject"));
+ }
+
+ [Fact]
+ public void Transform_FederationV1_Should_ReturnError()
+ {
+ // arrange — no @link directive means v1
+ const string federationSdl =
+ """
+ type Product @key(fields: "id") {
+ id: ID!
+ name: String
+ }
+ type Query {
+ product(id: ID!): Product
+ }
+ directive @key(fields: String!) repeatable on OBJECT | INTERFACE
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsFailure);
+ Assert.Contains(
+ result.Errors,
+ e => e.Message.Contains("v1"));
+ }
+
+ [Fact]
+ public void Transform_InvalidSdl_Should_ReturnParseError()
+ {
+ // arrange
+ const string federationSdl = "this is not valid graphql }{][";
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsFailure);
+ Assert.Contains(
+ result.Errors,
+ e => e.Message.Contains("parse"));
+ }
+
+ [Fact]
+ public void Transform_EmptyString_Should_ThrowArgumentException()
+ {
+ // arrange & act & assert
+ Assert.Throws(
+ () => FederationSchemaTransformer.Transform(string.Empty));
+ }
+
+ [Fact]
+ public void Transform_NestedObjectKey()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+ }
+ type Article @key(fields: "metadata { id }") {
+ metadata: ArticleMetadata!
+ title: String!
+ }
+ type ArticleMetadata {
+ id: ID!
+ author: String
+ }
+ type Query {
+ article: Article
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = Article
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_NestedListKey()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+ }
+ type ProductList @key(fields: "products { id }") {
+ products: [Product!]!
+ }
+ type Product @key(fields: "id") {
+ id: ID!
+ }
+ type Query {
+ topProducts: ProductList!
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = ProductList | Product
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+
+ [Fact]
+ public void Transform_DeeplyNestedListKey()
+ {
+ // arrange
+ const string federationSdl =
+ """
+ schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@shareable"]) {
+ query: Query
+ }
+ type ProductList
+ @key(fields: "products { id pid category { id tag } } selected { id }") {
+ products: [Product!]!
+ first: Product @shareable
+ selected: Product @shareable
+ }
+ type Product @key(fields: "id pid category { id tag }") {
+ id: String!
+ pid: String
+ category: Category
+ }
+ type Category @key(fields: "id tag") {
+ id: String!
+ tag: String
+ }
+ type Query {
+ topProducts: ProductList!
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+ }
+ type _Service { sdl: String! }
+ union _Entity = ProductList | Product | Category
+ scalar FieldSet
+ scalar _Any
+ directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+ directive @shareable on FIELD_DEFINITION | OBJECT
+ directive @link(url: String! import: [String!]) repeatable on SCHEMA
+ """;
+
+ // act
+ var result = FederationSchemaTransformer.Transform(federationSdl);
+
+ // assert
+ Assert.True(result.IsSuccess);
+ Snapshot.Create()
+ .Add(federationSdl, "Apollo Federation SDL", "graphql")
+ .Add(result.Value, "Transformed SDL", "graphql")
+ .MatchMarkdownSnapshot();
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/HotChocolate.Fusion.Composition.ApolloFederation.Tests.csproj b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/HotChocolate.Fusion.Composition.ApolloFederation.Tests.csproj
new file mode 100644
index 00000000000..1d60935f928
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/HotChocolate.Fusion.Composition.ApolloFederation.Tests.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+ HotChocolate.Fusion.Composition.ApolloFederation.Tests
+ HotChocolate.Fusion.ApolloFederation
+
+
+
+
+
+
+
diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_DeeplyNestedListKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_DeeplyNestedListKey.md
new file mode 100644
index 00000000000..a8372b0203f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_DeeplyNestedListKey.md
@@ -0,0 +1,103 @@
+# Transform_DeeplyNestedListKey
+
+## Apollo Federation SDL
+
+```graphql
+schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@shareable"]) {
+ query: Query
+}
+type ProductList
+ @key(fields: "products { id pid category { id tag } } selected { id }") {
+ products: [Product!]!
+ first: Product @shareable
+ selected: Product @shareable
+}
+type Product @key(fields: "id pid category { id tag }") {
+ id: String!
+ pid: String
+ category: Category
+}
+type Category @key(fields: "id tag") {
+ id: String!
+ tag: String
+}
+type Query {
+ topProducts: ProductList!
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+}
+type _Service { sdl: String! }
+union _Entity = ProductList | Product | Category
+scalar FieldSet
+scalar _Any
+directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+directive @shareable on FIELD_DEFINITION | OBJECT
+directive @link(url: String! import: [String!]) repeatable on SCHEMA
+```
+
+## Transformed SDL
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ categoryByIdAndTag(id: String!, tag: String!): Category @internal @lookup
+ productByIdAndPidAndCategoryAndIdAndTag(
+ key: ProductByIdAndPidAndCategoryAndIdAndTagInput! @is(field: "{ id, pid, category: category.{ id, tag } }")
+ ): Product @internal @lookup
+ productListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndId(
+ key: ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput! @is(field: "{ products: products[{ id, pid, category: category.{ id, tag } }], selected: selected.{ id } }")
+ ): ProductList @internal @lookup
+ topProducts: ProductList!
+}
+
+type Category @key(fields: "id tag") {
+ id: String!
+ tag: String
+}
+
+type Product @key(fields: "id pid category { id tag }") {
+ category: Category
+ id: String!
+ pid: String
+}
+
+type ProductList {
+ first: Product @shareable
+ products: [Product!]! @shareable
+ selected: Product @shareable
+}
+
+input ProductByIdAndPidAndCategoryAndIdAndTagInput {
+ category: ProductByIdAndPidAndCategoryAndIdAndTagInput_Category
+ id: String!
+ pid: String
+}
+
+input ProductByIdAndPidAndCategoryAndIdAndTagInput_Category {
+ id: String!
+ tag: String
+}
+
+input ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput {
+ products: [ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput_Products!]!
+ selected: ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput_Selected
+}
+
+input ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput_Products {
+ category: ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput_Products_Category
+ id: String!
+ pid: String
+}
+
+input ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput_Products_Category {
+ id: String!
+ tag: String
+}
+
+input ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput_Selected {
+ id: String!
+}
+```
diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NestedListKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NestedListKey.md
new file mode 100644
index 00000000000..fca1ba22741
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NestedListKey.md
@@ -0,0 +1,58 @@
+# Transform_NestedListKey
+
+## Apollo Federation SDL
+
+```graphql
+schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+}
+type ProductList @key(fields: "products { id }") {
+ products: [Product!]!
+}
+type Product @key(fields: "id") {
+ id: ID!
+}
+type Query {
+ topProducts: ProductList!
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+}
+type _Service { sdl: String! }
+union _Entity = ProductList | Product
+scalar FieldSet
+scalar _Any
+directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+directive @link(url: String! import: [String!]) repeatable on SCHEMA
+```
+
+## Transformed SDL
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ productById(id: ID!): Product @internal @lookup
+ productListByProductsAndId(
+ key: ProductListByProductsAndIdInput! @is(field: "{ products: products[{ id }] }")
+ ): ProductList @internal @lookup
+ topProducts: ProductList!
+}
+
+type Product @key(fields: "id") {
+ id: ID!
+}
+
+type ProductList {
+ products: [Product!]! @shareable
+}
+
+input ProductListByProductsAndIdInput {
+ products: [ProductListByProductsAndIdInput_Products!]!
+}
+
+input ProductListByProductsAndIdInput_Products {
+ id: ID!
+}
+```
diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NestedObjectKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NestedObjectKey.md
new file mode 100644
index 00000000000..a10e4debb9b
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NestedObjectKey.md
@@ -0,0 +1,61 @@
+# Transform_NestedObjectKey
+
+## Apollo Federation SDL
+
+```graphql
+schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key"]) {
+ query: Query
+}
+type Article @key(fields: "metadata { id }") {
+ metadata: ArticleMetadata!
+ title: String!
+}
+type ArticleMetadata {
+ id: ID!
+ author: String
+}
+type Query {
+ article: Article
+ _service: _Service!
+ _entities(representations: [_Any!]!): [_Entity]!
+}
+type _Service { sdl: String! }
+union _Entity = Article
+scalar FieldSet
+scalar _Any
+directive @key(fields: FieldSet! resolvable: Boolean = true) repeatable on OBJECT | INTERFACE
+directive @link(url: String! import: [String!]) repeatable on SCHEMA
+```
+
+## Transformed SDL
+
+```graphql
+schema {
+ query: Query
+}
+
+type Query {
+ article: Article
+ articleByMetadataAndId(
+ key: ArticleByMetadataAndIdInput! @is(field: "{ metadata: metadata.{ id } }")
+ ): Article @internal @lookup
+}
+
+type Article @key(fields: "metadata { id }") {
+ metadata: ArticleMetadata!
+ title: String!
+}
+
+type ArticleMetadata {
+ author: String
+ id: ID!
+}
+
+input ArticleByMetadataAndIdInput {
+ metadata: ArticleByMetadataAndIdInput_Metadata
+}
+
+input ArticleByMetadataAndIdInput_Metadata {
+ id: ID!
+}
+```
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/HotChocolate.Fusion.Connectors.ApolloFederation.Compliance.Tests.csproj b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/HotChocolate.Fusion.Connectors.ApolloFederation.Compliance.Tests.csproj
new file mode 100644
index 00000000000..091f96bd0aa
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/HotChocolate.Fusion.Connectors.ApolloFederation.Compliance.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+ HotChocolate.Fusion.Connectors.ApolloFederation.Compliance.Tests
+ HotChocolate.Fusion
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditAssertions.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditAssertions.cs
new file mode 100644
index 00000000000..352b9c6a313
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditAssertions.cs
@@ -0,0 +1,78 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace HotChocolate.Fusion;
+
+///
+/// Assertions for federation-gateway-audit test cases. Matches the audit runner
+/// contract from graphql-hive/federation-gateway-audit/src/test.ts: deep-compare
+/// the data payload and assert presence-of-errors independently.
+///
+internal static class AuditAssertions
+{
+ ///
+ /// Asserts that the gateway response matches the expected outcome.
+ ///
+ ///
+ /// The full gateway response serialized as JSON (i.e. result.ToJson()).
+ ///
+ ///
+ /// The expected data payload. When , the data
+ /// payload is not asserted.
+ ///
+ ///
+ /// When not , the presence of an errors array is
+ /// asserted against this value.
+ ///
+ public static void Assert(
+ string actualJson,
+ string? expectedDataJson,
+ bool? expectsErrors)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(actualJson);
+
+ var actual = JsonNode.Parse(actualJson)
+ ?? throw new InvalidOperationException("Gateway response JSON parsed to null.");
+
+ if (expectedDataJson is not null)
+ {
+ var actualData = actual["data"];
+ var expectedData = JsonNode.Parse(expectedDataJson);
+
+ if (!JsonNode.DeepEquals(actualData, expectedData))
+ {
+ var actualDataText = actualData?.ToJsonString(s_indented) ?? "null";
+ var expectedDataText = expectedData?.ToJsonString(s_indented) ?? "null";
+
+ Xunit.Assert.Fail(
+ $"""
+ Data payload did not match.
+
+ Expected:
+ {expectedDataText}
+
+ Actual:
+ {actualDataText}
+ """);
+ }
+ }
+
+ if (expectsErrors is not null)
+ {
+ var errors = actual["errors"];
+ var hasErrors = errors is JsonArray { Count: > 0 };
+
+ if (hasErrors != expectsErrors.Value)
+ {
+ var errorsText = errors?.ToJsonString(s_indented) ?? "";
+
+ Xunit.Assert.Fail(
+ expectsErrors.Value
+ ? $"Expected response to carry errors, but none were present. Response: {actualJson}"
+ : $"Expected response to carry no errors, but errors were present: {errorsText}");
+ }
+ }
+ }
+
+ private static readonly JsonSerializerOptions s_indented = new() { WriteIndented = true };
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditFixture.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditFixture.cs
new file mode 100644
index 00000000000..fb3de84235f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditFixture.cs
@@ -0,0 +1,58 @@
+using System.Reflection;
+
+namespace HotChocolate.Fusion;
+
+///
+/// Reads embedded reference files (tests.json, data.json,
+/// subgraphs/*.graphql) vendored from the federation-gateway-audit repository
+/// under each suite's Reference/ directory. Reference resources are material
+/// for porting a suite; the live tests inline their query literals via
+/// .
+///
+internal static class AuditFixture
+{
+ private const string SuitesPath = "Suites";
+ private const string ReferencePath = "Reference";
+ private static readonly Assembly s_assembly = typeof(AuditFixture).Assembly;
+ private static readonly string s_assemblyName = s_assembly.GetName().Name!;
+
+ ///
+ /// Reads an embedded reference text file for the specified audit suite.
+ ///
+ ///
+ /// The suite's PascalCase folder name under Suites/
+ /// (e.g. "SimpleEntityCall").
+ ///
+ ///
+ /// The path under the suite's Reference/ directory
+ /// (e.g. "tests.json" or "email.graphql"). Forward slashes are
+ /// translated to embedded-resource path separators.
+ ///
+ /// The UTF-8 contents of the embedded resource.
+ ///
+ /// Thrown when the expected embedded resource cannot be located.
+ ///
+ public static string LoadText(string suiteName, string relativePath)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(suiteName);
+ ArgumentException.ThrowIfNullOrEmpty(relativePath);
+
+ var resourceName = BuildResourceName(suiteName, relativePath);
+
+ using var stream = s_assembly.GetManifestResourceStream(resourceName)
+ ?? throw new FileNotFoundException(
+ $"Embedded reference resource '{resourceName}' was not found.",
+ resourceName);
+
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+
+ private static string BuildResourceName(string suiteName, string relativePath)
+ {
+ // Embedded resource names replace directory separators with '.', so
+ // 'subgraphs/email.graphql' becomes 'subgraphs.email.graphql'.
+ var normalized = relativePath.Replace('/', '.').Replace('\\', '.');
+ return $"{s_assemblyName}.{SuitesPath}.{suiteName}.{ReferencePath}.{normalized}";
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditTestCase.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditTestCase.cs
new file mode 100644
index 00000000000..a2c06db9cba
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/AuditTestCase.cs
@@ -0,0 +1,19 @@
+namespace HotChocolate.Fusion;
+
+///
+/// Describes a single federation-gateway-audit test case:
+/// a GraphQL query and its expected response (data and/or errors).
+///
+/// The GraphQL operation to execute against the gateway.
+///
+/// The expected JSON payload of the data field, or if the
+/// assertion should not compare the data payload.
+///
+///
+/// If not , the test asserts whether an errors array is
+/// present on the response.
+///
+public sealed record AuditTestCase(
+ string Query,
+ string? ExpectedData,
+ bool? ExpectsErrors);
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/ComplianceTestBase.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/ComplianceTestBase.cs
new file mode 100644
index 00000000000..cdc006d6c6e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/ComplianceTestBase.cs
@@ -0,0 +1,61 @@
+using System.Diagnostics.CodeAnalysis;
+using HotChocolate.Execution;
+
+namespace HotChocolate.Fusion;
+
+///
+/// Base class for graphql-hive/federation-gateway-audit compliance suites.
+/// Each derived suite builds its gateway (via ) and
+/// declares one [Fact] per audit test case that calls
+/// with the inline query and expected response. The composed
+/// (subgraph TestServers and the gateway service
+/// provider) is disposed automatically at the end of each test.
+///
+public abstract class ComplianceTestBase : IAsyncLifetime
+{
+ private FusionGateway? _gateway;
+
+ ///
+ /// Builds the Fusion gateway for this suite. Called lazily from
+ /// on the first invocation per test.
+ ///
+ protected abstract Task BuildGatewayAsync();
+
+ ///
+ /// Executes against the gateway and asserts the response
+ /// matches the expected data payload and/or error presence.
+ ///
+ /// The GraphQL operation to execute.
+ ///
+ /// The expected data payload as a JSON object. When ,
+ /// the data payload is not asserted.
+ ///
+ ///
+ /// When not , asserts whether an errors array is
+ /// present on the response.
+ ///
+ protected async Task RunAsync(
+ [StringSyntax("GraphQL")] string query,
+ [StringSyntax("Json")] string? expectedData = null,
+ bool? expectsErrors = null)
+ {
+ _gateway ??= await BuildGatewayAsync();
+ var result = await _gateway.Executor.ExecuteAsync(query);
+ var json = result.ToJson(withIndentations: false);
+
+ AuditAssertions.Assert(json, expectedData, expectsErrors);
+ }
+
+ ///
+ public Task InitializeAsync() => Task.CompletedTask;
+
+ ///
+ public async Task DisposeAsync()
+ {
+ if (_gateway is not null)
+ {
+ await _gateway.DisposeAsync();
+ _gateway = null;
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGateway.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGateway.cs
new file mode 100644
index 00000000000..3755d272d8c
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGateway.cs
@@ -0,0 +1,52 @@
+using HotChocolate.Execution;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HotChocolate.Fusion;
+
+///
+/// A composed Fusion gateway over a set of Apollo Federation subgraph
+/// s running in-process under
+/// . Disposing the gateway tears
+/// down the subgraph hosts and the gateway service provider.
+///
+public sealed class FusionGateway : IAsyncDisposable
+{
+ private readonly IReadOnlyList _subgraphs;
+ private readonly ServiceProvider _services;
+ private bool _disposed;
+
+ internal FusionGateway(
+ IRequestExecutor executor,
+ ServiceProvider services,
+ IReadOnlyList subgraphs)
+ {
+ Executor = executor;
+ _services = services;
+ _subgraphs = subgraphs;
+ }
+
+ ///
+ /// The Fusion gateway .
+ ///
+ public IRequestExecutor Executor { get; }
+
+ ///
+ /// Tears down subgraph hosts and disposes the gateway service provider.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ foreach (var subgraph in _subgraphs)
+ {
+ await subgraph.DisposeAsync().ConfigureAwait(false);
+ }
+
+ await _services.DisposeAsync().ConfigureAwait(false);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGatewayBuilder.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGatewayBuilder.cs
new file mode 100644
index 00000000000..3eb8d94b081
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/FusionGatewayBuilder.cs
@@ -0,0 +1,493 @@
+using System.Buffers;
+using System.Text;
+using System.Text.Json;
+using HotChocolate.Buffers;
+using HotChocolate.Execution;
+using HotChocolate.Fusion.ApolloFederation;
+using HotChocolate.Fusion.Logging;
+using HotChocolate.Fusion.Options;
+using HotChocolate.Language;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit.Sdk;
+
+namespace HotChocolate.Fusion;
+
+///
+/// Builds a Fusion gateway over a set of in-process Apollo Federation subgraphs.
+/// Each subgraph factory produces a whose
+/// serves the subgraph's
+/// /graphql endpoint. The gateway's routes
+/// source-schema requests through those test servers, so every gateway call flows
+/// through the real ASP.NET Core / HotChocolate HTTP pipeline.
+///
+internal static class FusionGatewayBuilder
+{
+ private const string DefaultBaseAddress = "http://localhost/graphql";
+
+ ///
+ /// Composes a Fusion gateway around the supplied Apollo Federation subgraphs.
+ ///
+ ///
+ /// Named subgraph factories. Each factory returns a
+ /// whose HotChocolate AddApolloFederation() schema is hosted under
+ /// .
+ ///
+ ///
+ /// The composed ; dispose it to tear down the
+ /// subgraph hosts and the gateway service provider.
+ ///
+ public static async Task ComposeAsync(
+ params (string Name, Func> Factory)[] subgraphs)
+ {
+ ArgumentNullException.ThrowIfNull(subgraphs);
+
+ if (subgraphs.Length == 0)
+ {
+ throw new ArgumentException(
+ "At least one subgraph must be provided.",
+ nameof(subgraphs));
+ }
+
+ var hosts = new List(subgraphs.Length);
+ var sourceSchemaTexts = new List(subgraphs.Length);
+ var subgraphInfos = new List(subgraphs.Length);
+
+ try
+ {
+ foreach (var (name, factory) in subgraphs)
+ {
+ var host = await factory().ConfigureAwait(false);
+
+ if (!string.Equals(host.Name, name, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Subgraph factory for '{name}' produced a host named '{host.Name}'.");
+ }
+
+ hosts.Add(host);
+
+ var info = await BuildSubgraphInfoAsync(host).ConfigureAwait(false);
+
+ sourceSchemaTexts.Add(new SourceSchemaText(name, info.CompositeSdl));
+ subgraphInfos.Add(info);
+ }
+
+ var schemaDocument = ComposeSchema(sourceSchemaTexts);
+ var settings = BuildGatewaySettings(subgraphInfos);
+
+ var gatewayServices = new ServiceCollection();
+ gatewayServices.AddSingleton(
+ new TestSubgraphHttpClientFactory(hosts));
+
+ gatewayServices
+ .AddGraphQLGateway()
+ .AddApolloFederationSupport()
+ .AddInMemoryConfiguration(schemaDocument, settings);
+
+ var services = gatewayServices.BuildServiceProvider();
+
+ var executor = await services
+ .GetRequiredService()
+ .GetExecutorAsync()
+ .ConfigureAwait(false);
+
+ return new FusionGateway(executor, services, hosts);
+ }
+ catch
+ {
+ foreach (var host in hosts)
+ {
+ await host.DisposeAsync().ConfigureAwait(false);
+ }
+
+ throw;
+ }
+ }
+
+ private static async Task BuildSubgraphInfoAsync(SubgraphHost host)
+ {
+ var federationSdl = await FetchSubgraphSdlAsync(host).ConfigureAwait(false);
+ var transformResult = FederationSchemaTransformer.Transform(federationSdl);
+
+ if (!transformResult.IsSuccess)
+ {
+ var messages = string.Join(
+ ", ",
+ transformResult.Errors.Select(static e => e.Message));
+ throw new XunitException(
+ $"Apollo Federation transform failed for subgraph '{host.Name}': {messages}");
+ }
+
+ var compositeSdl = transformResult.Value;
+ var lookups = ExtractLookups(compositeSdl);
+
+ return new SubgraphInfo(
+ host.Name,
+ compositeSdl,
+ lookups,
+ new Uri(DefaultBaseAddress));
+ }
+
+ private static async Task FetchSubgraphSdlAsync(SubgraphHost host)
+ {
+ using var client = host.CreateClient();
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/graphql")
+ {
+ Content = new StringContent(
+ "{\"query\":\"{ _service { sdl } }\"}",
+ Encoding.UTF8,
+ "application/json")
+ };
+
+ using var response = await client.SendAsync(request).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ await using var stream = await response.Content.ReadAsStreamAsync()
+ .ConfigureAwait(false);
+ using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false);
+
+ if (!document.RootElement.TryGetProperty("data", out var data)
+ || !data.TryGetProperty("_service", out var service)
+ || !service.TryGetProperty("sdl", out var sdl)
+ || sdl.ValueKind != JsonValueKind.String
+ || sdl.GetString() is not { Length: > 0 } sdlText)
+ {
+ throw new XunitException(
+ $"Subgraph '{host.Name}' did not return '_service.sdl' via HTTP.");
+ }
+
+ return sdlText;
+ }
+
+ private static DocumentNode ComposeSchema(IReadOnlyList sourceSchemas)
+ {
+ var compositionLog = new CompositionLog();
+ var options = new SchemaComposerOptions();
+
+ // The Apollo Federation transformer already emits every resolvable
+ // '@key' as an explicit '@lookup' field with '@is' metadata. Turning
+ // off key inference per source schema avoids double-emitting the
+ // '@key' directive and prevents the composer from re-introducing
+ // list-typed '@key' directives for nested list keys, which the
+ // Composite Schema Spec disallows at type level.
+ foreach (var sourceSchema in sourceSchemas)
+ {
+ options.SourceSchemas[sourceSchema.Name] = new SourceSchemaOptions
+ {
+ Preprocessor = new SourceSchemaPreprocessorOptions
+ {
+ InferKeysFromLookups = false
+ }
+ };
+ }
+
+ var composer = new SchemaComposer(sourceSchemas, options, compositionLog);
+
+ var result = composer.Compose();
+
+ if (!result.IsSuccess)
+ {
+ var sb = new StringBuilder();
+ sb.Append(result.Errors[0].Message);
+
+ foreach (var entry in compositionLog)
+ {
+ sb.AppendLine();
+ sb.Append(entry.Message);
+
+ if (entry.Extensions is { Count: > 0 } extensions)
+ {
+ foreach (var (key, value) in extensions)
+ {
+ sb.AppendLine();
+ sb.Append(" - ");
+ sb.Append(key);
+ sb.Append(": ");
+
+ if (value is System.Collections.IEnumerable enumerable
+ and not string)
+ {
+ var first = true;
+ foreach (var item in enumerable)
+ {
+ if (!first)
+ {
+ sb.Append("; ");
+ }
+ sb.Append(item);
+ first = false;
+ }
+ }
+ else
+ {
+ sb.Append(value);
+ }
+ }
+ }
+ }
+
+ throw new XunitException(sb.ToString());
+ }
+
+ return result.Value.ToSyntaxNode();
+ }
+
+ private static JsonDocumentOwner BuildGatewaySettings(IReadOnlyList subgraphs)
+ {
+ var buffer = new ArrayBufferWriter();
+
+ using (var writer = new Utf8JsonWriter(buffer))
+ {
+ writer.WriteStartObject();
+ writer.WriteStartObject("sourceSchemas");
+
+ foreach (var subgraph in subgraphs)
+ {
+ writer.WriteStartObject(subgraph.Name);
+
+ writer.WriteStartObject("transports");
+ writer.WriteStartObject("http");
+ writer.WriteString("url", subgraph.BaseAddress.ToString());
+ writer.WriteEndObject();
+ writer.WriteEndObject();
+
+ writer.WriteStartObject("extensions");
+ writer.WriteStartObject("apolloFederation");
+ writer.WriteStartObject("lookups");
+
+ foreach (var (lookupName, info) in subgraph.Lookups)
+ {
+ writer.WriteStartObject(lookupName);
+ writer.WriteString("entityType", info.EntityTypeName);
+
+ if (info.ArgumentToKeyFieldMap.Count > 0)
+ {
+ writer.WriteStartObject("arguments");
+
+ foreach (var (argument, keyField) in info.ArgumentToKeyFieldMap)
+ {
+ writer.WriteString(argument, keyField);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndObject();
+ writer.WriteEndObject();
+ writer.WriteEndObject();
+
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndObject();
+ writer.WriteEndObject();
+ writer.Flush();
+ }
+
+ var document = JsonDocument.Parse(buffer.WrittenMemory);
+ return new JsonDocumentOwner(document, EmptyMemoryOwner.Instance);
+ }
+
+ private static IReadOnlyDictionary ExtractLookups(string compositeSdl)
+ {
+ var document = Utf8GraphQLParser.Parse(compositeSdl);
+ var queryName = FindRootQueryName(document) ?? "Query";
+
+ var lookups = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var definition in document.Definitions)
+ {
+ if (definition is not ObjectTypeDefinitionNode objectType
+ || !string.Equals(objectType.Name.Value, queryName, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ foreach (var field in objectType.Fields)
+ {
+ if (!HasDirective(field.Directives, "lookup"))
+ {
+ continue;
+ }
+
+ var entityTypeName = GetNamedTypeName(field.Type);
+
+ if (entityTypeName is null)
+ {
+ continue;
+ }
+
+ var arguments = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var argument in field.Arguments)
+ {
+ var keyFieldName = GetIsFieldName(argument.Directives) ?? argument.Name.Value;
+ arguments[argument.Name.Value] = keyFieldName;
+ }
+
+ lookups[field.Name.Value] = new LookupFieldSettings(entityTypeName, arguments);
+ }
+ }
+
+ return lookups;
+ }
+
+ private static string? FindRootQueryName(DocumentNode document)
+ {
+ foreach (var definition in document.Definitions)
+ {
+ if (definition is SchemaDefinitionNode schema)
+ {
+ foreach (var operationType in schema.OperationTypes)
+ {
+ if (operationType.Operation is OperationType.Query)
+ {
+ return operationType.Type.Name.Value;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static bool HasDirective(
+ IReadOnlyList directives,
+ string name)
+ {
+ foreach (var directive in directives)
+ {
+ if (string.Equals(directive.Name.Value, name, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string? GetIsFieldName(IReadOnlyList directives)
+ {
+ foreach (var directive in directives)
+ {
+ if (!string.Equals(directive.Name.Value, "is", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ foreach (var argument in directive.Arguments)
+ {
+ if (string.Equals(argument.Name.Value, "field", StringComparison.Ordinal)
+ && argument.Value is StringValueNode stringValue)
+ {
+ return MapLookupArgumentPath(stringValue.Value);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ ///
+ /// Turns the @is(field: "...") value on a lookup argument into the
+ /// marker the runtime connector uses when building the
+ /// _entities(representations: [...]) payload.
+ ///
+ ///
+ /// The @is value is a Field Selection Map (FSM). That is the
+ /// Composite Schema Spec syntax for a field path (e.g. id,
+ /// category.{id, tag}, products[{id, pid}]).
+ ///
+ /// Two cases:
+ ///
+ /// -
+ ///
+ /// The FSM starts with {. The argument is a wrapper whose value is
+ /// already shaped like the representation. Return an empty string, which
+ /// tells the connector to spread the argument's fields into the
+ /// representation root instead of nesting under a field name.
+ ///
+ ///
+ /// -
+ ///
+ /// Any other FSM. The argument maps to one top-level representation field.
+ /// Return the first path segment, that is, everything before the first
+ /// ., [, { or space.
+ ///
+ ///
+ ///
+ ///
+ private static string MapLookupArgumentPath(string fieldSelectionMap)
+ {
+ if (fieldSelectionMap.Length > 0 && fieldSelectionMap[0] == '{')
+ {
+ return string.Empty;
+ }
+
+ for (var i = 0; i < fieldSelectionMap.Length; i++)
+ {
+ var c = fieldSelectionMap[i];
+
+ if (c is '.' or '[' or '{' or ' ')
+ {
+ return fieldSelectionMap[..i];
+ }
+ }
+
+ return fieldSelectionMap;
+ }
+
+ private static string? GetNamedTypeName(ITypeNode type) => type switch
+ {
+ NonNullTypeNode nonNull => GetNamedTypeName(nonNull.Type),
+ ListTypeNode list => GetNamedTypeName(list.Type),
+ NamedTypeNode named => named.Name.Value,
+ _ => null
+ };
+
+ private sealed record LookupFieldSettings(
+ string EntityTypeName,
+ IReadOnlyDictionary ArgumentToKeyFieldMap);
+
+ private sealed record SubgraphInfo(
+ string Name,
+ string CompositeSdl,
+ IReadOnlyDictionary Lookups,
+ Uri BaseAddress);
+
+ private sealed class TestSubgraphHttpClientFactory : IHttpClientFactory
+ {
+ private readonly Dictionary _subgraphs;
+
+ public TestSubgraphHttpClientFactory(IReadOnlyList subgraphs)
+ {
+ _subgraphs = subgraphs.ToDictionary(static s => s.Name, StringComparer.Ordinal);
+ }
+
+ public HttpClient CreateClient(string name)
+ {
+ if (!_subgraphs.TryGetValue(name, out var subgraph))
+ {
+ throw new InvalidOperationException(
+ $"No subgraph host registered for Apollo Federation subgraph '{name}'.");
+ }
+
+ return subgraph.CreateClient();
+ }
+ }
+
+ private sealed class EmptyMemoryOwner : IMemoryOwner
+ {
+ public static readonly EmptyMemoryOwner Instance = new();
+
+ public Memory Memory => default;
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/SubgraphHost.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/SubgraphHost.cs
new file mode 100644
index 00000000000..889f235abf3
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Infrastructure/SubgraphHost.cs
@@ -0,0 +1,54 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+
+namespace HotChocolate.Fusion;
+
+///
+/// An Apollo Federation subgraph hosted in-process under
+/// . Disposing stops and releases the web application.
+///
+public sealed class SubgraphHost : IAsyncDisposable
+{
+ private readonly WebApplication _app;
+ private bool _disposed;
+
+ internal SubgraphHost(string name, WebApplication app)
+ {
+ Name = name;
+ _app = app;
+ Server = app.GetTestServer();
+ }
+
+ ///
+ /// The Fusion source-schema name (used by the gateway to route requests).
+ ///
+ public string Name { get; }
+
+ ///
+ /// The underlying . Fresh
+ /// instances are produced by .
+ ///
+ public TestServer Server { get; }
+
+ ///
+ /// Creates a new that routes to the subgraph's
+ /// in-process . The caller owns the returned client.
+ ///
+ public HttpClient CreateClient() => Server.CreateClient();
+
+ ///
+ /// Stops the subgraph's web application and disposes its resources.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ await _app.StopAsync().ConfigureAwait(false);
+ await _app.DisposeAsync().ConfigureAwait(false);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/AbstractTypes/AbstractTypesTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/AbstractTypes/AbstractTypesTests.cs
new file mode 100644
index 00000000000..03ffc27b206
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/AbstractTypes/AbstractTypesTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class AbstractTypesTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: abstract-type / edge-case coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ChildTypeMismatch/ChildTypeMismatchTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ChildTypeMismatch/ChildTypeMismatchTests.cs
new file mode 100644
index 00000000000..ad1738f3338
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ChildTypeMismatch/ChildTypeMismatchTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class ChildTypeMismatchTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: abstract-type / edge-case coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CircularReferenceInterface/CircularReferenceInterfaceTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CircularReferenceInterface/CircularReferenceInterfaceTests.cs
new file mode 100644
index 00000000000..e363095a799
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CircularReferenceInterface/CircularReferenceInterfaceTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class CircularReferenceInterfaceTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: abstract-type / edge-case coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/ComplexEntityCallTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/ComplexEntityCallTests.cs
new file mode 100644
index 00000000000..eb4c7ae6a1f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/ComplexEntityCallTests.cs
@@ -0,0 +1,140 @@
+using HotChocolate.Fusion.Suites.ComplexEntityCall.Link;
+using HotChocolate.Fusion.Suites.ComplexEntityCall.List;
+using HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+using HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+namespace HotChocolate.Fusion.Suites;
+
+///
+/// Port of the complex-entity-call suite from
+/// graphql-hive/federation-gateway-audit. The gateway composes four
+/// Apollo Federation subgraphs (link, list, price,
+/// products) that share the Product / ProductList /
+/// Category entities via several nested-path @key directives,
+/// including the list-typed products { id pid } and the deeply nested
+/// products { id pid category { id tag } } selected { id }.
+///
+public sealed class ComplexEntityCallTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => FusionGatewayBuilder.ComposeAsync(
+ (LinkSubgraph.Name, LinkSubgraph.BuildAsync),
+ (ListSubgraph.Name, ListSubgraph.BuildAsync),
+ (PriceSubgraph.Name, PriceSubgraph.BuildAsync),
+ (ProductsSubgraph.Name, ProductsSubgraph.BuildAsync));
+
+ ///
+ /// Verifies the four-subgraph composition accepts the nested and nested-list
+ /// @key directives from the audit fixtures and that a query
+ /// traversing the nested-object Category @key("id tag") pattern and
+ /// the mainProduct cycle back through Product resolves
+ /// correctly.
+ ///
+ [Fact]
+ public Task TopProducts_ResolvesNestedCategoryKey() => RunAsync(
+ query: """
+ query {
+ topProducts {
+ products {
+ id
+ category {
+ mainProduct { id }
+ id
+ tag
+ }
+ }
+ }
+ }
+ """,
+ expectedData: """
+ {
+ "topProducts": {
+ "products": [
+ {
+ "id": "1",
+ "category": {
+ "mainProduct": { "id": "1" },
+ "id": "c1",
+ "tag": "t1"
+ }
+ },
+ {
+ "id": "2",
+ "category": {
+ "mainProduct": { "id": "2" },
+ "id": "c2",
+ "tag": "t2"
+ }
+ }
+ ]
+ }
+ }
+ """);
+
+ ///
+ /// The full audit query. Exercises the nested-list
+ /// ProductList @key("products { id pid }") and deeply nested
+ /// ProductList @key("products { id pid category { id tag } } selected { id }")
+ /// routing through the Fusion planner to populate first,
+ /// selected, pid, and price in a single query.
+ ///
+ ///
+ /// The Apollo Federation composite-schema output for these keys is accepted
+ /// by the Fusion composer (validated by
+ /// ) but the Fusion
+ /// planner currently declines to route through the nested-list lookup
+ /// fields when the originating subgraph does not own pid. Tracking
+ /// the follow-up separately so this PR stays scoped to nested-@key
+ /// composition support.
+ ///
+ [Fact(Skip = "Planner does not yet route through nested-list @key lookups when the originating subgraph lacks sibling key fields. See APOLLO_FEDERATION_COMPLIANCE_PLAN.md follow-up.")]
+ public Task TopProducts_Projects_Across_All_Subgraphs() => RunAsync(
+ query: """
+ query {
+ topProducts {
+ products {
+ id
+ pid
+ price { price }
+ category {
+ mainProduct { id }
+ id
+ tag
+ }
+ }
+ selected { id }
+ first { id }
+ }
+ }
+ """,
+ expectedData: """
+ {
+ "topProducts": {
+ "products": [
+ {
+ "id": "1",
+ "pid": "p1",
+ "price": { "price": 100 },
+ "category": {
+ "mainProduct": { "id": "1" },
+ "id": "c1",
+ "tag": "t1"
+ }
+ },
+ {
+ "id": "2",
+ "pid": "p2",
+ "price": { "price": 200 },
+ "category": {
+ "mainProduct": { "id": "2" },
+ "id": "c2",
+ "tag": "t2"
+ }
+ }
+ ],
+ "selected": { "id": "2" },
+ "first": { "id": "1" }
+ }
+ }
+ """);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/LinkData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/LinkData.cs
new file mode 100644
index 00000000000..c383bb1c6e6
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/LinkData.cs
@@ -0,0 +1,17 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Link;
+
+///
+/// Seed data for the link subgraph, transcribed from
+/// graphql-hive/federation-gateway-audit/src/test-suites/complex-entity-call/data.ts.
+///
+internal static class LinkData
+{
+ public static readonly IReadOnlyList Items =
+ [
+ new Product { Id = "1", Pid = "p1" },
+ new Product { Id = "2", Pid = "p2" }
+ ];
+
+ public static readonly IReadOnlyDictionary ById =
+ Items.ToDictionary(static p => p.Id, StringComparer.Ordinal);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/LinkSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/LinkSubgraph.cs
new file mode 100644
index 00000000000..cca72f8742e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/LinkSubgraph.cs
@@ -0,0 +1,43 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Link;
+
+///
+/// Builds the link Apollo Federation subgraph for the
+/// complex-entity-call audit suite: exposes the Product entity
+/// under both @key(fields: "id") and @key(fields: "id pid").
+///
+public static class LinkSubgraph
+{
+ ///
+ /// The source-schema name by which the gateway addresses this subgraph.
+ ///
+ public const string Name = "link";
+
+ ///
+ /// Starts the subgraph's and returns a
+ /// that routes /graphql requests to the
+ /// in-process pipeline.
+ ///
+ public static async Task BuildAsync()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddRouting()
+ .AddGraphQLServer()
+ .AddApolloFederation()
+ .AddQueryType()
+ .AddType();
+
+ var app = builder.Build();
+ app.MapGraphQL();
+
+ await app.StartAsync();
+
+ return new SubgraphHost(Name, app);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/Product.cs
new file mode 100644
index 00000000000..0a660e46813
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/Product.cs
@@ -0,0 +1,12 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Link;
+
+///
+/// The Product entity as projected by the link subgraph
+/// (@key(fields: "id"), @key(fields: "id pid")).
+///
+public sealed class Product
+{
+ public string Id { get; init; } = default!;
+
+ public string Pid { get; init; } = default!;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/ProductType.cs
new file mode 100644
index 00000000000..0672ffcde8e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/ProductType.cs
@@ -0,0 +1,34 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Link;
+
+///
+/// Apollo Federation descriptor for the Product entity owned by the
+/// link subgraph. Resolves by id and by the composite
+/// id pid key.
+///
+public sealed class ProductType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("id")
+ .ResolveReferenceWith(_ => ResolveById(default!));
+
+ descriptor
+ .Key("id pid")
+ .ResolveReferenceWith(_ => ResolveByIdAndPid(default!, default!));
+
+ descriptor.Field(p => p.Id).Type>();
+ descriptor.Field(p => p.Pid).Type>();
+ }
+
+ private static Product? ResolveById(string id)
+ => LinkData.ById.TryGetValue(id, out var p) ? p : null;
+
+ private static Product? ResolveByIdAndPid(string id, string pid)
+ => LinkData.Items.FirstOrDefault(
+ p => p.Id.Equals(id, StringComparison.Ordinal)
+ && p.Pid.Equals(pid, StringComparison.Ordinal));
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/QueryType.cs
new file mode 100644
index 00000000000..60e60301cab
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Link/QueryType.cs
@@ -0,0 +1,21 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Link;
+
+///
+/// Root Query placeholder for the link subgraph. The subgraph
+/// exposes no user-facing root fields; HotChocolate requires a Query
+/// type so the Apollo Federation interceptor can attach _service and
+/// _entities.
+/// marks this type as extending the federated Query.
+///
+public sealed class QueryType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .ExtendServiceType()
+ .Name(OperationTypeNames.Query);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ListData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ListData.cs
new file mode 100644
index 00000000000..8a5594c9678
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ListData.cs
@@ -0,0 +1,31 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.List;
+
+///
+/// Seed data for the list subgraph, transcribed from
+/// graphql-hive/federation-gateway-audit/src/test-suites/complex-entity-call/data.ts.
+/// The list subgraph returns first = products[0] and
+/// selected = products[1] for any product list it is asked about.
+///
+internal static class ListData
+{
+ public static readonly IReadOnlyList Items =
+ [
+ new Product { Id = "1", Pid = "p1" },
+ new Product { Id = "2", Pid = "p2" }
+ ];
+
+ public static readonly IReadOnlyDictionary<(string Id, string Pid), Product> ByIdAndPid =
+ Items.ToDictionary(static p => (p.Id, p.Pid ?? string.Empty));
+
+ public static Product ResolveIdPid(string id, string? pid)
+ {
+ if (pid is not null
+ && ByIdAndPid.TryGetValue((id, pid), out var exact))
+ {
+ return exact;
+ }
+
+ return Items.FirstOrDefault(p => p.Id.Equals(id, StringComparison.Ordinal))
+ ?? new Product { Id = id, Pid = pid };
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ListSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ListSubgraph.cs
new file mode 100644
index 00000000000..9b0ef36698a
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ListSubgraph.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.List;
+
+///
+/// Builds the list Apollo Federation subgraph for the
+/// complex-entity-call audit suite: owns the shareable first and
+/// selected fields on ProductList and resolves the list entity
+/// by its nested products { id pid } key.
+///
+public static class ListSubgraph
+{
+ ///
+ /// The source-schema name by which the gateway addresses this subgraph.
+ ///
+ public const string Name = "list";
+
+ ///
+ /// Starts the subgraph's and returns a
+ /// that routes /graphql requests to the
+ /// in-process pipeline.
+ ///
+ public static async Task BuildAsync()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddRouting()
+ .AddGraphQLServer()
+ .AddApolloFederation()
+ .AddQueryType()
+ .AddType()
+ .AddType();
+
+ var app = builder.Build();
+ app.MapGraphQL();
+
+ await app.StartAsync();
+
+ return new SubgraphHost(Name, app);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/Product.cs
new file mode 100644
index 00000000000..f05579be1da
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/Product.cs
@@ -0,0 +1,12 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.List;
+
+///
+/// The Product entity as projected by the list subgraph
+/// (@key(fields: "id pid")).
+///
+public sealed class Product
+{
+ public string Id { get; init; } = default!;
+
+ public string? Pid { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductList.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductList.cs
new file mode 100644
index 00000000000..8ca342f2f4b
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductList.cs
@@ -0,0 +1,15 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.List;
+
+///
+/// The ProductList entity as projected by the list subgraph
+/// (@key(fields: "products { id pid }")). Owns the shareable
+/// first and selected fields.
+///
+public sealed class ProductList
+{
+ public IReadOnlyList Products { get; init; } = [];
+
+ public Product? First { get; init; }
+
+ public Product? Selected { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductListType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductListType.cs
new file mode 100644
index 00000000000..5efa67b1415
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductListType.cs
@@ -0,0 +1,39 @@
+using HotChocolate.ApolloFederation.Resolvers;
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.List;
+
+///
+/// Apollo Federation descriptor for the ProductList entity owned by the
+/// list subgraph (@key(fields: "products { id pid }")). Owns the
+/// shareable first and selected fields.
+///
+public sealed class ProductListType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("products { id pid }")
+ .ResolveReferenceWith(_ => ResolveByProducts(default!));
+
+ descriptor.Field(l => l.Products).Type>>>();
+ descriptor.Field(l => l.First).Shareable().Type();
+ descriptor.Field(l => l.Selected).Shareable().Type();
+ }
+
+ private static ProductList ResolveByProducts(
+ [Map("products")] IReadOnlyList products)
+ {
+ var materialized = products
+ .Select(static p => ListData.ResolveIdPid(p.Id, p.Pid))
+ .ToArray();
+
+ return new ProductList
+ {
+ Products = materialized,
+ First = materialized.Length > 0 ? materialized[0] : null,
+ Selected = materialized.Length > 1 ? materialized[^1] : null
+ };
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductType.cs
new file mode 100644
index 00000000000..77549fc1845
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/ProductType.cs
@@ -0,0 +1,24 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.List;
+
+///
+/// Apollo Federation descriptor for the Product entity owned by the
+/// list subgraph (@key(fields: "id pid")).
+///
+public sealed class ProductType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("id pid")
+ .ResolveReferenceWith(_ => ResolveByIdPid(default!, default));
+
+ descriptor.Field(p => p.Id).Type>();
+ descriptor.Field(p => p.Pid).Type();
+ }
+
+ private static Product ResolveByIdPid(string id, string? pid)
+ => ListData.ResolveIdPid(id, pid);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/QueryType.cs
new file mode 100644
index 00000000000..ed1c2ef0830
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/List/QueryType.cs
@@ -0,0 +1,19 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.List;
+
+///
+/// Root Query placeholder for the list subgraph. The subgraph
+/// exposes no user-facing root fields; ExtendServiceType marks this type
+/// as extending the federated Query.
+///
+public sealed class QueryType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .ExtendServiceType()
+ .Name(OperationTypeNames.Query);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Category.cs
new file mode 100644
index 00000000000..2b393339473
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Category.cs
@@ -0,0 +1,12 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// The Category entity as projected by the price subgraph
+/// (@key(fields: "id tag")).
+///
+public sealed class Category
+{
+ public string Id { get; init; } = default!;
+
+ public string? Tag { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/CategoryType.cs
new file mode 100644
index 00000000000..4b1fb46a03f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/CategoryType.cs
@@ -0,0 +1,24 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// Apollo Federation descriptor for the Category entity owned by the
+/// price subgraph (@key(fields: "id tag")).
+///
+public sealed class CategoryType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("id tag")
+ .ResolveReferenceWith(_ => ResolveByIdTag(default!, default));
+
+ descriptor.Field(c => c.Id).Type>();
+ descriptor.Field(c => c.Tag).Type();
+ }
+
+ private static Category ResolveByIdTag(string id, string? tag)
+ => new() { Id = id, Tag = tag };
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Price.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Price.cs
new file mode 100644
index 00000000000..493e75d26ba
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Price.cs
@@ -0,0 +1,9 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// Value type returned by Product.price in the price subgraph.
+///
+public sealed class Price
+{
+ public float Value { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceData.cs
new file mode 100644
index 00000000000..0d63cd95bbb
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceData.cs
@@ -0,0 +1,46 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// Seed data for the price subgraph, transcribed from
+/// graphql-hive/federation-gateway-audit/src/test-suites/complex-entity-call/data.ts.
+///
+internal static class PriceData
+{
+ public static readonly IReadOnlyList Items =
+ [
+ new Product
+ {
+ Id = "1",
+ Pid = "p1",
+ Category = new Category { Id = "c1", Tag = "t1" },
+ Price = new Price { Value = 100 }
+ },
+ new Product
+ {
+ Id = "2",
+ Pid = "p2",
+ Category = new Category { Id = "c2", Tag = "t2" },
+ Price = new Price { Value = 200 }
+ }
+ ];
+
+ public static readonly IReadOnlyDictionary ById =
+ Items.ToDictionary(static p => p.Id, StringComparer.Ordinal);
+
+ public static Product ResolveByKey(string id, string? pid, string? categoryId, string? categoryTag)
+ {
+ if (ById.TryGetValue(id, out var exact))
+ {
+ return exact;
+ }
+
+ return new Product
+ {
+ Id = id,
+ Pid = pid,
+ Category = categoryId is null
+ ? null
+ : new Category { Id = categoryId, Tag = categoryTag }
+ };
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceSubgraph.cs
new file mode 100644
index 00000000000..2696e471092
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceSubgraph.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// Builds the price Apollo Federation subgraph for the
+/// complex-entity-call audit suite. The subgraph owns
+/// Product.price, the deeply nested ProductList key, and
+/// Category @key("id tag").
+///
+public static class PriceSubgraph
+{
+ ///
+ /// The source-schema name by which the gateway addresses this subgraph.
+ ///
+ public const string Name = "price";
+
+ ///
+ /// Starts the subgraph's and returns a
+ /// that routes /graphql requests to the
+ /// in-process pipeline.
+ ///
+ public static async Task BuildAsync()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddRouting()
+ .AddGraphQLServer()
+ .AddApolloFederation()
+ .AddQueryType()
+ .AddType()
+ .AddType()
+ .AddType()
+ .AddType();
+
+ var app = builder.Build();
+ app.MapGraphQL();
+
+ await app.StartAsync();
+
+ return new SubgraphHost(Name, app);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceType.cs
new file mode 100644
index 00000000000..bc8cebf3678
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/PriceType.cs
@@ -0,0 +1,19 @@
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// GraphQL type for the Price value returned by Product.price.
+///
+public sealed class PriceType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor.Name("Price");
+ descriptor.Field("price")
+ .Type>()
+ .Resolve(ctx => ctx.Parent().Value);
+
+ descriptor.Ignore(p => p.Value);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Product.cs
new file mode 100644
index 00000000000..8610921f7da
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/Product.cs
@@ -0,0 +1,16 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// The Product entity as projected by the price subgraph
+/// (@key(fields: "id pid category { id tag }")).
+///
+public sealed class Product
+{
+ public string Id { get; init; } = default!;
+
+ public string? Pid { get; init; }
+
+ public Category? Category { get; init; }
+
+ public Price? Price { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductList.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductList.cs
new file mode 100644
index 00000000000..95fe08e95e8
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductList.cs
@@ -0,0 +1,15 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// The ProductList entity as projected by the price subgraph
+/// (@key(fields: "products { id pid category { id tag } } selected { id }")).
+/// Owns the shareable first and selected fields.
+///
+public sealed class ProductList
+{
+ public IReadOnlyList Products { get; init; } = [];
+
+ public Product? First { get; init; }
+
+ public Product? Selected { get; init; }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductListType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductListType.cs
new file mode 100644
index 00000000000..2b4c79cb3b9
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductListType.cs
@@ -0,0 +1,50 @@
+using HotChocolate.ApolloFederation.Resolvers;
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// Apollo Federation descriptor for the ProductList entity owned by the
+/// price subgraph
+/// (@key(fields: "products { id pid category { id tag } } selected { id }")).
+/// The key resolver reconstructs the list plus the selected product from
+/// the composite key payload.
+///
+public sealed class ProductListType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("products { id pid category { id tag } } selected { id }")
+ .ResolveReferenceWith(_ => ResolveByKey(default!, default));
+
+ descriptor.Field(l => l.Products).Type>>>();
+ descriptor.Field(l => l.First).Shareable().Type();
+ descriptor.Field(l => l.Selected).Shareable().Type();
+ }
+
+ private static ProductList ResolveByKey(
+ [Map("products")] IReadOnlyList products,
+ [Map("selected.id")] string? selectedId)
+ {
+ var materialized = products
+ .Select(static p =>
+ PriceData.ResolveByKey(
+ p.Id,
+ p.Pid,
+ p.Category?.Id,
+ p.Category?.Tag))
+ .ToArray();
+
+ return new ProductList
+ {
+ Products = materialized,
+ First = materialized.Length > 0 ? materialized[0] : null,
+ Selected = selectedId is null
+ ? null
+ : materialized.FirstOrDefault(
+ p => p.Id.Equals(selectedId, StringComparison.Ordinal))
+ };
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductType.cs
new file mode 100644
index 00000000000..223319f2629
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/ProductType.cs
@@ -0,0 +1,34 @@
+using HotChocolate.ApolloFederation.Resolvers;
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// Apollo Federation descriptor for the Product entity owned by the
+/// price subgraph
+/// (@key(fields: "id pid category { id tag }")). The key resolver
+/// extracts the nested category.id / category.tag fields via
+/// .
+///
+public sealed class ProductType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("id pid category { id tag }")
+ .ResolveReferenceWith(_ => ResolveByKey(default!, default, default, default));
+
+ descriptor.Field(p => p.Id).Type>();
+ descriptor.Field(p => p.Pid).Type();
+ descriptor.Field(p => p.Category).Type();
+ descriptor.Field(p => p.Price).Type();
+ }
+
+ private static Product ResolveByKey(
+ string id,
+ [Map("pid")] string? pid,
+ [Map("category.id")] string? categoryId,
+ [Map("category.tag")] string? categoryTag)
+ => PriceData.ResolveByKey(id, pid, categoryId, categoryTag);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/QueryType.cs
new file mode 100644
index 00000000000..bb616a0f27e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Price/QueryType.cs
@@ -0,0 +1,19 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Price;
+
+///
+/// Root Query placeholder for the price subgraph. The subgraph
+/// exposes no user-facing root fields; ExtendServiceType marks this type
+/// as extending the federated Query.
+///
+public sealed class QueryType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .ExtendServiceType()
+ .Name(OperationTypeNames.Query);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/Category.cs
new file mode 100644
index 00000000000..468dfe7ca6f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/Category.cs
@@ -0,0 +1,14 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// The Category entity as projected by the products subgraph
+/// (@key(fields: "id")).
+///
+public sealed class Category
+{
+ public string Id { get; init; } = default!;
+
+ public string? Tag { get; init; }
+
+ public string MainProductId { get; init; } = default!;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/CategoryType.cs
new file mode 100644
index 00000000000..b145069a1e0
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/CategoryType.cs
@@ -0,0 +1,37 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// Apollo Federation descriptor for the Category entity owned by the
+/// products subgraph (@key(fields: "id")).
+///
+public sealed class CategoryType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("id")
+ .ResolveReferenceWith(_ => ResolveById(default!));
+
+ descriptor.Field(c => c.Id).Type>();
+ descriptor.Field(c => c.Tag).Shareable().Type();
+
+ descriptor.Field("mainProduct")
+ .Type>()
+ .Shareable()
+ .Resolve(ctx =>
+ {
+ var category = ctx.Parent();
+ return ProductsData.ById.TryGetValue(category.MainProductId, out var product)
+ ? product
+ : null;
+ });
+
+ descriptor.Ignore(c => c.MainProductId);
+ }
+
+ private static Category? ResolveById(string id)
+ => ProductsData.CategoriesById.TryGetValue(id, out var c) ? c : null;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/Product.cs
new file mode 100644
index 00000000000..d0cc1103902
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/Product.cs
@@ -0,0 +1,12 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// The Product entity as projected by the products subgraph
+/// (@extends @key(fields: "id"), id external, owns category).
+///
+public sealed class Product
+{
+ public string Id { get; init; } = default!;
+
+ public string CategoryId { get; init; } = default!;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductList.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductList.cs
new file mode 100644
index 00000000000..3c1c308f8f1
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductList.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// The ProductList entity as projected by the products subgraph
+/// (@key(fields: "products { id }")).
+///
+public sealed class ProductList
+{
+ public IReadOnlyList Products { get; init; } = [];
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductListType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductListType.cs
new file mode 100644
index 00000000000..a03b5b03f2e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductListType.cs
@@ -0,0 +1,30 @@
+using HotChocolate.ApolloFederation.Resolvers;
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Language;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// Apollo Federation descriptor for the ProductList entity owned by the
+/// products subgraph (@key(fields: "products { id }")).
+///
+public sealed class ProductListType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("products { id }")
+ .ResolveReferenceWith(_ => ResolveByProducts(default!));
+
+ descriptor.Field(l => l.Products).Type>>>();
+ }
+
+ private static ProductList ResolveByProducts([Map("products")] IReadOnlyList products)
+ => new() { Products = [.. products.Select(p => ResolveProduct(p.Id))] };
+
+ private static Product ResolveProduct(string id)
+ => ProductsData.ById.TryGetValue(id, out var product)
+ ? product
+ : new Product { Id = id, CategoryId = string.Empty };
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductType.cs
new file mode 100644
index 00000000000..742fe6a94d8
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductType.cs
@@ -0,0 +1,47 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// Apollo Federation descriptor for the Product entity in the
+/// products subgraph. The subgraph extends the federated Product
+/// type (@extends in the audit SDL) by adding a local category
+/// field while keeping the id field external.
+///
+public sealed class ProductType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .ExtendServiceType()
+ .Key("id")
+ .ResolveReferenceWith(_ => ResolveById(default!));
+
+ // Apollo Federation would mark this as '@external' (the field is
+ // owned by the link/list/price subgraphs). In the Fusion composite
+ // schema we model the same intent with '@shareable': the field
+ // travels with Product instances returned from this subgraph's root
+ // queries so the planner can use it as the entity-routing key.
+ descriptor.Field(p => p.Id)
+ .Shareable()
+ .Type>();
+
+ descriptor.Field("category")
+ .Type()
+ .Shareable()
+ .Resolve(ctx =>
+ {
+ var product = ctx.Parent();
+ return ProductsData.CategoriesById.TryGetValue(product.CategoryId, out var category)
+ ? category
+ : null;
+ });
+
+ // Ignore the CategoryId property: it is not part of the public schema.
+ descriptor.Ignore(p => p.CategoryId);
+ }
+
+ private static Product? ResolveById(string id)
+ => ProductsData.ById.TryGetValue(id, out var p) ? p : null;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductsData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductsData.cs
new file mode 100644
index 00000000000..630a089775b
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductsData.cs
@@ -0,0 +1,26 @@
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// Seed data for the products subgraph, transcribed from
+/// graphql-hive/federation-gateway-audit/src/test-suites/complex-entity-call/data.ts.
+///
+internal static class ProductsData
+{
+ public static readonly IReadOnlyList Items =
+ [
+ new Product { Id = "1", CategoryId = "c1" },
+ new Product { Id = "2", CategoryId = "c2" }
+ ];
+
+ public static readonly IReadOnlyDictionary ById =
+ Items.ToDictionary(static p => p.Id, StringComparer.Ordinal);
+
+ public static readonly IReadOnlyList Categories =
+ [
+ new Category { Id = "c1", Tag = "t1", MainProductId = "1" },
+ new Category { Id = "c2", Tag = "t2", MainProductId = "2" }
+ ];
+
+ public static readonly IReadOnlyDictionary CategoriesById =
+ Categories.ToDictionary(static c => c.Id, StringComparer.Ordinal);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductsSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductsSubgraph.cs
new file mode 100644
index 00000000000..5c0fc527f98
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/ProductsSubgraph.cs
@@ -0,0 +1,46 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// Builds the products Apollo Federation subgraph for the
+/// complex-entity-call audit suite: exposes Query.topProducts and
+/// owns the Category entity plus the Product.category and
+/// ProductList keys.
+///
+public static class ProductsSubgraph
+{
+ ///
+ /// The source-schema name by which the gateway addresses this subgraph.
+ ///
+ public const string Name = "products";
+
+ ///
+ /// Starts the subgraph's and returns a
+ /// that routes /graphql requests to the
+ /// in-process pipeline.
+ ///
+ public static async Task BuildAsync()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddRouting()
+ .AddGraphQLServer()
+ .AddApolloFederation()
+ .AddQueryType()
+ .AddType()
+ .AddType()
+ .AddType();
+
+ var app = builder.Build();
+ app.MapGraphQL();
+
+ await app.StartAsync();
+
+ return new SubgraphHost(Name, app);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/QueryType.cs
new file mode 100644
index 00000000000..cf4f2611cf6
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Products/QueryType.cs
@@ -0,0 +1,19 @@
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.ComplexEntityCall.Products;
+
+///
+/// Root Query for the products subgraph. Exposes
+/// topProducts: ProductList! returning the full seeded list.
+///
+public sealed class QueryType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor.Name(OperationTypeNames.Query);
+ descriptor
+ .Field("topProducts")
+ .Type>()
+ .Resolve(_ => new ProductList { Products = ProductsData.Items });
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/data.json
new file mode 100644
index 00000000000..c6eb5ea4a02
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/data.json
@@ -0,0 +1,28 @@
+{
+ "products": [
+ {
+ "id": "1",
+ "pid": "p1",
+ "categoryId": "c1",
+ "price": 100
+ },
+ {
+ "id": "2",
+ "pid": "p2",
+ "categoryId": "c2",
+ "price": 200
+ }
+ ],
+ "categories": [
+ {
+ "id": "c1",
+ "tag": "t1",
+ "mainProduct": "1"
+ },
+ {
+ "id": "c2",
+ "tag": "t2",
+ "mainProduct": "2"
+ }
+ ]
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/link.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/link.graphql
new file mode 100644
index 00000000000..0d4d72dbfdb
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/link.graphql
@@ -0,0 +1,7 @@
+extend schema
+ @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
+
+type Product @key(fields: "id") @key(fields: "id pid") {
+ id: String!
+ pid: String!
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/list.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/list.graphql
new file mode 100644
index 00000000000..e5a1ff5df5a
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/list.graphql
@@ -0,0 +1,16 @@
+extend schema
+ @link(
+ url: "https://specs.apollo.dev/federation/v2.0"
+ import: ["@key", "@shareable"]
+ )
+
+type ProductList @key(fields: "products{id pid}") {
+ products: [Product!]!
+ first: Product @shareable
+ selected: Product @shareable
+}
+
+type Product @key(fields: "id pid") {
+ id: String!
+ pid: String
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/price.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/price.graphql
new file mode 100644
index 00000000000..8313e063c2f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/price.graphql
@@ -0,0 +1,28 @@
+extend schema
+ @link(
+ url: "https://specs.apollo.dev/federation/v2.0"
+ import: ["@key", "@shareable"]
+ )
+
+type ProductList
+ @key(fields: "products{id pid category{id tag}} selected{id}") {
+ products: [Product!]!
+ first: Product @shareable
+ selected: Product @shareable
+}
+
+type Product @key(fields: "id pid category{id tag}") {
+ id: String!
+ price: Price
+ pid: String
+ category: Category
+}
+
+type Category @key(fields: "id tag") {
+ id: String!
+ tag: String
+}
+
+type Price {
+ price: Float!
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/products.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/products.graphql
new file mode 100644
index 00000000000..da181da1a13
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/subgraphs/products.graphql
@@ -0,0 +1,24 @@
+extend schema
+ @link(
+ url: "https://specs.apollo.dev/federation/v2.0"
+ import: ["@key", "@external", "@extends", "@shareable"]
+ )
+
+type Query {
+ topProducts: ProductList!
+}
+
+type ProductList @key(fields: "products{id}") {
+ products: [Product!]!
+}
+
+type Product @extends @key(fields: "id") {
+ id: String! @external
+ category: Category @shareable
+}
+
+type Category @key(fields: "id") {
+ mainProduct: Product! @shareable
+ id: String!
+ tag: String @shareable
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/tests.json
new file mode 100644
index 00000000000..a12deecf513
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ComplexEntityCall/Reference/tests.json
@@ -0,0 +1,35 @@
+[
+ {
+ "query": "query { topProducts { products { id pid price { price } category { mainProduct { id } id tag } } selected { id } first { id } } }",
+ "expected": {
+ "data": {
+ "topProducts": {
+ "products": [
+ {
+ "id": "1",
+ "pid": "p1",
+ "price": { "price": 100 },
+ "category": {
+ "mainProduct": { "id": "1" },
+ "id": "c1",
+ "tag": "t1"
+ }
+ },
+ {
+ "id": "2",
+ "pid": "p2",
+ "price": { "price": 200 },
+ "category": {
+ "mainProduct": { "id": "2" },
+ "id": "c2",
+ "tag": "t2"
+ }
+ }
+ ],
+ "selected": { "id": "2" },
+ "first": { "id": "1" }
+ }
+ }
+ }
+ }
+]
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/CorruptedSupergraphNodeIdTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/CorruptedSupergraphNodeIdTests.cs
new file mode 100644
index 00000000000..2a47c31f646
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/CorruptedSupergraphNodeId/CorruptedSupergraphNodeIdTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class CorruptedSupergraphNodeIdTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: abstract-type / edge-case coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/EnumIntersectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/EnumIntersectionTests.cs
new file mode 100644
index 00000000000..a2b451520e2
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/EnumIntersectionTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class EnumIntersectionTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtends/Fed1ExternalExtendsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtends/Fed1ExternalExtendsTests.cs
new file mode 100644
index 00000000000..866d5d24c4e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtends/Fed1ExternalExtendsTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class Fed1ExternalExtendsTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: Federation v1 (no @link).")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtendsResolvable/Fed1ExternalExtendsResolvableTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtendsResolvable/Fed1ExternalExtendsResolvableTests.cs
new file mode 100644
index 00000000000..19c80d5eabf
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtendsResolvable/Fed1ExternalExtendsResolvableTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class Fed1ExternalExtendsResolvableTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: Federation v1 (no @link).")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtension/Fed1ExternalExtensionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtension/Fed1ExternalExtensionTests.cs
new file mode 100644
index 00000000000..6d5d8abbf4d
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed1ExternalExtension/Fed1ExternalExtensionTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class Fed1ExternalExtensionTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: Federation v1 (no @link).")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Fed2ExternalExtendsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Fed2ExternalExtendsTests.cs
new file mode 100644
index 00000000000..3ec6c24421a
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Fed2ExternalExtendsTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class Fed2ExternalExtendsTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Fed2ExternalExtensionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Fed2ExternalExtensionTests.cs
new file mode 100644
index 00000000000..fb75187b9cc
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Fed2ExternalExtensionTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class Fed2ExternalExtensionTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/IncludeSkipTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/IncludeSkipTests.cs
new file mode 100644
index 00000000000..c6f7a010b76
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/IncludeSkipTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class IncludeSkipTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/InputObjectIntersectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/InputObjectIntersectionTests.cs
new file mode 100644
index 00000000000..f08e944b231
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/InputObjectIntersectionTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class InputObjectIntersectionTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectIndirectExtension/InterfaceObjectIndirectExtensionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectIndirectExtension/InterfaceObjectIndirectExtensionTests.cs
new file mode 100644
index 00000000000..ac517b93cac
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectIndirectExtension/InterfaceObjectIndirectExtensionTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class InterfaceObjectIndirectExtensionTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: @interfaceObject rejected by composition.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/InterfaceObjectWithRequiresTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/InterfaceObjectWithRequiresTests.cs
new file mode 100644
index 00000000000..0db357b6bc9
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InterfaceObjectWithRequires/InterfaceObjectWithRequiresTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class InterfaceObjectWithRequiresTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: @interfaceObject rejected by composition.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/KeysMashupTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/KeysMashupTests.cs
new file mode 100644
index 00000000000..569498ccd2e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/KeysMashupTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class KeysMashupTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/MutationsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/MutationsTests.cs
new file mode 100644
index 00000000000..8d859dfe615
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/MutationsTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class MutationsTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/MysteriousExternal/MysteriousExternalTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/MysteriousExternal/MysteriousExternalTests.cs
new file mode 100644
index 00000000000..badb32a5b95
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/MysteriousExternal/MysteriousExternalTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class MysteriousExternalTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: abstract-type / edge-case coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NestedProvides/NestedProvidesTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NestedProvides/NestedProvidesTests.cs
new file mode 100644
index 00000000000..5ddeacbd02c
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NestedProvides/NestedProvidesTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class NestedProvidesTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTests.cs
new file mode 100644
index 00000000000..3a118913fa6
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class NodeTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/NonResolvableInterfaceObjectTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/NonResolvableInterfaceObjectTests.cs
new file mode 100644
index 00000000000..191796148e1
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NonResolvableInterfaceObject/NonResolvableInterfaceObjectTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class NonResolvableInterfaceObjectTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: @interfaceObject rejected by composition.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/NullKeysTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/NullKeysTests.cs
new file mode 100644
index 00000000000..74514cc4bad
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/NullKeysTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class NullKeysTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/OverrideTypeInterface/OverrideTypeInterfaceTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/OverrideTypeInterface/OverrideTypeInterfaceTests.cs
new file mode 100644
index 00000000000..2f5c758f80f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/OverrideTypeInterface/OverrideTypeInterfaceTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class OverrideTypeInterfaceTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: @override not yet handled by composition.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/OverrideWithRequires/OverrideWithRequiresTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/OverrideWithRequires/OverrideWithRequiresTests.cs
new file mode 100644
index 00000000000..f39b10f32b9
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/OverrideWithRequires/OverrideWithRequiresTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class OverrideWithRequiresTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: @override not yet handled by composition.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/ParentEntityCallTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/ParentEntityCallTests.cs
new file mode 100644
index 00000000000..e233415a9ab
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/ParentEntityCallTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class ParentEntityCallTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/ParentEntityCallComplexTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/ParentEntityCallComplexTests.cs
new file mode 100644
index 00000000000..c6ae438f4fa
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/ParentEntityCallComplexTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class ParentEntityCallComplexTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ProvidesOnInterface/ProvidesOnInterfaceTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ProvidesOnInterface/ProvidesOnInterfaceTests.cs
new file mode 100644
index 00000000000..e39ff0b2042
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ProvidesOnInterface/ProvidesOnInterfaceTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class ProvidesOnInterfaceTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ProvidesOnUnion/ProvidesOnUnionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ProvidesOnUnion/ProvidesOnUnionTests.cs
new file mode 100644
index 00000000000..78c55d909e4
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ProvidesOnUnion/ProvidesOnUnionTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class ProvidesOnUnionTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresCircular/RequiresCircularTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresCircular/RequiresCircularTests.cs
new file mode 100644
index 00000000000..2d208f75003
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresCircular/RequiresCircularTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class RequiresCircularTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresInterface/RequiresInterfaceTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresInterface/RequiresInterfaceTests.cs
new file mode 100644
index 00000000000..d14352ebbda
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresInterface/RequiresInterfaceTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class RequiresInterfaceTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresRequires/RequiresRequiresTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresRequires/RequiresRequiresTests.cs
new file mode 100644
index 00000000000..c82713e1245
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresRequires/RequiresRequiresTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class RequiresRequiresTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithArgument/RequiresWithArgumentTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithArgument/RequiresWithArgumentTests.cs
new file mode 100644
index 00000000000..62124393048
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithArgument/RequiresWithArgumentTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class RequiresWithArgumentTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithArgumentConflict/RequiresWithArgumentConflictTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithArgumentConflict/RequiresWithArgumentConflictTests.cs
new file mode 100644
index 00000000000..4922a665fe9
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithArgumentConflict/RequiresWithArgumentConflictTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class RequiresWithArgumentConflictTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithFragments/RequiresWithFragmentsTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithFragments/RequiresWithFragmentsTests.cs
new file mode 100644
index 00000000000..f7b5153d7d6
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/RequiresWithFragments/RequiresWithFragmentsTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class RequiresWithFragmentsTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/SharedRootTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/SharedRootTests.cs
new file mode 100644
index 00000000000..3a0f78f109a
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/SharedRootTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class SharedRootTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/EmailData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/EmailData.cs
new file mode 100644
index 00000000000..59b9d16135b
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/EmailData.cs
@@ -0,0 +1,23 @@
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Email;
+
+///
+/// Seed data for the email subgraph, transcribed from
+/// graphql-hive/federation-gateway-audit/src/test-suites/simple-entity-call/data.ts.
+///
+internal static class EmailData
+{
+ ///
+ /// The seeded entities, ordered by id.
+ ///
+ public static readonly IReadOnlyList Users =
+ [
+ new User("1", "user1@gmail.com"),
+ new User("2", "user2@gmail.com")
+ ];
+
+ ///
+ /// The seeded entities indexed by their id field.
+ ///
+ public static readonly IReadOnlyDictionary ById =
+ Users.ToDictionary(static u => u.Id, StringComparer.Ordinal);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/EmailSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/EmailSubgraph.cs
new file mode 100644
index 00000000000..3a6b8811ef3
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/EmailSubgraph.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Email;
+
+///
+/// Builds the email Apollo Federation subgraph:
+/// type User @key(fields: "id") { id: ID!, email: String! }
+/// plus a root Query.user: User. The subgraph runs in-process under
+/// so the gateway dispatches real HTTP requests through
+/// the full ASP.NET Core / HotChocolate pipeline.
+///
+public static class EmailSubgraph
+{
+ ///
+ /// The source-schema name by which the gateway addresses this subgraph.
+ ///
+ public const string Name = "email";
+
+ ///
+ /// Starts the subgraph's and returns a
+ /// that routes /graphql requests to the
+ /// in-process pipeline.
+ ///
+ public static async Task BuildAsync()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddRouting()
+ .AddGraphQLServer()
+ .AddApolloFederation()
+ .AddQueryType()
+ .AddType();
+
+ var app = builder.Build();
+ app.MapGraphQL();
+
+ await app.StartAsync();
+
+ return new SubgraphHost(Name, app);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/QueryType.cs
new file mode 100644
index 00000000000..a2029dad187
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/QueryType.cs
@@ -0,0 +1,19 @@
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Email;
+
+///
+/// Root Query type for the email subgraph. Exposes a single
+/// user: User field that returns the first seeded user.
+///
+public sealed class QueryType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor.Name(OperationTypeNames.Query);
+ descriptor
+ .Field("user")
+ .Type()
+ .Resolve(_ => EmailData.Users[0]);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/User.cs
new file mode 100644
index 00000000000..e609ff33884
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/User.cs
@@ -0,0 +1,7 @@
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Email;
+
+///
+/// The User entity as projected by the email subgraph
+/// (@key(fields: "id")).
+///
+public sealed record User(string Id, string Email);
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/UserType.cs
new file mode 100644
index 00000000000..67eba291c09
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Email/UserType.cs
@@ -0,0 +1,24 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Email;
+
+///
+/// Apollo Federation descriptor for the User entity owned by the email
+/// subgraph. Mirrors the fluent descriptor pattern used by
+/// HotChocolate.ApolloFederation.CertificationSchema.CodeFirst.Types.UserType.
+///
+public sealed class UserType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Key("id")
+ .ResolveReferenceWith(_ => ResolveById(default!));
+
+ descriptor.Field(u => u.Id).Type>();
+ descriptor.Field(u => u.Email).Type>();
+ }
+
+ private static User ResolveById(string id) => EmailData.ById[id];
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/NicknameData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/NicknameData.cs
new file mode 100644
index 00000000000..ba29dc4dcc2
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/NicknameData.cs
@@ -0,0 +1,23 @@
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Nickname;
+
+///
+/// Seed data for the nickname subgraph, transcribed from
+/// graphql-hive/federation-gateway-audit/src/test-suites/simple-entity-call/data.ts.
+///
+internal static class NicknameData
+{
+ ///
+ /// The seeded entities, ordered by email.
+ ///
+ public static readonly IReadOnlyList Users =
+ [
+ new User("user1@gmail.com", "user1"),
+ new User("user2@gmail.com", "user2")
+ ];
+
+ ///
+ /// The seeded entities indexed by their email field.
+ ///
+ public static readonly IReadOnlyDictionary ByEmail =
+ Users.ToDictionary(static u => u.Email, StringComparer.Ordinal);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/NicknameSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/NicknameSubgraph.cs
new file mode 100644
index 00000000000..254b89abfb4
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/NicknameSubgraph.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Nickname;
+
+///
+/// Builds the nickname Apollo Federation subgraph:
+/// extend type User @key(fields: "email") { email: String! @external, nickname: String! }.
+/// The subgraph has no user-facing root query fields; Apollo Federation still
+/// exposes _service { sdl } and _entities automatically. The
+/// subgraph runs in-process under so the gateway
+/// dispatches real HTTP requests through the full ASP.NET Core / HotChocolate
+/// pipeline.
+///
+public static class NicknameSubgraph
+{
+ ///
+ /// The source-schema name by which the gateway addresses this subgraph.
+ ///
+ public const string Name = "nickname";
+
+ ///
+ /// Starts the subgraph's and returns a
+ /// that routes /graphql requests to the
+ /// in-process pipeline.
+ ///
+ public static async Task BuildAsync()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ builder.Services
+ .AddRouting()
+ .AddGraphQLServer()
+ .AddApolloFederation()
+ .AddQueryType()
+ .AddType();
+
+ var app = builder.Build();
+ app.MapGraphQL();
+
+ await app.StartAsync();
+
+ return new SubgraphHost(Name, app);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/QueryType.cs
new file mode 100644
index 00000000000..706f94aafd3
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/QueryType.cs
@@ -0,0 +1,23 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Nickname;
+
+///
+/// Root Query placeholder for the nickname subgraph. The subgraph's
+/// SDL declares no user-facing query fields, but HotChocolate requires a Query type
+/// to exist so the Apollo Federation type interceptor can attach
+/// _service { sdl } and _entities(...) fields. Applying
+///
+/// marks this type as extending the federated Query, so composition treats it
+/// as a pure entity provider.
+///
+public sealed class QueryType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .ExtendServiceType()
+ .Name(OperationTypeNames.Query);
+ }
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/User.cs
new file mode 100644
index 00000000000..3f950b8eee4
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/User.cs
@@ -0,0 +1,7 @@
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Nickname;
+
+///
+/// The User entity as projected by the nickname subgraph
+/// (@key(fields: "email"), email is external).
+///
+public sealed record User(string Email, string Nickname);
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/UserType.cs
new file mode 100644
index 00000000000..c663e31f205
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Nickname/UserType.cs
@@ -0,0 +1,25 @@
+using HotChocolate.ApolloFederation.Types;
+using HotChocolate.Types;
+
+namespace HotChocolate.Fusion.Suites.SimpleEntityCall.Nickname;
+
+///
+/// Apollo Federation descriptor for the User entity as extended by the
+/// nickname subgraph. Mirrors
+/// HotChocolate.ApolloFederation.CertificationSchema.CodeFirst.Types.UserType.
+///
+public sealed class UserType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .ExtendServiceType()
+ .Key("email")
+ .ResolveReferenceWith(_ => ResolveByEmail(default!));
+
+ descriptor.Field(u => u.Email).External().Type>();
+ descriptor.Field(u => u.Nickname).Type>();
+ }
+
+ private static User ResolveByEmail(string email) => NicknameData.ByEmail[email];
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/data.json
new file mode 100644
index 00000000000..05399f5ce79
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/data.json
@@ -0,0 +1,14 @@
+{
+ "users": [
+ {
+ "id": "1",
+ "email": "user1@gmail.com",
+ "nickname": "user1"
+ },
+ {
+ "id": "2",
+ "email": "user2@gmail.com",
+ "nickname": "user2"
+ }
+ ]
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/email.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/email.graphql
new file mode 100644
index 00000000000..d847044d35e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/email.graphql
@@ -0,0 +1,11 @@
+extend schema
+ @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
+
+type Query {
+ user: User
+}
+
+type User @key(fields: "id") {
+ id: ID!
+ email: String!
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/nickname.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/nickname.graphql
new file mode 100644
index 00000000000..4e5609c8aed
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/nickname.graphql
@@ -0,0 +1,10 @@
+extend schema
+ @link(
+ url: "https://specs.apollo.dev/federation/v2.0"
+ import: ["@key", "@external"]
+ )
+
+type User @key(fields: "email") {
+ email: String! @external
+ nickname: String!
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/tests.json
new file mode 100644
index 00000000000..104a43ef470
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/Reference/tests.json
@@ -0,0 +1,13 @@
+[
+ {
+ "query": "query {\n user {\n id\n nickname\n }\n}\n",
+ "expected": {
+ "data": {
+ "user": {
+ "id": "1",
+ "nickname": "user1"
+ }
+ }
+ }
+ }
+]
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/SimpleEntityCallTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/SimpleEntityCallTests.cs
new file mode 100644
index 00000000000..b5967bfa957
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleEntityCall/SimpleEntityCallTests.cs
@@ -0,0 +1,32 @@
+using HotChocolate.Fusion.Suites.SimpleEntityCall.Email;
+using HotChocolate.Fusion.Suites.SimpleEntityCall.Nickname;
+
+namespace HotChocolate.Fusion.Suites;
+
+///
+/// Port of the simple-entity-call suite from
+/// graphql-hive/federation-gateway-audit. The gateway composes an
+/// email subgraph (which owns User.id and User.email) and a
+/// nickname subgraph (which extends User by email and owns
+/// User.nickname). A single query touches both subgraphs via an entity call.
+///
+public sealed class SimpleEntityCallTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => FusionGatewayBuilder.ComposeAsync(
+ (EmailSubgraph.Name, EmailSubgraph.BuildAsync),
+ (NicknameSubgraph.Name, NicknameSubgraph.BuildAsync));
+
+ [Fact]
+ public Task User_Nickname() => RunAsync(
+ query: """
+ {
+ user { id nickname }
+ }
+ """,
+ expectedData: """
+ {
+ "user": { "id": "1", "nickname": "user1" }
+ }
+ """);
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/SimpleInaccessibleTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/SimpleInaccessibleTests.cs
new file mode 100644
index 00000000000..b8367544067
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInaccessible/SimpleInaccessibleTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class SimpleInaccessibleTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @inaccessible currently silently dropped.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInterfaceObject/SimpleInterfaceObjectTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInterfaceObject/SimpleInterfaceObjectTests.cs
new file mode 100644
index 00000000000..a71a938024e
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleInterfaceObject/SimpleInterfaceObjectTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class SimpleInterfaceObjectTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: @interfaceObject rejected by composition.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleOverride/SimpleOverrideTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleOverride/SimpleOverrideTests.cs
new file mode 100644
index 00000000000..3c632ef46d6
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleOverride/SimpleOverrideTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class SimpleOverrideTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: @override not yet handled by composition.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/SimpleRequiresProvidesTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/SimpleRequiresProvidesTests.cs
new file mode 100644
index 00000000000..81090c15a0f
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/SimpleRequiresProvidesTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class SimpleRequiresProvidesTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: @requires/@provides coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/TypenameTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/TypenameTests.cs
new file mode 100644
index 00000000000..98b0c990b49
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/TypenameTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class TypenameTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: subgraph harness.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnavailableOverride/UnavailableOverrideTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnavailableOverride/UnavailableOverrideTests.cs
new file mode 100644
index 00000000000..a4f640663a4
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnavailableOverride/UnavailableOverrideTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class UnavailableOverrideTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Unsupported: @override not yet handled by composition.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/UnionInterfaceDistributedTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/UnionInterfaceDistributedTests.cs
new file mode 100644
index 00000000000..343acef5866
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionInterfaceDistributed/UnionInterfaceDistributedTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class UnionInterfaceDistributedTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: abstract-type / edge-case coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionIntersection/UnionIntersectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionIntersection/UnionIntersectionTests.cs
new file mode 100644
index 00000000000..7887c46b9e0
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/UnionIntersection/UnionIntersectionTests.cs
@@ -0,0 +1,10 @@
+namespace HotChocolate.Fusion.Suites;
+
+public sealed class UnionIntersectionTests : ComplianceTestBase
+{
+ protected override Task BuildGatewayAsync()
+ => throw new NotImplementedException("Subgraphs not yet wired for this suite.");
+
+ [Fact(Skip = "Pending: abstract-type / edge-case coverage.")]
+ public Task Pending() => Task.CompletedTask;
+}
diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs
new file mode 100644
index 00000000000..6c5df169a5a
--- /dev/null
+++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs
@@ -0,0 +1,543 @@
+using System.Collections.Immutable;
+using HotChocolate.Fusion.Execution.Clients;
+using HotChocolate.Language;
+
+namespace HotChocolate.Fusion;
+
+public class ApolloFederationConnectorTests
+{
+ [Fact]
+ public void Configuration_Should_StoreProperties()
+ {
+ // arrange
+ var baseAddress = new Uri("http://localhost:5000/graphql");
+ var lookups = new Dictionary();
+
+ // act
+ var config = new ApolloFederationSourceSchemaClientConfiguration(
+ "products",
+ "products-http",
+ baseAddress,
+ lookups,
+ SupportedOperationType.Query);
+
+ // assert
+ Assert.Equal("products", config.Name);
+ Assert.Equal("products-http", config.HttpClientName);
+ Assert.Same(baseAddress, config.BaseAddress);
+ Assert.Same(lookups, config.Lookups);
+ Assert.Equal(SupportedOperationType.Query, config.SupportedOperations);
+ }
+
+ [Fact]
+ public void Configuration_Should_DefaultToQueryAndMutation()
+ {
+ // arrange & act
+ var config = new ApolloFederationSourceSchemaClientConfiguration(
+ "products",
+ "products-http",
+ new Uri("http://localhost:5000/graphql"),
+ new Dictionary