Skip to content

Add apollo federation connector#9479

Open
michaelstaib wants to merge 10 commits intomainfrom
mst/apollofederation-adapter
Open

Add apollo federation connector#9479
michaelstaib wants to merge 10 commits intomainfrom
mst/apollofederation-adapter

Conversation

@michaelstaib
Copy link
Copy Markdown
Member

No description provided.

Copilot AI review requested due to automatic review settings April 3, 2026 12:40
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an Apollo Federation connector for HotChocolate Fusion, including a schema transformer that converts Apollo Federation v2 subgraph SDL into Composite Schema Spec-compatible source schema SDL, plus execution-time query rewriting to _entities for entity lookups.

Changes:

  • Add Apollo Federation v2 SDL → Composite Schema transformations (remove federation infra, normalize @key, generate @lookup, transform @requires@require args).
  • Add an Apollo Federation source schema client/connector that rewrites lookup queries to _entities and unwraps results back into Fusion’s expected shape.
  • Add OperationHash plumbing to SourceSchemaClientRequest/execution nodes to support connector-side rewrite caching, plus new test projects and snapshots.

Reviewed changes

Copilot reviewed 47 out of 48 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/HotChocolate/Utilities/src/Utilities.Buffers/HotChocolate.Utilities.Buffers.csproj Exposes internals to the new Apollo Federation connector assembly.
src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj Exposes Fusion.Execution internals to the Apollo Federation connector assembly.
src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.resx Removes an unused resource entry.
src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs Regenerates strongly-typed resource designer after resource removal.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs Precomputes and forwards OperationHash on requests.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs Adds OperationHash to plan nodes for connector caching.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationBatchExecutionNode.cs Propagates OperationHash into batched requests.
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Clients/SourceSchemaClientRequest.cs Adds required OperationHash field to requests.
src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/HotChocolate.Fusion.Connectors.ApolloFederation.csproj New connector project definition.
src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs Public configuration object for the connector.
src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientFactory.cs Factory wiring for HTTP-based Apollo Federation client instances.
src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs Rewrites Fusion planner lookup queries to _entities operations with caching.
src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs Internal model for rewritten operations.
src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs Internal model describing lookup fields and key mappings.
src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs Executes passthrough or rewritten _entities requests and maps results back.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/HotChocolate.Fusion.Composition.ApolloFederation.csproj New composition-time Apollo Federation transformation project.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs Orchestrates transformations and returns transformed SDL / SourceSchemaText.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs Analyzes federation SDL to extract keys, query root name, and unsupported features.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs Removes federation infra types/fields/directives from SDL.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs Strips resolvable: argument from @key.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs Generates @lookup fields on Query for resolvable keys.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs Converts @requires(fields: ...) into @require(field: ...) argument directives.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs Analyzer output model.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/EntityKeyInfo.cs Model for extracted federation keys.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationDirectiveNames.cs Constants for federation directive names.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationFieldNames.cs Constants for federation field names.
src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationTypeNames.cs Constants for federation type/scalar names.
src/HotChocolate/Fusion/HotChocolate.Fusion.slnx Adds the connector project and its tests to the solution.
src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/HotChocolate.Fusion.Connectors.ApolloFederation.Tests.csproj New connector test project.
src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs Unit tests for configuration and query rewriting behavior.
src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs Integration tests for transform + rewrite + execute roundtrips.
src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/snapshots/* Snapshots for connector integration tests.
src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/HotChocolate.Fusion.Composition.ApolloFederation.Tests.csproj New transformation test project.
src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs Unit tests for many federation SDL transformation scenarios.
src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/snapshots/* Markdown snapshots for transformer test cases.
Files not reviewed (1)
  • src/HotChocolate/Fusion/src/Fusion.Execution/Properties/FusionExecutionResources.Designer.cs: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

return field;
}

if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes))
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The out var fieldTypes value is never used. With TreatWarningsAsErrors=true in this repo, this unused local will fail the build. Use out _ or remove the TryGetValue entirely if the lookup is unnecessary.

Suggested change
if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes))
if (!analysis.TypeFieldTypes.TryGetValue(typeName, out _))

Copilot uses AI. Check for mistakes.
return null;
}

if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes))
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The out var fieldTypes value is never used. With TreatWarningsAsErrors=true in this repo, this unused local will fail the build. Use out _ or remove the lookup if it’s redundant (the rest of the method already consults analysis.TypeFieldTypes).

Suggested change
if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes))
if (!analysis.TypeFieldTypes.TryGetValue(typeName, out _))

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +106
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);
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The passthrough path only forwards request.Variables[0]. If SourceSchemaClientRequest.Variables contains multiple entries (variable batching), this executes the subgraph operation once and ignores the remaining variable sets. The passthrough implementation needs to execute once per variable set (or use the existing variable-batching transport shape) so results correspond to each VariableValues entry.

Suggested change
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);
GraphQLHttpRequest httpRequest;
if (request.Variables.IsDefaultOrEmpty || request.Variables.Length == 1)
{
var operationRequest = new OperationRequest(
request.OperationSourceText,
id: null,
operationName: null,
onError: null,
variables: request.Variables.IsDefaultOrEmpty
? VariableValues.Empty
: request.Variables[0],
extensions: JsonSegment.Empty);
httpRequest = new GraphQLHttpRequest(operationRequest);
}
else
{
var operationRequests = new OperationRequest[request.Variables.Length];
for (var i = 0; i < request.Variables.Length; i++)
{
operationRequests[i] = new OperationRequest(
request.OperationSourceText,
id: null,
operationName: null,
onError: null,
variables: request.Variables[i],
extensions: JsonSegment.Empty);
}
httpRequest = new GraphQLHttpRequest(operationRequests);
}

Copilot uses AI. Check for mistakes.
Comment on lines +395 to +399
for (var i = 0; i < variables.Length; i++)
{
var variable = variables[i];
yield return variable.AdditionalPaths.IsDefaultOrEmpty
? new SourceSchemaResult(variable.Path, result)
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop yields the same result document for every variable set when variables.Length > 1. Each variable set should receive its own response document from the subgraph (or a variable-batch response that contains per-variable results); otherwise the first execution’s result is duplicated across all variable sets.

Copilot uses AI. Check for mistakes.
Comment on lines +433 to +489
// 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": {"<lookupFieldName>": <entity>}}
var entityJson = BuildWrappedEntityJson(lookupFieldName, entity);
var entityBytes = Encoding.UTF8.GetBytes(entityJson);
var entityDocument = SourceResultDocument.Parse(entityBytes, entityBytes.Length);

CompactPath resultPath;
ImmutableArray<CompactPath> additionalPaths;

if (variables.IsDefaultOrEmpty || i >= variables.Length)
{
resultPath = CompactPath.Root;
additionalPaths = [];
}
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();
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sourceDocument is disposed only after the async iterator completes. If enumeration stops early (cancellation/error), sourceDocument won’t be disposed, leaking pooled buffers. Wrap the iterator body in try/finally and dispose sourceDocument in the finally.

Suggested change
// 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": {"<lookupFieldName>": <entity>}}
var entityJson = BuildWrappedEntityJson(lookupFieldName, entity);
var entityBytes = Encoding.UTF8.GetBytes(entityJson);
var entityDocument = SourceResultDocument.Parse(entityBytes, entityBytes.Length);
CompactPath resultPath;
ImmutableArray<CompactPath> additionalPaths;
if (variables.IsDefaultOrEmpty || i >= variables.Length)
{
resultPath = CompactPath.Root;
additionalPaths = [];
}
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();
try
{
// 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": {"<lookupFieldName>": <entity>}}
var entityJson = BuildWrappedEntityJson(lookupFieldName, entity);
var entityBytes = Encoding.UTF8.GetBytes(entityJson);
var entityDocument = SourceResultDocument.Parse(entityBytes, entityBytes.Length);
CompactPath resultPath;
ImmutableArray<CompactPath> additionalPaths;
if (variables.IsDefaultOrEmpty || i >= variables.Length)
{
resultPath = CompactPath.Root;
additionalPaths = [];
}
else
{
resultPath = variables[i].Path;
additionalPaths = variables[i].AdditionalPaths;
}
yield return additionalPaths.IsDefaultOrEmpty
? new SourceSchemaResult(resultPath, entityDocument)
: new SourceSchemaResult(resultPath, entityDocument, additionalPaths: additionalPaths);
}
}
finally
{
sourceDocument.Dispose();
}

Copilot uses AI. Check for mistakes.
Comment on lines +473 to +477
if (variables.IsDefaultOrEmpty || i >= variables.Length)
{
resultPath = CompactPath.Root;
additionalPaths = [];
}
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the _entities array length doesn’t match the number of input variable sets, routing results to CompactPath.Root can merge data into the wrong place. Since Apollo Federation preserves representation order/length, validate the expected count and treat mismatches as an error (or avoid falling back to the root path).

Copilot uses AI. Check for mistakes.
Comment on lines 29 to 32
Id = id;
Operation = operation;
_operationHash = XxHash64.HashToUInt64(Encoding.UTF8.GetBytes(operation.SourceText));
SchemaName = schemaName;
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Computing the xxHash via Encoding.UTF8.GetBytes(...) allocates a new byte array. Consider hashing via a pooled/stack buffer (ArrayPool) or another non-allocating approach, especially if many operation definitions are created per plan.

Copilot uses AI. Check for mistakes.
…adapter

# Conflicts:
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Argument.Tests.cs
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.CostDirective.Tests.cs
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Interface.Tests.cs
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.OutputField.Tests.cs
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Union.Tests.cs
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs
#	src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaPreprocessorTests.Preprocess_InferKeysFromLookupsEnabled_AppliesInferredKeyDirectives.graphql
#	src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Rewriter_Should_RewriteLookupToEntities_FromTransformedSchema.graphql
#	src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants