Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bbe232c
Document framework gap blocking parent-entity-call: composer cycles o…
michaelstaib Apr 22, 2026
b0a5cdc
Document framework gap blocking typename: subgraph requires @interfac…
michaelstaib Apr 22, 2026
4998d88
Enable Apollo Federation compliance suite: shared-root
michaelstaib Apr 22, 2026
230d907
Enable Apollo Federation compliance suite: mutations
michaelstaib Apr 22, 2026
36d3d53
Enable Apollo Federation compliance suite: node
michaelstaib Apr 22, 2026
18a0dda
Enable Apollo Federation compliance suite: null-keys
michaelstaib Apr 22, 2026
880e169
Enable Apollo Federation compliance suite: include-skip
michaelstaib Apr 22, 2026
0d02ee1
Enable Apollo Federation compliance suite: enum-intersection
michaelstaib Apr 22, 2026
29936ca
Enable Apollo Federation compliance suite: input-object-intersection
michaelstaib Apr 22, 2026
b7632b2
Fix mutations compliance suite build on net8.0 by using object lock
michaelstaib Apr 22, 2026
6eb3a7f
Enable Apollo Federation compliance suite: keys-mashup
michaelstaib Apr 22, 2026
baffe28
Document framework gap blocking fed2-external-extends: lookup name co…
michaelstaib Apr 22, 2026
47fa3f0
Document framework gap blocking fed2-external-extension: same lookup-…
michaelstaib Apr 22, 2026
cd22181
Document framework gap: null-keys suite is intermittently flaky
michaelstaib Apr 22, 2026
4be159f
Enable Apollo Federation compliance suite: parent-entity-call-complex
michaelstaib Apr 22, 2026
81bf261
Document framework gap blocking simple-requires-provides: @provides e…
michaelstaib Apr 22, 2026
1757751
Mirror source field nullability on generated @require arguments
michaelstaib Apr 22, 2026
7196e93
Project Apollo Federation @require arguments into _entities represent…
michaelstaib Apr 22, 2026
7acff5f
Include all resolvable key directives in Apollo Federation _Entity union
michaelstaib Apr 22, 2026
bb3f74e
Emit @require metadata into compliance gateway settings
michaelstaib Apr 22, 2026
0816f08
Enable Apollo Federation compliance cases routing through @require
michaelstaib Apr 22, 2026
1f2a465
edits
michaelstaib Apr 22, 2026
1740df8
edits
michaelstaib Apr 23, 2026
e237562
Merge branch 'main' into mst/apollo-fed-connector-2
michaelstaib Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
Expand Up @@ -564,8 +564,11 @@ private void AddToUnionIfHasTypeLevelKeyDirective(
ObjectType objectType,
ObjectTypeConfiguration objectTypeCfg)
{
if (objectTypeCfg.Directives.FirstOrDefault(d => d.Value is KeyDirective) is { } keyDirective
&& ((KeyDirective)keyDirective.Value).Resolvable)
// Apollo Federation adds a type to the '_Entity' union as soon as it
// carries any resolvable '@key'. Scanning only the first key directive
// miscategorizes types whose first declared key is marked
// 'resolvable: false' even though a later key is resolvable.
if (objectTypeCfg.Directives.Any(d => d.Value is KeyDirective keyDirective && keyDirective.Resolvable))
{
_entityTypes.Add(objectType);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,12 @@ private static void ExtractRequireArguments(
continue;
}

var fieldType = sourceField.Type;
var nonNullType = EnsureNonNull(StripNonNull(fieldType));

if (nonNullType is not IInputType inputType)
// Mirror the source field's nullability on the generated
// argument. The composed schema validator compares this
// argument against the owning source field as-is, so
// wrapping a nullable source in NonNull here would make
// every post-merge validation reject the composition.
if (sourceField.Type is not IInputType inputType)
{
continue;
}
Expand Down Expand Up @@ -172,24 +174,4 @@ private static string BuildFieldPath(List<string> path, string fieldName)

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,14 @@ private static ApolloFederationSourceSchemaClientConfiguration CreateConfigurati
}

var lookups = ParseLookups(schemaName, federation);
var entityRequires = ParseEntityRequires(schemaName, federation);

return new ApolloFederationSourceSchemaClientConfiguration(
schemaName,
clientName,
new Uri(url),
lookups);
lookups,
entityRequires);
}

private static Dictionary<string, LookupFieldInfo> ParseLookups(
Expand Down Expand Up @@ -107,6 +109,83 @@ private static Dictionary<string, LookupFieldInfo> ParseLookups(
return lookups;
}

private static Dictionary<string, EntityRequiresInfo> ParseEntityRequires(
string schemaName,
JsonElement federation)
{
if (!federation.TryGetProperty("entityTypes", out var entityTypesElement)
|| entityTypesElement.ValueKind != JsonValueKind.Object)
{
return [];
}

var entityRequires = new Dictionary<string, EntityRequiresInfo>(StringComparer.Ordinal);

foreach (var entityType in entityTypesElement.EnumerateObject())
{
if (entityType.Value.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException(
$"Source schema '{schemaName}' entity type '{entityType.Name}' must be a JSON object.");
}

if (!entityType.Value.TryGetProperty("fields", out var fieldsElement)
|| fieldsElement.ValueKind != JsonValueKind.Object)
{
continue;
}

var fields = new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);

foreach (var field in fieldsElement.EnumerateObject())
{
if (field.Value.ValueKind != JsonValueKind.Object)
{
throw new InvalidOperationException(
$"Source schema '{schemaName}' entity type '{entityType.Name}' "
+ $"field '{field.Name}' must be a JSON object.");
}

if (!field.Value.TryGetProperty("requires", out var requiresElement)
|| requiresElement.ValueKind != JsonValueKind.Object)
{
continue;
}

var requires = new Dictionary<string, string>(StringComparer.Ordinal);

foreach (var argument in requiresElement.EnumerateObject())
{
if (argument.Value.ValueKind != JsonValueKind.String
|| argument.Value.GetString() is not { Length: > 0 } path)
{
throw new InvalidOperationException(
$"Source schema '{schemaName}' entity type '{entityType.Name}' "
+ $"field '{field.Name}' require argument '{argument.Name}' "
+ "must map to a non-empty string representing the field path.");
}

requires[argument.Name] = path;
}

if (requires.Count > 0)
{
fields[field.Name] = requires;
}
}

if (fields.Count > 0)
{
entityRequires[entityType.Name] = new EntityRequiresInfo
{
Fields = fields
};
}
}

return entityRequires;
}

private static Dictionary<string, string> ParseArguments(
string schemaName,
string lookupName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,32 @@ public sealed class ApolloFederationSourceSchemaClientConfiguration : ISourceSch
/// The lookup field metadata used to rewrite Fusion planner queries into
/// Apollo Federation <c>_entities</c> queries.
/// </param>
/// <param name="entityRequires">
/// Per-entity-type metadata describing the <c>@require</c> arguments on
/// each entity field. Enables the rewriter to strip synthetic require
/// arguments from outgoing queries and to inject the bound variable
/// values into the <c>_entities</c> representation body.
/// </param>
/// <param name="supportedOperations">The supported operation types.</param>
internal ApolloFederationSourceSchemaClientConfiguration(
string name,
string httpClientName,
Uri baseAddress,
IReadOnlyDictionary<string, LookupFieldInfo> lookups,
IReadOnlyDictionary<string, EntityRequiresInfo> entityRequires,
SupportedOperationType supportedOperations = SupportedOperationType.Query | SupportedOperationType.Mutation)
{
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentException.ThrowIfNullOrEmpty(httpClientName);
ArgumentNullException.ThrowIfNull(baseAddress);
ArgumentNullException.ThrowIfNull(lookups);
ArgumentNullException.ThrowIfNull(entityRequires);

Name = name;
HttpClientName = httpClientName;
BaseAddress = baseAddress;
Lookups = lookups;
EntityRequires = entityRequires;
SupportedOperations = supportedOperations;
}

Expand All @@ -60,6 +69,12 @@ internal ApolloFederationSourceSchemaClientConfiguration(
/// </summary>
internal IReadOnlyDictionary<string, LookupFieldInfo> Lookups { get; }

/// <summary>
/// Gets the per-entity-type <c>@require</c> argument metadata keyed by
/// entity type name (e.g. <c>"Product"</c>).
/// </summary>
internal IReadOnlyDictionary<string, EntityRequiresInfo> EntityRequires { get; }

/// <inheritdoc />
public SupportedOperationType SupportedOperations { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ protected override ISourceSchemaClient CreateClient(

var queryRewriter = _rewritersBySchema.GetOrAdd(
configuration.Name,
static (_, config) => new FederationQueryRewriter(config.Lookups),
static (_, config) => new FederationQueryRewriter(config.Lookups, config.EntityRequires),
configuration);

var graphQLClient = GraphQLHttpClient.Create(httpClient, disposeHttpClient: true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal sealed class FederationQueryRewriter
{
private readonly ConcurrentDictionary<ulong, RewrittenOperation> _cache = new();
private readonly IReadOnlyDictionary<string, LookupFieldInfo> _lookupFields;
private readonly IReadOnlyDictionary<string, EntityRequiresInfo> _entityRequires;

/// <summary>
/// Initializes a new instance of <see cref="FederationQueryRewriter"/>.
Expand All @@ -27,10 +28,18 @@ internal sealed class FederationQueryRewriter
/// A dictionary mapping query field names (e.g. <c>"productById"</c>) to their
/// <see cref="LookupFieldInfo"/> describing the entity type and key argument mappings.
/// </param>
public FederationQueryRewriter(IReadOnlyDictionary<string, LookupFieldInfo> lookupFields)
/// <param name="entityRequires">
/// A dictionary keyed by entity type name (e.g. <c>"Product"</c>) that
/// describes the <c>@require</c> arguments declared on each entity field.
/// </param>
public FederationQueryRewriter(
IReadOnlyDictionary<string, LookupFieldInfo> lookupFields,
IReadOnlyDictionary<string, EntityRequiresInfo> entityRequires)
{
ArgumentNullException.ThrowIfNull(lookupFields);
ArgumentNullException.ThrowIfNull(entityRequires);
_lookupFields = lookupFields;
_entityRequires = entityRequires;
}

/// <summary>
Expand All @@ -57,7 +66,7 @@ private RewrittenOperation Rewrite(string operationSourceText)
&& selections[0] is FieldNode lookupField
&& _lookupFields.TryGetValue(lookupField.Name.Value, out var lookupInfo))
{
return RewriteEntityLookup(lookupField, lookupInfo);
return RewriteEntityLookup(lookupField, lookupInfo, _entityRequires);
}

// Not an entity lookup, pass through unchanged.
Expand All @@ -73,7 +82,8 @@ private RewrittenOperation Rewrite(string operationSourceText)

private static RewrittenOperation RewriteEntityLookup(
FieldNode lookupField,
LookupFieldInfo lookupInfo)
LookupFieldInfo lookupInfo,
IReadOnlyDictionary<string, EntityRequiresInfo> entityRequires)
{
// 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)
Expand All @@ -89,7 +99,24 @@ private static RewrittenOperation RewriteEntityLookup(
}
}

// 2. Build the _entities query AST.
// 2. Project any '@require' arguments declared on the lookup field's
// selection into the representation. The planner emits the require
// arguments as a variable reference on each inline-fragment field;
// we strip those arguments from the outgoing selection and record
// the variable-to-representation mapping so the client can splice
// the bound variable value onto the representation body.
//
// The walk only descends into the lookup field's top-level selection
// set. Nested '@require' paths (require arguments on fields nested
// inside other selections) are not yet handled; the composer does
// not currently generate them for any enabled compliance suite.
var innerSelections = StripRequireArguments(
lookupField.SelectionSet,
lookupInfo.EntityTypeName,
entityRequires,
variableToKeyFieldMap);

// 3. Build the _entities query AST.
// query($representations: [_Any!]!) {
// _entities(representations: $representations) {
// ... on EntityType { <inner selections> }
Expand All @@ -113,8 +140,7 @@ private static RewrittenOperation RewriteEntityLookup(
location: null,
typeCondition: new NamedTypeNode(lookupInfo.EntityTypeName),
directives: [],
selectionSet: lookupField.SelectionSet
?? new SelectionSetNode(Array.Empty<ISelectionNode>()));
selectionSet: innerSelections);

// The _entities field: _entities(representations: $representations) { ... on Product { ... } }
var entitiesField = new FieldNode(
Expand Down Expand Up @@ -148,6 +174,100 @@ private static RewrittenOperation RewriteEntityLookup(
};
}

/// <summary>
/// Returns the lookup field's inner selection set with every
/// <c>@require</c>-tagged argument removed. The stripped variables are
/// recorded into <paramref name="variableToKeyFieldMap"/> so that the
/// client merges them into the <c>_entities</c> representation body.
/// </summary>
private static SelectionSetNode StripRequireArguments(
SelectionSetNode? selectionSet,
string entityTypeName,
IReadOnlyDictionary<string, EntityRequiresInfo> entityRequires,
Dictionary<string, string> variableToKeyFieldMap)
{
if (selectionSet is null)
{
return new SelectionSetNode(Array.Empty<ISelectionNode>());
}

if (!entityRequires.TryGetValue(entityTypeName, out var requiresInfo)
|| requiresInfo.Fields.Count == 0)
{
return selectionSet;
}

var selections = selectionSet.Selections;
List<ISelectionNode>? rewritten = null;

for (var i = 0; i < selections.Count; i++)
{
var selection = selections[i];

if (selection is not FieldNode fieldNode
|| !requiresInfo.Fields.TryGetValue(fieldNode.Name.Value, out var requiresArgs))
{
rewritten?.Add(selection);
continue;
}

// Walk the field's arguments and drop any that match a require
// argument name; record the bound variable name against the
// require field path so the client can inject it into the
// representation.
var arguments = fieldNode.Arguments;
List<ArgumentNode>? retained = null;

for (var j = 0; j < arguments.Count; j++)
{
var argument = arguments[j];

if (requiresArgs.TryGetValue(argument.Name.Value, out var requireFieldPath)
&& argument.Value is VariableNode variable)
{
variableToKeyFieldMap[variable.Name.Value] = requireFieldPath;

if (retained is null)
{
retained = new List<ArgumentNode>(arguments.Count);
for (var k = 0; k < j; k++)
{
retained.Add(arguments[k]);
}
}

continue;
}

retained?.Add(argument);
}

if (retained is null)
{
rewritten?.Add(selection);
continue;
}

if (rewritten is null)
{
rewritten = new List<ISelectionNode>(selections.Count);
for (var k = 0; k < i; k++)
{
rewritten.Add(selections[k]);
}
}

rewritten.Add(fieldNode.WithArguments(retained));
}

if (rewritten is null)
{
return selectionSet;
}

return new SelectionSetNode(rewritten);
}

private static OperationDefinitionNode GetOperationDefinition(DocumentNode document)
{
for (var i = 0; i < document.Definitions.Count; i++)
Expand Down
Loading
Loading