Conversation
There was a problem hiding this comment.
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→@requireargs). - Add an Apollo Federation source schema client/connector that rewrites lookup queries to
_entitiesand unwraps results back into Fusion’s expected shape. - Add
OperationHashplumbing toSourceSchemaClientRequest/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)) |
There was a problem hiding this comment.
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.
| if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes)) | |
| if (!analysis.TypeFieldTypes.TryGetValue(typeName, out _)) |
| return null; | ||
| } | ||
|
|
||
| if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes)) |
There was a problem hiding this comment.
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).
| if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes)) | |
| if (!analysis.TypeFieldTypes.TryGetValue(typeName, out _)) |
src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs
Show resolved
Hide resolved
| 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); |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| for (var i = 0; i < variables.Length; i++) | ||
| { | ||
| var variable = variables[i]; | ||
| yield return variable.AdditionalPaths.IsDefaultOrEmpty | ||
| ? new SourceSchemaResult(variable.Path, result) |
There was a problem hiding this comment.
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.
| // 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(); |
There was a problem hiding this comment.
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.
| // 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(); | |
| } |
| if (variables.IsDefaultOrEmpty || i >= variables.Length) | ||
| { | ||
| resultPath = CompactPath.Root; | ||
| additionalPaths = []; | ||
| } |
There was a problem hiding this comment.
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).
| Id = id; | ||
| Operation = operation; | ||
| _operationHash = XxHash64.HashToUInt64(Encoding.UTF8.GetBytes(operation.SourceText)); | ||
| SchemaName = schemaName; |
There was a problem hiding this comment.
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.
…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
No description provided.