From 006a3409f9e0342181a828bb4986777af1426499 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 29 Mar 2026 02:20:57 +0000 Subject: [PATCH 1/6] Add apollo federation connector --- CLAUDE.md | 6 +- .../Fusion/HotChocolate.Fusion.slnx | 2 + .../AnalysisResult.cs | 42 ++ .../EntityKeyInfo.cs | 18 + .../FederationDirectiveNames.cs | 19 + .../FederationFieldNames.cs | 7 + .../FederationSchemaAnalyzer.cs | 360 +++++++++++ .../FederationSchemaTransformer.cs | 84 +++ .../FederationTypeNames.cs | 10 + .../GenerateLookupFields.cs | 353 +++++++++++ ...Fusion.Composition.ApolloFederation.csproj | 17 + .../RemoveFederationInfrastructure.cs | 218 +++++++ .../RewriteKeyDirectives.cs | 157 +++++ .../TransformRequiresToRequire.cs | 329 ++++++++++ .../FederationSchemaTransformerTests.cs | 598 ++++++++++++++++++ ....Composition.ApolloFederation.Tests.csproj | 13 + ...ransformerTests.TransformToSourceSchema.md | 38 ++ ...TransformerTests.Transform_CompositeKey.md | 40 ++ ...formerTests.Transform_ExternalDirective.md | 39 ++ ...nsformerTests.Transform_FullIntegration.md | 77 +++ ...erTests.Transform_KeyResolvableArgument.md | 38 ++ ...TransformerTests.Transform_MultipleKeys.md | 43 ++ ...ransform_NonResolvableAndResolvableKeys.md | 40 ++ ...sformerTests.Transform_NonResolvableKey.md | 37 ++ ...formerTests.Transform_ProvidesDirective.md | 50 ++ ...formerTests.Transform_RequiresDirective.md | 44 ++ ...TransformerTests.Transform_SimpleEntity.md | 38 ++ 27 files changed, 2716 insertions(+), 1 deletion(-) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/EntityKeyInfo.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationDirectiveNames.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationFieldNames.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationTypeNames.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/HotChocolate.Fusion.Composition.ApolloFederation.csproj create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/HotChocolate.Fusion.Composition.ApolloFederation.Tests.csproj create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.TransformToSourceSchema.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md create mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md diff --git a/CLAUDE.md b/CLAUDE.md index 8083d466e6c..f2a1c6394d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,8 +55,12 @@ dotnet test src/HotChocolate/Fusion ### Testing -- Filter tests during iteration — never run the full suite unnecessarily +- Prefer snapshot tests over manual `Assert` calls — use **CookieCrumble** for snapshots +- CookieCrumble has native snapshot support for `IExecutionResult`, `GraphQLHttpResponse`, and other core types +- For smaller snapshots, prefer **inline snapshots** (`MatchInlineSnapshot`) over snapshot files +- For tests with multiple assertions, use **Markdown snapshots** (`MatchMarkdownSnapshot`) - Snapshot tests: update from `__mismatch__/` directory, understand ordering issues before updating +- Filter tests during iteration — never run the full suite unnecessarily - Real databases in integration tests, not mocks (unless explicitly instructed otherwise) ## Performance diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx index 69c04986eaf..c196f3a84e3 100644 --- a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx +++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx @@ -6,6 +6,7 @@ + @@ -18,6 +19,7 @@ + diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs new file mode 100644 index 00000000000..ffe8897c968 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs @@ -0,0 +1,42 @@ +using HotChocolate.Fusion.Errors; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.ApolloFederation; + +/// +/// Contains the metadata extracted from analyzing a Federation v2 schema document. +/// +internal sealed class AnalysisResult +{ + /// + /// Gets the detected federation version string (e.g. "v2.0", "v2.5"). + /// A value of "v1" indicates unsupported federation v1. + /// + public string FederationVersion { get; set; } = "v1"; + + /// + /// Gets the entity key definitions keyed by type name. + /// Each type may have multiple @key directives. + /// + public Dictionary> EntityKeys { get; init; } = []; + + /// + /// Gets the field type map: typeName -> fieldName -> field type node. + /// + public Dictionary> TypeFieldTypes { get; init; } = []; + + /// + /// Gets the name of the query root type (defaults to "Query"). + /// + public string QueryTypeName { get; set; } = "Query"; + + /// + /// Gets the list of composition errors detected during analysis. + /// + public List Errors { get; init; } = []; + + /// + /// Gets a value indicating whether analysis produced any errors. + /// + public bool HasErrors => Errors.Count > 0; +} 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..602bfb802e3 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs @@ -0,0 +1,360 @@ +using HotChocolate.Fusion.Errors; +using HotChocolate.Language; + +namespace HotChocolate.Fusion.ApolloFederation; + +/// +/// Analyzes a parsed to extract Apollo Federation metadata +/// needed for transformations. +/// +internal static class FederationSchemaAnalyzer +{ + private const string FederationUrlPrefix = "specs.apollo.dev/federation"; + + /// + /// Analyzes the given federation schema document and extracts metadata. + /// + /// + /// The parsed GraphQL document to analyze. + /// + /// + /// An containing the extracted federation metadata. + /// + public static AnalysisResult Analyze(DocumentNode document) + { + var result = new AnalysisResult(); + + DetectFederationVersion(document, result); + DetectQueryTypeName(document, result); + AnalyzeTypeDefinitions(document, result); + + return result; + } + + private static void DetectFederationVersion(DocumentNode document, AnalysisResult result) + { + var federationVersion = FindFederationVersion(document); + + if (federationVersion is null) + { + result.Errors.Add(new CompositionError("Federation v1 is not supported.")); + return; + } + + result.FederationVersion = federationVersion; + } + + private static string? FindFederationVersion(DocumentNode document) + { + foreach (var definition in document.Definitions) + { + IReadOnlyList? directives = definition switch + { + SchemaDefinitionNode schemaDef => schemaDef.Directives, + SchemaExtensionNode schemaExt => schemaExt.Directives, + _ => null + }; + + if (directives is null) + { + continue; + } + + foreach (var directive in directives) + { + if (!directive.Name.Value.Equals( + FederationDirectiveNames.Link, + StringComparison.Ordinal)) + { + continue; + } + + var url = GetStringArgument(directive, "url"); + + if (url is null + || !url.Contains(FederationUrlPrefix, StringComparison.Ordinal)) + { + continue; + } + + // Check for @composeDirective imports in this @link directive. + if (HasComposeDirectiveImport(directive)) + { + // We only add the error but continue to extract the version. + } + + // 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 bool HasComposeDirectiveImport(DirectiveNode linkDirective) + { + // @link can import specific directives via the `import` argument. + // We do not inspect imports here; @composeDirective is a separate directive. + // This is checked elsewhere in AnalyzeTypeDefinitions. + return false; + } + + private static void DetectQueryTypeName(DocumentNode document, AnalysisResult result) + { + foreach (var definition in document.Definitions) + { + if (definition is not SchemaDefinitionNode schemaDef) + { + continue; + } + + foreach (var operationType in schemaDef.OperationTypes) + { + if (operationType.Operation == OperationType.Query) + { + result.QueryTypeName = operationType.Type.Name.Value; + return; + } + } + } + + // Default remains "Query" as set in AnalysisResult constructor. + } + + private static void AnalyzeTypeDefinitions(DocumentNode document, AnalysisResult result) + { + foreach (var definition in document.Definitions) + { + switch (definition) + { + case ObjectTypeDefinitionNode objectType: + AnalyzeComplexType( + objectType.Name.Value, + objectType.Directives, + objectType.Fields, + result); + break; + + case InterfaceTypeDefinitionNode interfaceType: + AnalyzeComplexType( + interfaceType.Name.Value, + interfaceType.Directives, + interfaceType.Fields, + result); + break; + } + } + + // Check for @composeDirective definitions in the document. + foreach (var definition in document.Definitions) + { + if (definition is DirectiveDefinitionNode directiveDef + && directiveDef.Name.Value.Equals( + FederationDirectiveNames.ComposeDirective, + StringComparison.Ordinal)) + { + result.Errors.Add( + new CompositionError( + "The @composeDirective feature is not supported.")); + break; + } + } + + // Also check for @composeDirective usage in @link imports. + foreach (var definition in document.Definitions) + { + IReadOnlyList? directives = definition switch + { + SchemaDefinitionNode schemaDef => schemaDef.Directives, + SchemaExtensionNode schemaExt => schemaExt.Directives, + _ => null + }; + + if (directives is null) + { + continue; + } + + foreach (var directive in directives) + { + if (directive.Name.Value.Equals( + FederationDirectiveNames.Link, + StringComparison.Ordinal)) + { + var url = GetStringArgument(directive, "url"); + + if (url?.Contains(FederationUrlPrefix, StringComparison.Ordinal) == false) + { + // This is a non-federation @link, check if there is a + // @composeDirective applied elsewhere. + CheckForComposeDirectiveUsage(document, result); + break; + } + } + } + } + } + + private static void CheckForComposeDirectiveUsage( + DocumentNode document, + AnalysisResult result) + { + foreach (var definition in document.Definitions) + { + IReadOnlyList? directives = definition switch + { + SchemaDefinitionNode schemaDef => schemaDef.Directives, + SchemaExtensionNode schemaExt => schemaExt.Directives, + _ => null + }; + + if (directives is null) + { + continue; + } + + foreach (var directive in directives) + { + if (directive.Name.Value.Equals( + FederationDirectiveNames.ComposeDirective, + StringComparison.Ordinal)) + { + result.Errors.Add( + new CompositionError( + "The @composeDirective feature is not supported.")); + return; + } + } + } + } + + private static void AnalyzeComplexType( + string typeName, + IReadOnlyList directives, + IReadOnlyList fields, + AnalysisResult result) + { + // Check for unsupported directives on the type. + foreach (var directive in directives) + { + if (directive.Name.Value.Equals( + FederationDirectiveNames.InterfaceObject, + StringComparison.Ordinal)) + { + result.Errors.Add( + new CompositionError( + $"The @interfaceObject directive on type '{typeName}'" + + " is not supported.")); + } + } + + // Extract @key directives. + foreach (var directive in directives) + { + if (!directive.Name.Value.Equals( + FederationDirectiveNames.Key, + StringComparison.Ordinal)) + { + continue; + } + + var fieldsValue = GetStringArgument(directive, "fields"); + + if (fieldsValue is null) + { + continue; + } + + var resolvable = GetBooleanArgument(directive, "resolvable") ?? true; + + if (!result.EntityKeys.TryGetValue(typeName, out var keyList)) + { + keyList = []; + result.EntityKeys[typeName] = keyList; + } + + keyList.Add(new EntityKeyInfo + { + Fields = fieldsValue, + Resolvable = resolvable + }); + } + + // Build field type map. + var fieldTypes = new Dictionary(StringComparer.Ordinal); + + foreach (var field in fields) + { + fieldTypes[field.Name.Value] = field.Type; + } + + result.TypeFieldTypes[typeName] = fieldTypes; + + // Check for unsupported directives on fields. + foreach (var field in fields) + { + foreach (var directive in field.Directives) + { + if (directive.Name.Value.Equals( + FederationDirectiveNames.InterfaceObject, + StringComparison.Ordinal)) + { + result.Errors.Add( + new CompositionError( + "The @interfaceObject directive on field" + + $" '{typeName}.{field.Name.Value}' is not supported.")); + } + + if (directive.Name.Value.Equals( + FederationDirectiveNames.Override, + StringComparison.Ordinal)) + { + var label = GetStringArgument(directive, "label"); + + if (label is not null) + { + result.Errors.Add( + new CompositionError( + "The @override directive with a 'label' argument" + + $" on field '{typeName}.{field.Name.Value}'" + + " is not supported.")); + } + } + } + } + } + + private static string? GetStringArgument(DirectiveNode directive, string argumentName) + { + foreach (var argument in directive.Arguments) + { + if (argument.Name.Value.Equals(argumentName, StringComparison.Ordinal) + && argument.Value is StringValueNode stringValue) + { + return stringValue.Value; + } + } + + return null; + } + + private static bool? GetBooleanArgument(DirectiveNode directive, string argumentName) + { + foreach (var argument in directive.Arguments) + { + if (argument.Name.Value.Equals(argumentName, StringComparison.Ordinal) + && argument.Value is BooleanValueNode boolValue) + { + return boolValue.Value; + } + } + + return null; + } +} 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..5fbfc87a782 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs @@ -0,0 +1,84 @@ +using System.Collections.Immutable; +using HotChocolate.Fusion.Errors; +using HotChocolate.Fusion.Results; +using HotChocolate.Language; + +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); + + DocumentNode document; + + try + { + document = Utf8GraphQLParser.Parse(federationSdl); + } + catch (SyntaxException ex) + { + return new CompositionError( + $"Failed to parse federation SDL: {ex.Message}"); + } + + var analysis = FederationSchemaAnalyzer.Analyze(document); + + if (analysis.HasErrors) + { + return analysis.Errors.ToImmutableArray(); + } + + document = RemoveFederationInfrastructure.Apply(document, analysis); + document = RewriteKeyDirectives.Apply(document); + document = GenerateLookupFields.Apply(document, analysis); + document = TransformRequiresToRequire.Apply(document, analysis); + + return document.ToString(indented: true); + } + + /// + /// Transforms the given Apollo Federation v2 subgraph SDL into a + /// for the composition pipeline. + /// + /// + /// The name to assign to the source schema. + /// + /// + /// The Apollo Federation v2 subgraph SDL to transform. + /// + /// + /// A containing a + /// on success, or composition errors on failure. + /// + public static CompositionResult TransformToSourceSchema( + string schemaName, + string federationSdl) + { + ArgumentException.ThrowIfNullOrEmpty(schemaName); + + var (_, isFailure, sdl, errors) = Transform(federationSdl); + + if (isFailure) + { + return errors; + } + + return new SourceSchemaText(schemaName, sdl); + } +} 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..742c12f1f96 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs @@ -0,0 +1,353 @@ +using HotChocolate.Language; + +namespace HotChocolate.Fusion.ApolloFederation; + +/// +/// Generates @lookup query fields for each resolvable entity key. +/// +internal static class GenerateLookupFields +{ + /// + /// Applies the lookup field generation to the document. + /// + /// + /// The document to transform. + /// + /// + /// The analysis result containing entity key and field type metadata. + /// + /// + /// A new document with generated lookup fields on the Query type. + /// + public static DocumentNode Apply(DocumentNode document, AnalysisResult analysis) + { + var lookupFields = new List(); + + foreach (var (typeName, keys) in analysis.EntityKeys) + { + foreach (var key in keys) + { + if (!key.Resolvable) + { + continue; + } + + var field = GenerateLookupField(typeName, key, analysis); + + if (field is not null) + { + lookupFields.Add(field); + } + } + } + + if (lookupFields.Count == 0) + { + return document; + } + + return AppendFieldsToQueryType(document, analysis.QueryTypeName, lookupFields); + } + + private static FieldDefinitionNode? GenerateLookupField( + string typeName, + EntityKeyInfo key, + AnalysisResult analysis) + { + SelectionSetNode selectionSet; + + try + { + selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet( + "{ " + key.Fields + " }"); + } + catch (SyntaxException) + { + return null; + } + + var leafFields = new List(); + ExtractLeafFields(selectionSet, [], leafFields); + + if (leafFields.Count == 0) + { + return null; + } + + if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes)) + { + return null; + } + + var arguments = new List(); + var nameParts = new List(); + + foreach (var leaf in leafFields) + { + var argumentName = leaf.ArgumentName; + var fieldType = ResolveLeafFieldType(leaf, typeName, analysis); + + if (fieldType is null) + { + continue; + } + + // Make the type NonNull. + var nonNullType = EnsureNonNull(fieldType); + + var argumentDirectives = new List(); + + // If the field has a nested path, add @is directive. + if (leaf.Path.Count > 0) + { + var fieldPath = BuildFieldPath(leaf); + argumentDirectives.Add( + new DirectiveNode( + "is", + new ArgumentNode("field", new StringValueNode(fieldPath)))); + } + + arguments.Add( + new InputValueDefinitionNode( + null, + new NameNode(argumentName), + null, + nonNullType, + null, + argumentDirectives)); + + nameParts.Add(ToPascalCase(argumentName)); + } + + if (arguments.Count == 0) + { + return null; + } + + var fieldName = ToCamelCase(typeName) + "By" + string.Join("And", nameParts); + + return new FieldDefinitionNode( + null, + new NameNode(fieldName), + null, + arguments, + new NamedTypeNode(typeName), + [new DirectiveNode("internal"), new DirectiveNode("lookup")]); + } + + private static ITypeNode? ResolveLeafFieldType( + LeafFieldInfo leaf, + string typeName, + AnalysisResult analysis) + { + if (leaf.Path.Count == 0) + { + // Simple field: look up directly. + if (analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes) + && fieldTypes.TryGetValue(leaf.FieldName, out var fieldType)) + { + return fieldType; + } + + return null; + } + + // Nested field: walk the path. + var currentTypeName = typeName; + + foreach (var pathSegment in leaf.Path) + { + if (!analysis.TypeFieldTypes.TryGetValue(currentTypeName, out var pathFieldTypes) + || !pathFieldTypes.TryGetValue(pathSegment, out var pathFieldType)) + { + return null; + } + + currentTypeName = GetNamedTypeName(pathFieldType); + + if (currentTypeName is null) + { + return null; + } + } + + // Now look up the final leaf field. + if (analysis.TypeFieldTypes.TryGetValue(currentTypeName, out var leafFieldTypes) + && leafFieldTypes.TryGetValue(leaf.FieldName, out var leafFieldType)) + { + return leafFieldType; + } + + return null; + } + + private static string? GetNamedTypeName(ITypeNode typeNode) + { + return typeNode switch + { + NamedTypeNode named => named.Name.Value, + NonNullTypeNode nonNull => GetNamedTypeName(nonNull.Type), + ListTypeNode list => GetNamedTypeName(list.Type), + _ => null + }; + } + + private static void ExtractLeafFields( + SelectionSetNode selectionSet, + List parentPath, + List results) + { + 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 field: recurse with the current field added to the path. + var nestedPath = new List(parentPath) { fieldName }; + ExtractLeafFields(fieldNode.SelectionSet!, nestedPath, results); + } + else + { + // Leaf field. + var argumentName = parentPath.Count > 0 + ? BuildArgumentName(parentPath, fieldName) + : fieldName; + + results.Add(new LeafFieldInfo + { + FieldName = fieldName, + ArgumentName = argumentName, + Path = parentPath + }); + } + } + } + + private static string BuildArgumentName(List path, string fieldName) + { + // e.g., path=["variation"], fieldName="id" => "variationId" + var result = path[0]; + + for (var i = 1; i < path.Count; i++) + { + result += ToPascalCase(path[i]); + } + + result += ToPascalCase(fieldName); + return result; + } + + private static string BuildFieldPath(LeafFieldInfo leaf) + { + // Build something like "variation { id }" + var result = string.Empty; + + for (var i = 0; i < leaf.Path.Count; i++) + { + if (i > 0) + { + result += " { "; + } + + result += leaf.Path[i]; + } + + result += " { " + leaf.FieldName + " }"; + + for (var i = 1; i < leaf.Path.Count; i++) + { + result += " }"; + } + + return result; + } + + private static ITypeNode EnsureNonNull(ITypeNode typeNode) + { + if (typeNode is NonNullTypeNode) + { + return typeNode; + } + + if (typeNode is INullableTypeNode nullable) + { + return new NonNullTypeNode(nullable); + } + + return typeNode; + } + + private static DocumentNode AppendFieldsToQueryType( + DocumentNode document, + string queryTypeName, + List lookupFields) + { + var definitions = new List(document.Definitions.Count); + + foreach (var definition in document.Definitions) + { + if (definition is ObjectTypeDefinitionNode objectType + && objectType.Name.Value.Equals(queryTypeName, StringComparison.Ordinal)) + { + var allFields = new List( + objectType.Fields.Count + lookupFields.Count); + + allFields.AddRange(objectType.Fields); + allFields.AddRange(lookupFields); + + definitions.Add(objectType.WithFields(allFields)); + } + else + { + definitions.Add(definition); + } + } + + return document.WithDefinitions(definitions); + } + + 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 class LeafFieldInfo + { + public required string FieldName { get; init; } + + public required string ArgumentName { get; init; } + + public required List Path { get; init; } + } +} 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..2fec9b6b92a --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/HotChocolate.Fusion.Composition.ApolloFederation.csproj @@ -0,0 +1,17 @@ + + + + HotChocolate.Fusion.Composition.ApolloFederation + HotChocolate.Fusion.ApolloFederation + + + + + + + + + + + + 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..b6cf0f2105f --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs @@ -0,0 +1,218 @@ +using HotChocolate.Language; + +namespace HotChocolate.Fusion.ApolloFederation; + +/// +/// Removes Apollo Federation infrastructure types, directives, and fields +/// from a schema document. +/// +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 + }; + + private static readonly HashSet _federationFieldNames = new(StringComparer.Ordinal) + { + FederationFieldNames.Entities, + FederationFieldNames.Service + }; + + /// + /// Applies the transformation to remove federation infrastructure from the document. + /// + /// + /// The document to transform. + /// + /// + /// The analysis result containing metadata about the schema. + /// + /// + /// A new document with federation infrastructure removed. + /// + public static DocumentNode Apply(DocumentNode document, AnalysisResult analysis) + { + var definitions = new List(document.Definitions.Count); + + foreach (var definition in document.Definitions) + { + var transformed = TransformDefinition(definition, analysis); + + if (transformed is not null) + { + definitions.Add(transformed); + } + } + + return document.WithDefinitions(definitions); + } + + private static IDefinitionNode? TransformDefinition( + IDefinitionNode definition, + AnalysisResult analysis) + { + switch (definition) + { + case ObjectTypeDefinitionNode objectType + when objectType.Name.Value.Equals( + FederationTypeNames.Service, + StringComparison.Ordinal): + return null; + + case UnionTypeDefinitionNode unionType + when unionType.Name.Value.Equals( + FederationTypeNames.Entity, + StringComparison.Ordinal): + return null; + + case ScalarTypeDefinitionNode scalarType + when _federationScalarNames.Contains(scalarType.Name.Value): + return null; + + case DirectiveDefinitionNode directiveDef + when _federationDirectiveNames.Contains(directiveDef.Name.Value): + return null; + + case SchemaDefinitionNode schemaDef: + return TransformSchemaDefinition(schemaDef); + + case SchemaExtensionNode schemaExt: + return TransformSchemaExtension(schemaExt); + + case ObjectTypeDefinitionNode objectType + when objectType.Name.Value.Equals( + analysis.QueryTypeName, + StringComparison.Ordinal): + return RemoveQueryFederationFields(objectType); + + default: + return definition; + } + } + + private static IDefinitionNode? TransformSchemaDefinition(SchemaDefinitionNode schemaDef) + { + var nonLinkDirectives = new List(); + + foreach (var directive in schemaDef.Directives) + { + if (!directive.Name.Value.Equals( + FederationDirectiveNames.Link, + StringComparison.Ordinal)) + { + nonLinkDirectives.Add(directive); + } + } + + // If the schema definition only had @link directives and has standard + // operation types, remove it entirely. + if (nonLinkDirectives.Count == 0 && HasOnlyStandardOperationTypes(schemaDef)) + { + return null; + } + + // Otherwise keep it but strip the @link directives. + if (nonLinkDirectives.Count != schemaDef.Directives.Count) + { + return schemaDef.WithDirectives(nonLinkDirectives); + } + + return schemaDef; + } + + private static IDefinitionNode? TransformSchemaExtension(SchemaExtensionNode schemaExt) + { + var nonLinkDirectives = new List(); + + foreach (var directive in schemaExt.Directives) + { + if (!directive.Name.Value.Equals( + FederationDirectiveNames.Link, + StringComparison.Ordinal)) + { + nonLinkDirectives.Add(directive); + } + } + + // If only @link directives, remove entirely. + if (nonLinkDirectives.Count == 0) + { + return null; + } + + if (nonLinkDirectives.Count != schemaExt.Directives.Count) + { + return schemaExt.WithDirectives(nonLinkDirectives); + } + + return schemaExt; + } + + private static bool HasOnlyStandardOperationTypes(SchemaDefinitionNode schemaDef) + { + foreach (var operationType in schemaDef.OperationTypes) + { + var isStandard = operationType.Operation switch + { + OperationType.Query + => operationType.Type.Name.Value.Equals("Query", StringComparison.Ordinal), + OperationType.Mutation + => operationType.Type.Name.Value.Equals("Mutation", StringComparison.Ordinal), + OperationType.Subscription + => operationType.Type.Name.Value.Equals( + "Subscription", + StringComparison.Ordinal), + _ => false + }; + + if (!isStandard) + { + return false; + } + } + + return true; + } + + private static ObjectTypeDefinitionNode RemoveQueryFederationFields( + ObjectTypeDefinitionNode queryType) + { + var filteredFields = new List(queryType.Fields.Count); + + foreach (var field in queryType.Fields) + { + if (!_federationFieldNames.Contains(field.Name.Value)) + { + filteredFields.Add(field); + } + } + + if (filteredFields.Count == queryType.Fields.Count) + { + return queryType; + } + + return queryType.WithFields(filteredFields); + } +} 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..98ad743f475 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs @@ -0,0 +1,157 @@ +using HotChocolate.Language; + +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 document. + /// + /// + /// The document to transform. + /// + /// + /// A new document with rewritten @key directives. + /// + public static DocumentNode Apply(DocumentNode document) + { + var definitions = new List(document.Definitions.Count); + var changed = false; + + foreach (var definition in document.Definitions) + { + switch (definition) + { + case ObjectTypeDefinitionNode objectType: + { + var rewritten = RewriteDirectivesOnObjectType(objectType); + + if (!ReferenceEquals(rewritten, objectType)) + { + changed = true; + } + + definitions.Add(rewritten); + break; + } + + case InterfaceTypeDefinitionNode interfaceType: + { + var rewritten = RewriteDirectivesOnInterfaceType(interfaceType); + + if (!ReferenceEquals(rewritten, interfaceType)) + { + changed = true; + } + + definitions.Add(rewritten); + break; + } + + default: + definitions.Add(definition); + break; + } + } + + if (!changed) + { + return document; + } + + return document.WithDefinitions(definitions); + } + + private static ObjectTypeDefinitionNode RewriteDirectivesOnObjectType( + ObjectTypeDefinitionNode objectType) + { + var rewritten = RewriteKeyDirectivesInList(objectType.Directives); + + if (rewritten is null) + { + return objectType; + } + + return objectType.WithDirectives(rewritten); + } + + private static InterfaceTypeDefinitionNode RewriteDirectivesOnInterfaceType( + InterfaceTypeDefinitionNode interfaceType) + { + var rewritten = RewriteKeyDirectivesInList(interfaceType.Directives); + + if (rewritten is null) + { + return interfaceType; + } + + return interfaceType.WithDirectives(rewritten); + } + + private static List? RewriteKeyDirectivesInList( + IReadOnlyList directives) + { + List? result = null; + + for (var i = 0; i < directives.Count; i++) + { + var directive = directives[i]; + + if (!directive.Name.Value.Equals( + FederationDirectiveNames.Key, + StringComparison.Ordinal)) + { + result?.Add(directive); + continue; + } + + // Check if we need to strip the resolvable argument. + var hasResolvable = false; + + foreach (var argument in directive.Arguments) + { + if (argument.Name.Value.Equals("resolvable", StringComparison.Ordinal)) + { + hasResolvable = true; + break; + } + } + + if (!hasResolvable) + { + result?.Add(directive); + continue; + } + + // Lazily create the result list and copy previous items. + if (result is null) + { + result = new List(directives.Count); + + for (var j = 0; j < i; j++) + { + result.Add(directives[j]); + } + } + + // Keep only the "fields" argument. + var fieldsOnlyArgs = new List(); + + foreach (var argument in directive.Arguments) + { + if (argument.Name.Value.Equals("fields", StringComparison.Ordinal)) + { + fieldsOnlyArgs.Add(argument); + } + } + + result.Add(directive.WithArguments(fieldsOnlyArgs)); + } + + return result; + } +} 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..761f44a64a2 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs @@ -0,0 +1,329 @@ +using HotChocolate.Language; + +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 document. + /// + /// + /// The document to transform. + /// + /// + /// The analysis result containing field type metadata. + /// + /// + /// A new document with @requires directives replaced by + /// @require argument directives. + /// + public static DocumentNode Apply(DocumentNode document, AnalysisResult analysis) + { + var definitions = new List(document.Definitions.Count); + var changed = false; + + foreach (var definition in document.Definitions) + { + if (definition is ObjectTypeDefinitionNode objectType) + { + var transformed = TransformObjectType(objectType, analysis); + + if (!ReferenceEquals(transformed, objectType)) + { + changed = true; + } + + definitions.Add(transformed); + } + else + { + definitions.Add(definition); + } + } + + if (!changed) + { + return document; + } + + return document.WithDefinitions(definitions); + } + + private static ObjectTypeDefinitionNode TransformObjectType( + ObjectTypeDefinitionNode objectType, + AnalysisResult analysis) + { + var typeName = objectType.Name.Value; + var fields = new List(objectType.Fields.Count); + var anyFieldChanged = false; + + foreach (var field in objectType.Fields) + { + var transformed = TransformField(field, typeName, analysis); + + if (!ReferenceEquals(transformed, field)) + { + anyFieldChanged = true; + } + + fields.Add(transformed); + } + + if (!anyFieldChanged) + { + return objectType; + } + + return objectType.WithFields(fields); + } + + private static FieldDefinitionNode TransformField( + FieldDefinitionNode field, + string typeName, + AnalysisResult analysis) + { + DirectiveNode? requiresDirective = null; + + foreach (var directive in field.Directives) + { + if (directive.Name.Value.Equals( + FederationDirectiveNames.Requires, + StringComparison.Ordinal)) + { + requiresDirective = directive; + break; + } + } + + if (requiresDirective is null) + { + return field; + } + + var fieldsValue = GetStringArgument(requiresDirective, "fields"); + + if (fieldsValue is null) + { + return field; + } + + SelectionSetNode selectionSet; + + try + { + selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet( + "{ " + fieldsValue + " }"); + } + catch (SyntaxException) + { + return field; + } + + if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes)) + { + return field; + } + + var newArguments = new List(field.Arguments); + + ExtractRequireArguments(selectionSet, [], typeName, analysis, newArguments); + + // Remove the @requires directive from the field. + var newDirectives = new List(field.Directives.Count); + + foreach (var directive in field.Directives) + { + if (!directive.Name.Value.Equals( + FederationDirectiveNames.Requires, + StringComparison.Ordinal)) + { + newDirectives.Add(directive); + } + } + + return field + .WithArguments(newArguments) + .WithDirectives(newDirectives); + } + + private static void ExtractRequireArguments( + SelectionSetNode selectionSet, + List parentPath, + string currentTypeName, + AnalysisResult analysis, + List arguments) + { + 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. + var nestedTypeName = ResolveFieldTypeName(currentTypeName, fieldName, analysis); + + if (nestedTypeName is null) + { + continue; + } + + var nestedPath = new List(parentPath) { fieldName }; + ExtractRequireArguments( + fieldNode.SelectionSet!, + nestedPath, + nestedTypeName, + analysis, + arguments); + } + else + { + // Leaf field: generate an argument. + var fieldType = ResolveFieldType(currentTypeName, fieldName, analysis); + + if (fieldType is null) + { + continue; + } + + var nonNullType = EnsureNonNull(StripNonNull(fieldType)); + + string requireFieldValue; + + if (parentPath.Count == 0) + { + requireFieldValue = fieldName; + } + else + { + requireFieldValue = BuildFieldPath(parentPath, fieldName); + } + + var requireDirective = new DirectiveNode( + "require", + new ArgumentNode("field", new StringValueNode(requireFieldValue))); + + arguments.Add( + new InputValueDefinitionNode( + null, + new NameNode(fieldName), + null, + nonNullType, + null, + [requireDirective])); + } + } + } + + 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 string? ResolveFieldTypeName( + string typeName, + string fieldName, + AnalysisResult analysis) + { + var fieldType = ResolveFieldType(typeName, fieldName, analysis); + + if (fieldType is null) + { + return null; + } + + return GetNamedTypeName(fieldType); + } + + private static ITypeNode? ResolveFieldType( + string typeName, + string fieldName, + AnalysisResult analysis) + { + if (analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes) + && fieldTypes.TryGetValue(fieldName, out var fieldType)) + { + return fieldType; + } + + return null; + } + + private static string? GetNamedTypeName(ITypeNode typeNode) + { + return typeNode switch + { + NamedTypeNode named => named.Name.Value, + NonNullTypeNode nonNull => GetNamedTypeName(nonNull.Type), + ListTypeNode list => GetNamedTypeName(list.Type), + _ => null + }; + } + + private static ITypeNode StripNonNull(ITypeNode typeNode) + { + if (typeNode is NonNullTypeNode nonNull) + { + return nonNull.Type; + } + + return typeNode; + } + + private static ITypeNode EnsureNonNull(ITypeNode typeNode) + { + if (typeNode is NonNullTypeNode) + { + return typeNode; + } + + if (typeNode is INullableTypeNode nullable) + { + return new NonNullTypeNode(nullable); + } + + return typeNode; + } + + private static string? GetStringArgument(DirectiveNode directive, string argumentName) + { + foreach (var argument in directive.Arguments) + { + if (argument.Name.Value.Equals(argumentName, StringComparison.Ordinal) + && argument.Value is StringValueNode stringValue) + { + return stringValue.Value; + } + } + + return null; + } +} 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..be66b5a1810 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs @@ -0,0 +1,598 @@ +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 TransformToSourceSchema() + { + // 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.TransformToSourceSchema( + "products", + federationSdl); + + // assert + Assert.True(result.IsSuccess); + Assert.Equal("products", result.Value.Name); + Snapshot.Create() + .Add(federationSdl, "Apollo Federation SDL", "graphql") + .Add(result.Value.SourceText, "Transformed Source Schema", "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_ProgressiveOverride_Should_ReturnError() + { + // arrange + const string federationSdl = + """ + schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@override"]) { + query: Query + } + type Product @key(fields: "id") { + id: ID! + name: String @override(from: "other", label: "percent(50)") + } + 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 @override(from: String!, label: String) on FIELD_DEFINITION + 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("@override") && e.Message.Contains("label")); + } + + [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 TransformToSourceSchema_Should_PropagateErrors() + { + // arrange + const string federationSdl = "not valid graphql"; + + // act + var result = FederationSchemaTransformer.TransformToSourceSchema( + "products", + federationSdl); + + // assert + Assert.True(result.IsFailure); + Assert.True(result.Errors.Length > 0); + } + + [Fact] + public void Transform_EmptyString_Should_ThrowArgumentException() + { + // arrange & act & assert + Assert.Throws( + () => FederationSchemaTransformer.Transform(string.Empty)); + } + + [Fact] + public void TransformToSourceSchema_EmptySchemaName_Should_ThrowArgumentException() + { + // arrange & act & assert + Assert.Throws( + () => FederationSchemaTransformer.TransformToSourceSchema( + string.Empty, + "type Query { hello: String }")); + } +} 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.TransformToSourceSchema.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.TransformToSourceSchema.md new file mode 100644 index 00000000000..9aee7430392 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.TransformToSourceSchema.md @@ -0,0 +1,38 @@ +# TransformToSourceSchema + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed Source Schema + +```graphql +type Product @key(fields: "id") { + id: ID! + name: String +} + +type Query { + product(id: ID!): Product + productById(id: ID!): Product @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md new file mode 100644 index 00000000000..38e905c81ef --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md @@ -0,0 +1,40 @@ +# Transform_CompositeKey + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type Product @key(fields: "sku package") { + sku: String! + package: String! + name: String +} + +type Query { + products: [Product] + productBySkuAndPackage(sku: String! package: String!): Product @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md new file mode 100644 index 00000000000..362cf442b86 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md @@ -0,0 +1,39 @@ +# Transform_ExternalDirective + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type Product @key(fields: "id") { + id: ID! + price: Float @external +} + +type Query { + products: [Product] + productById(id: ID!): Product @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md new file mode 100644 index 00000000000..1a110ccb6b9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md @@ -0,0 +1,77 @@ +# Transform_FullIntegration + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +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] + productById(id: ID!): Product @internal @lookup + productBySkuAndPackage(sku: String! package: String!): Product @internal @lookup + userById(id: ID!): User @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md new file mode 100644 index 00000000000..7fdb85e0cfc --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md @@ -0,0 +1,38 @@ +# Transform_KeyResolvableArgument + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type Product @key(fields: "id") { + id: ID! + name: String +} + +type Query { + products: [Product] + productById(id: ID!): Product @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md new file mode 100644 index 00000000000..b08c2cd03b5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md @@ -0,0 +1,43 @@ +# Transform_MultipleKeys + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type Product @key(fields: "id") @key(fields: "sku package") { + id: ID! + sku: String! + package: String! + name: String +} + +type Query { + products: [Product] + productById(id: ID!): Product @internal @lookup + productBySkuAndPackage(sku: String! package: String!): Product @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md new file mode 100644 index 00000000000..6104619aadd --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md @@ -0,0 +1,40 @@ +# Transform_NonResolvableAndResolvableKeys + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type Product @key(fields: "id") @key(fields: "sku") { + id: ID! + sku: String! + name: String +} + +type Query { + products: [Product] + productById(id: ID!): Product @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md new file mode 100644 index 00000000000..b3afe06d337 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md @@ -0,0 +1,37 @@ +# Transform_NonResolvableKey + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type Product @key(fields: "id") { + id: ID! + name: String +} + +type Query { + products: [Product] +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md new file mode 100644 index 00000000000..aa965091810 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md @@ -0,0 +1,50 @@ +# Transform_ProvidesDirective + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type User @key(fields: "id") { + id: ID! + username: String + totalProductsCreated: Int +} + +type Review { + body: String + author: User @provides(fields: "username") +} + +type Query { + reviews: [Review] + userById(id: ID!): User @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md new file mode 100644 index 00000000000..fee02105ffd --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md @@ -0,0 +1,44 @@ +# Transform_RequiresDirective + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type Product @key(fields: "id") { + id: ID! + price: Float @external + weight: Float @external + shippingEstimate(price: Float! @require(field: "price") weight: Float! @require(field: "weight")): Float +} + +type Query { + product(id: ID!): Product + productById(id: ID!): Product @internal @lookup +} +``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md new file mode 100644 index 00000000000..995e87d7bd9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md @@ -0,0 +1,38 @@ +# Transform_SimpleEntity + +## Apollo Federation SDL + +```graphql +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 +``` + +## Transformed SDL + +```graphql +type Product @key(fields: "id") { + id: ID! + name: String +} + +type Query { + product(id: ID!): Product + productById(id: ID!): Product @internal @lookup +} +``` From f1318a229ecccd8230c9462f060c99b93e95e416 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 29 Mar 2026 03:58:57 +0000 Subject: [PATCH 2/6] adds basic adapter --- .../Fusion/HotChocolate.Fusion.slnx | 2 + .../ApolloFederationSourceSchemaClient.cs | 562 ++++++++++++++++++ ...derationSourceSchemaClientConfiguration.cs | 41 ++ ...olloFederationSourceSchemaClientFactory.cs | 41 ++ .../FederationQueryRewriter.cs | 163 +++++ ....Fusion.Connectors.ApolloFederation.csproj | 21 + .../LookupFieldInfo.cs | 19 + .../RewrittenOperation.cs | 43 ++ .../Clients/SourceSchemaClientRequest.cs | 6 + .../Nodes/OperationBatchExecutionNode.cs | 6 +- .../Execution/Nodes/OperationDefinition.cs | 10 + .../Execution/Nodes/OperationExecutionNode.cs | 10 +- .../HotChocolate.Fusion.Execution.csproj | 1 + .../FusionExecutionResources.Designer.cs | 34 +- .../Properties/FusionExecutionResources.resx | 3 - .../ApolloFederationConnectorTests.cs | 226 +++++++ ...n.Connectors.ApolloFederation.Tests.csproj | 15 + .../SchemaTransformationIntegrationTests.cs | 256 ++++++++ ...esolveEntities_FromFederationSubgraph.json | 16 + ...llRoundtrip_Transform_Rewrite_Execute.json | 11 + ...upToEntities_FromTransformedSchema.graphql | 11 + ...Should_ProduceValidCompositeSchema.graphql | 17 + .../HotChocolate.Utilities.Buffers.csproj | 1 + 23 files changed, 1488 insertions(+), 27 deletions(-) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientFactory.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/HotChocolate.Fusion.Connectors.ApolloFederation.csproj create mode 100644 src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/HotChocolate.Fusion.Connectors.ApolloFederation.Tests.csproj create mode 100644 src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs create mode 100644 src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.EntitiesQuery_Should_ResolveEntities_FromFederationSubgraph.json create mode 100644 src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.FullRoundtrip_Transform_Rewrite_Execute.json create mode 100644 src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Rewriter_Should_RewriteLookupToEntities_FromTransformedSchema.graphql create mode 100644 src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx index c196f3a84e3..0b7b3008656 100644 --- a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx +++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx @@ -13,6 +13,7 @@ + @@ -26,6 +27,7 @@ + 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..13c16cdd38e --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs @@ -0,0 +1,562 @@ +using System.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using HotChocolate.Buffers; +using HotChocolate.Fusion.Execution; +using HotChocolate.Fusion.Text.Json; +using HotChocolate.Fusion.Transport; +using HotChocolate.Fusion.Transport.Http; +using HotChocolate.Text.Json; + +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); + + for (var i = 0; i < requests.Length; i++) + { + var request = requests[i]; + var response = await ExecuteAsync(context, request, cancellationToken) + .ConfigureAwait(false); + + await foreach (var result in response.ReadAsResultStreamAsync(cancellationToken) + .ConfigureAwait(false)) + { + yield return new BatchStreamResult(i, result); + } + } + } + + 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); + + writer.WriteStartArray(); + + if (variableSets.IsDefaultOrEmpty) + { + // Single empty representation with just __typename. + 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) + { + // Parse the variable values JSON to extract key fields. + 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(); // advance to value + + if (variableToKeyFieldMap.TryGetValue(propertyName, out var keyFieldName)) + { + writer.WritePropertyName(keyFieldName); + WriteCurrentValue(writer, ref reader); + } + else + { + reader.Skip(); + } + } + } + } + + writer.WriteEndObject(); + } + } + + writer.WriteEndArray(); + 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 + ? ImmutableArray.Empty + : 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; + ImmutableArray 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(); + } + + 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; + } + } + + 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..82d0bdc286b --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs @@ -0,0 +1,41 @@ +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 supported operation types. + public ApolloFederationSourceSchemaClientConfiguration( + string name, + string httpClientName, + SupportedOperationType supportedOperations = SupportedOperationType.Query | SupportedOperationType.Mutation) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(httpClientName); + + Name = name; + HttpClientName = httpClientName; + SupportedOperations = supportedOperations; + } + + /// + public string Name { get; } + + /// + /// Gets the name of the underlying HTTP client. + /// + public string HttpClientName { 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..c979718a7f4 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientFactory.cs @@ -0,0 +1,41 @@ +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 FederationQueryRewriter _queryRewriter; + + /// + /// Initializes a new instance of . + /// + /// The HTTP client factory. + /// + /// The query rewriter shared across all clients for this source schema. + /// + internal ApolloFederationSourceSchemaClientFactory( + IHttpClientFactory httpClientFactory, + FederationQueryRewriter queryRewriter) + { + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(queryRewriter); + + _httpClientFactory = httpClientFactory; + _queryRewriter = queryRewriter; + } + + /// + protected override ISourceSchemaClient CreateClient( + ApolloFederationSourceSchemaClientConfiguration configuration) + { + var httpClient = _httpClientFactory.CreateClient(configuration.HttpClientName); + var graphQLClient = GraphQLHttpClient.Create(httpClient, disposeHttpClient: true); + return new ApolloFederationSourceSchemaClient(graphQLClient, _queryRewriter); + } +} 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..05fe7ad1eb2 --- /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 Dictionary _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(Dictionary 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(operationDefinition, 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( + OperationDefinitionNode operationDefinition, + 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 + }; + } + + 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..cba476c833b --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/HotChocolate.Fusion.Connectors.ApolloFederation.csproj @@ -0,0 +1,21 @@ + + + + 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..eaad771530e --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs @@ -0,0 +1,43 @@ +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; } +} 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/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..d55475c2d7b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationDefinition.cs @@ -1,3 +1,5 @@ +using System.IO.Hashing; +using System.Text; using HotChocolate.Execution; namespace HotChocolate.Fusion.Execution.Nodes; @@ -7,6 +9,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 +28,7 @@ protected OperationDefinition( { Id = id; Operation = operation; + _operationHash = XxHash64.HashToUInt64(Encoding.UTF8.GetBytes(operation.SourceText)); SchemaName = schemaName; Source = source; _requirements = requirements; @@ -45,6 +49,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 217408ffddf..65c3177500a 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/OperationExecutionNode.cs @@ -1,8 +1,10 @@ using System.Buffers; using System.Collections.Immutable; using System.Diagnostics; +using System.IO.Hashing; using System.Reactive.Disposables; using System.Runtime.InteropServices; +using System.Text; using HotChocolate.Execution; using HotChocolate.Fusion.Diagnostics; using HotChocolate.Fusion.Execution.Clients; @@ -17,6 +19,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 +38,7 @@ internal OperationExecutionNode( { Id = id; _operation = operation; + _operationHash = XxHash64.HashToUInt64(Encoding.UTF8.GetBytes(operation.SourceText)); _schemaName = schemaName; _target = target; _source = source; @@ -119,7 +123,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 +280,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 3a07027e428..bd9752f6697 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 615d774f6c1..4a7babffb6e 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 ce9782f6f98..2e25c5854b3 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.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs new file mode 100644 index 00000000000..01a5997e416 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs @@ -0,0 +1,226 @@ +using HotChocolate.Fusion.Execution.Clients; + +namespace HotChocolate.Fusion; + +public class ApolloFederationConnectorTests +{ + [Fact] + public void Configuration_Should_StoreProperties() + { + // arrange & act + var config = new ApolloFederationSourceSchemaClientConfiguration( + "products", + "products-http", + SupportedOperationType.Query); + + // assert + Assert.Equal("products", config.Name); + Assert.Equal("products-http", config.HttpClientName); + Assert.Equal(SupportedOperationType.Query, config.SupportedOperations); + } + + [Fact] + public void Configuration_Should_DefaultToQueryAndMutation() + { + // arrange & act + var config = new ApolloFederationSourceSchemaClientConfiguration( + "products", + "products-http"); + + // assert + Assert.Equal( + SupportedOperationType.Query | SupportedOperationType.Mutation, + config.SupportedOperations); + } + + [Fact] + public void Rewrite_SimpleLookup_Should_ProduceEntitiesQuery() + { + // arrange + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields); + + const string sourceText = """ + query GetProduct($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + id + name + price + } + } + """; + + // act + var result = rewriter.GetOrRewrite(sourceText, 12345UL); + + // assert + Assert.True(result.IsEntityLookup); + Assert.Equal("Product", result.EntityTypeName); + Assert.Contains("_entities", result.OperationText); + Assert.Contains("representations", result.OperationText); + Assert.Contains("... on Product", result.OperationText); + Assert.Contains("name", result.OperationText); + Assert.Contains("price", result.OperationText); + Assert.Equal("id", result.VariableToKeyFieldMap["__fusion_1_id"]); + } + + [Fact] + public void Rewrite_CompositeKeyLookup_Should_MapMultipleArguments() + { + // arrange + var lookupFields = new Dictionary + { + ["productBySkuAndPackage"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary + { + ["sku"] = "sku", + ["package"] = "package" + } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields); + + const string sourceText = """ + query Op($__fusion_1_sku: String!, $__fusion_1_package: String!) { + productBySkuAndPackage(sku: $__fusion_1_sku, package: $__fusion_1_package) { + sku + package + name + } + } + """; + + // act + var result = rewriter.GetOrRewrite(sourceText, 12346UL); + + // assert + Assert.True(result.IsEntityLookup); + Assert.Equal("Product", result.EntityTypeName); + Assert.Equal("sku", result.VariableToKeyFieldMap["__fusion_1_sku"]); + Assert.Equal("package", result.VariableToKeyFieldMap["__fusion_1_package"]); + } + + [Fact] + public void Rewrite_NonLookupField_Should_BePassthrough() + { + // arrange + var lookupFields = new Dictionary(); + var rewriter = new FederationQueryRewriter(lookupFields); + + const string sourceText = """ + query { + topProducts { + id + name + } + } + """; + + // act + var result = rewriter.GetOrRewrite(sourceText, 12347UL); + + // assert + Assert.False(result.IsEntityLookup); + Assert.Null(result.EntityTypeName); + Assert.Null(result.LookupFieldName); + Assert.DoesNotContain("_entities", result.OperationText); + } + + [Fact] + public void GetOrRewrite_SameHash_Should_ReturnCachedResult() + { + // arrange + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields); + + const string sourceText = """ + query Op($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { id name } + } + """; + + // act + var result1 = rewriter.GetOrRewrite(sourceText, 99UL); + var result2 = rewriter.GetOrRewrite(sourceText, 99UL); + + // assert + Assert.Same(result1, result2); + } + + [Fact] + public void Rewrite_SimpleLookup_Should_ProduceCorrectVariableDefinition() + { + // arrange + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields); + + const string sourceText = """ + query GetProduct($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + id + name + } + } + """; + + // act + var result = rewriter.GetOrRewrite(sourceText, 55555UL); + + // assert — the rewritten query should declare $representations: [_Any!]! + Assert.Contains("$representations: [_Any!]!", result.OperationText); + Assert.Equal("productById", result.LookupFieldName); + } + + [Fact] + public void Rewrite_DifferentHashes_Should_ReturnDistinctResults() + { + // arrange + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields); + + const string sourceText = """ + query Op($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { id name } + } + """; + + // act + var result1 = rewriter.GetOrRewrite(sourceText, 100UL); + var result2 = rewriter.GetOrRewrite(sourceText, 200UL); + + // assert — different hash keys produce separate cache entries + Assert.NotSame(result1, result2); + // but the content should be equivalent since the source text is the same + Assert.Equal(result1.OperationText, result2.OperationText); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/HotChocolate.Fusion.Connectors.ApolloFederation.Tests.csproj b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/HotChocolate.Fusion.Connectors.ApolloFederation.Tests.csproj new file mode 100644 index 00000000000..e0b7271069e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/HotChocolate.Fusion.Connectors.ApolloFederation.Tests.csproj @@ -0,0 +1,15 @@ + + + + + HotChocolate.Fusion.Connectors.ApolloFederation.Tests + HotChocolate.Fusion + + + + + + + + + diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs new file mode 100644 index 00000000000..0f591b1c6d8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs @@ -0,0 +1,256 @@ +using HotChocolate.ApolloFederation.Resolvers; +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Execution; +using HotChocolate.Fusion.ApolloFederation; +using HotChocolate.Fusion.Execution.Clients; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion; + +public class SchemaTransformationIntegrationTests +{ + [Fact] + public async Task Transform_FederationSubgraph_Should_ProduceValidCompositeSchema() + { + // arrange: build an Apollo Federation subgraph and get its SDL + var schema = await new ServiceCollection() + .AddGraphQL() + .AddApolloFederation() + .AddQueryType() + .AddType() + .AddType() + .BuildSchemaAsync(); + + var federationSdl = schema.ToString(); + + // act: transform the federation SDL + var result = FederationSchemaTransformer.Transform(federationSdl); + + // assert + Assert.True( + result.IsSuccess, + $"Transform failed: {string.Join(", ", result.Errors.Select(e => e.Message))}"); + + var compositeSdl = result.Value; + + // Should have @key directives + Assert.Contains("@key", compositeSdl); + + // Should have @lookup fields + Assert.Contains("@lookup", compositeSdl); + + // Should NOT have federation infrastructure + Assert.DoesNotContain("_entities", compositeSdl); + Assert.DoesNotContain("_service", compositeSdl); + Assert.DoesNotContain("_Service", compositeSdl); + Assert.DoesNotContain("_Entity", compositeSdl); + Assert.DoesNotContain("_Any", compositeSdl); + + // Snapshot the output + compositeSdl.MatchSnapshot(extension: ".graphql"); + } + + [Fact] + public async Task Rewriter_Should_RewriteLookupToEntities_FromTransformedSchema() + { + // arrange: build Federation subgraph, transform, extract lookup fields + var schema = await new ServiceCollection() + .AddGraphQL() + .AddApolloFederation() + .AddQueryType() + .AddType() + .BuildSchemaAsync(); + + var federationSdl = schema.ToString(); + var result = FederationSchemaTransformer.Transform(federationSdl); + + Assert.True( + result.IsSuccess, + $"Transform failed: {string.Join(", ", result.Errors.Select(e => e.Message))}"); + + // Parse the composite SDL to find @lookup fields + // (In a real scenario, the connector would do this from the MutableSchemaDefinition) + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + + var rewriter = new FederationQueryRewriter(lookupFields); + + // Simulate what the Fusion planner would generate + const string plannerQuery = """ + query Op($__fusion_1_id: Int!) { + productById(id: $__fusion_1_id) { + id + name + price + } + } + """; + + // act + var rewritten = rewriter.GetOrRewrite(plannerQuery, 42UL); + + // assert + Assert.True(rewritten.IsEntityLookup); + Assert.Equal("Product", rewritten.EntityTypeName); + Assert.Contains("_entities", rewritten.OperationText); + Assert.Contains("... on Product", rewritten.OperationText); + Assert.Equal("id", rewritten.VariableToKeyFieldMap["__fusion_1_id"]); + + rewritten.OperationText.MatchSnapshot(extension: ".graphql"); + } + + [Fact] + public async Task EntitiesQuery_Should_ResolveEntities_FromFederationSubgraph() + { + // arrange: build Federation subgraph executor + var executor = await new ServiceCollection() + .AddGraphQL() + .AddApolloFederation() + .AddQueryType() + .AddType() + .BuildRequestExecutorAsync(); + + // This is the query our connector would generate after rewriting + var request = OperationRequestBuilder + .New() + .SetDocument( + """ + query($representations: [_Any!]!) { + _entities(representations: $representations) { + ... on Product { + id + name + price + } + } + } + """) + .SetVariableValues( + """ + { + "representations": [ + { "__typename": "Product", "id": 1 }, + { "__typename": "Product", "id": 2 } + ] + } + """) + .Build(); + + // act + var result = await executor.ExecuteAsync(request); + + // assert + result.ToJson().MatchSnapshot(extension: ".json"); + } + + [Fact] + public async Task FullRoundtrip_Transform_Rewrite_Execute() + { + // 1. Build Federation subgraph + var executor = await new ServiceCollection() + .AddGraphQL() + .AddApolloFederation() + .AddQueryType() + .AddType() + .AddType() + .BuildRequestExecutorAsync(); + + // 2. Get and transform the SDL + var federationSdl = executor.Schema.ToString(); + var transformResult = FederationSchemaTransformer.Transform(federationSdl); + + Assert.True( + transformResult.IsSuccess, + $"Transform failed: {string.Join(", ", transformResult.Errors.Select(e => e.Message))}"); + + // 3. Set up rewriter with lookup fields extracted from transformed schema + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + }, + ["userByEmail"] = new LookupFieldInfo + { + EntityTypeName = "User", + ArgumentToKeyFieldMap = new Dictionary { ["email"] = "email" } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields); + + // 4. Simulate planner query and rewrite + const string plannerQuery = """ + query($__fusion_1_id: Int!) { + productById(id: $__fusion_1_id) { + id + name + price + } + } + """; + + var rewritten = rewriter.GetOrRewrite(plannerQuery, 100UL); + + // 5. Execute the rewritten _entities query against the real subgraph + var request = OperationRequestBuilder + .New() + .SetDocument(rewritten.OperationText) + .SetVariableValues( + """ + { + "representations": [ + { "__typename": "Product", "id": 1 } + ] + } + """) + .Build(); + + var result = await executor.ExecuteAsync(request); + + // 6. Verify we got the entity back + var json = result.ToJson(); + Assert.Contains("Product 1", json); + json.MatchSnapshot(extension: ".json"); + } + + [Key("id")] + [ReferenceResolver(EntityResolver = nameof(ResolveById))] + public sealed class Product + { + public int Id { get; set; } + + public string Name { get; set; } = default!; + + public float Price { get; set; } + + public static Product ResolveById(int id) + => new() { Id = id, Name = $"Product {id}", Price = 9.99f }; + } + + [Key("email")] + [ReferenceResolver(EntityResolver = nameof(ResolveByEmail))] + public sealed class User + { + public string Email { get; set; } = default!; + + public string Name { get; set; } = default!; + + public static User ResolveByEmail(string email) + => new() { Email = email, Name = $"User {email}" }; + } + + public class Query + { + public Product? GetProduct(int id) => Product.ResolveById(id); + + public List GetTopProducts() + => [Product.ResolveById(1), Product.ResolveById(2)]; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.EntitiesQuery_Should_ResolveEntities_FromFederationSubgraph.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.EntitiesQuery_Should_ResolveEntities_FromFederationSubgraph.json new file mode 100644 index 00000000000..470a961ccd3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.EntitiesQuery_Should_ResolveEntities_FromFederationSubgraph.json @@ -0,0 +1,16 @@ +{ + "data": { + "_entities": [ + { + "id": 1, + "name": "Product 1", + "price": 9.989999771118164 + }, + { + "id": 2, + "name": "Product 2", + "price": 9.989999771118164 + } + ] + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.FullRoundtrip_Transform_Rewrite_Execute.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.FullRoundtrip_Transform_Rewrite_Execute.json new file mode 100644 index 00000000000..6de72e28010 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.FullRoundtrip_Transform_Rewrite_Execute.json @@ -0,0 +1,11 @@ +{ + "data": { + "_entities": [ + { + "id": 1, + "name": "Product 1", + "price": 9.989999771118164 + } + ] + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Rewriter_Should_RewriteLookupToEntities_FromTransformedSchema.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Rewriter_Should_RewriteLookupToEntities_FromTransformedSchema.graphql new file mode 100644 index 00000000000..0edd53aa19e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Rewriter_Should_RewriteLookupToEntities_FromTransformedSchema.graphql @@ -0,0 +1,11 @@ +query( + $representations: [_Any!]! +) { + _entities(representations: $representations) { + ... on Product { + id + name + price + } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql new file mode 100644 index 00000000000..5dafb527355 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql @@ -0,0 +1,17 @@ +type Product @key(fields: "id") { + id: Int! + name: String! + price: Float! +} + +type Query { + product(id: Int!): Product + topProducts: [Product!]! + productById(id: Int!): Product @internal @lookup + userByEmail(email: String!): User @internal @lookup +} + +type User @key(fields: "email") { + email: String! + name: String! +} diff --git a/src/HotChocolate/Utilities/src/Utilities.Buffers/HotChocolate.Utilities.Buffers.csproj b/src/HotChocolate/Utilities/src/Utilities.Buffers/HotChocolate.Utilities.Buffers.csproj index 66d41f10584..d741eaeb2d8 100644 --- a/src/HotChocolate/Utilities/src/Utilities.Buffers/HotChocolate.Utilities.Buffers.csproj +++ b/src/HotChocolate/Utilities/src/Utilities.Buffers/HotChocolate.Utilities.Buffers.csproj @@ -26,6 +26,7 @@ + From 60134194ae05b9552976113642467dce32e82ab7 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 7 Apr 2026 06:04:41 +0000 Subject: [PATCH 3/6] edits --- .../ApolloFederationSourceSchemaClient.cs | 510 ++++++++++++++---- .../FederationQueryRewriter.cs | 3 +- .../RewrittenOperation.cs | 9 + .../ApolloFederationConnectorTests.cs | 145 +++++ .../SchemaTransformationIntegrationTests.cs | 55 ++ ...ery_Should_ResolveMultipleEntityTypes.json | 22 + 6 files changed, 625 insertions(+), 119 deletions(-) create mode 100644 src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.BatchedEntitiesQuery_Should_ResolveMultipleEntityTypes.json diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs index 13c16cdd38e..6d0e5eaa1b8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClient.cs @@ -7,6 +7,7 @@ using HotChocolate.Fusion.Text.Json; using HotChocolate.Fusion.Transport; using HotChocolate.Fusion.Transport.Http; +using HotChocolate.Language; using HotChocolate.Text.Json; namespace HotChocolate.Fusion.Execution.Clients; @@ -75,18 +76,402 @@ public async IAsyncEnumerable ExecuteBatchStreamAsync( { ArgumentNullException.ThrowIfNull(context); - for (var i = 0; i < requests.Length; i++) + // Single request: use the simple path (no aliasing needed). + if (requests.Length == 1) { - var request = requests[i]; - var response = await ExecuteAsync(context, request, cancellationToken) + var response = await ExecuteAsync(context, requests[0], cancellationToken) .ConfigureAwait(false); await foreach (var result in response.ReadAsResultStreamAsync(cancellationToken) .ConfigureAwait(false)) { - yield return new BatchStreamResult(i, result); + 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; + ImmutableArray additionalPaths; + + if (variables.IsDefaultOrEmpty || j >= variables.Length) + { + resultPath = CompactPath.Root; + additionalPaths = []; + } + 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. + /// + 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)) + { + writer.WritePropertyName(keyFieldName); + WriteCurrentValue(writer, ref reader); + } + else + { + reader.Skip(); + } + } + } + } + + writer.WriteEndObject(); } } + + writer.WriteEndArray(); + } + + /// + /// 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( @@ -168,57 +553,9 @@ private static string BuildRepresentationsJson( using var stream = new MemoryStream(); using var writer = new Utf8JsonWriter(stream); - writer.WriteStartArray(); + WriteRepresentationsArray(writer, variableSets, entityTypeName, variableToKeyFieldMap); - if (variableSets.IsDefaultOrEmpty) - { - // Single empty representation with just __typename. - 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) - { - // Parse the variable values JSON to extract key fields. - 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(); // advance to value - - if (variableToKeyFieldMap.TryGetValue(propertyName, out var keyFieldName)) - { - writer.WritePropertyName(keyFieldName); - WriteCurrentValue(writer, ref reader); - } - else - { - reader.Skip(); - } - } - } - } - - writer.WriteEndObject(); - } - } - - writer.WriteEndArray(); writer.Flush(); - return Encoding.UTF8.GetString(stream.ToArray()); } @@ -463,7 +800,8 @@ public override async IAsyncEnumerable ReadAsResultStreamAsy var entity = entitiesElement[i]; // Build a wrapper: {"data": {"": }} - var entityJson = BuildWrappedEntityJson(lookupFieldName, entity); + var entityJson = ApolloFederationSourceSchemaClient.BuildWrappedEntityJson( + lookupFieldName, entity); var entityBytes = Encoding.UTF8.GetBytes(entityJson); var entityDocument = SourceResultDocument.Parse(entityBytes, entityBytes.Length); @@ -489,70 +827,6 @@ public override async IAsyncEnumerable ReadAsResultStreamAsy sourceDocument.Dispose(); } - 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; - } - } - public override void Dispose() { response.Dispose(); diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs index 05fe7ad1eb2..87e6ee3ee8f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs @@ -144,7 +144,8 @@ private static RewrittenOperation RewriteEntityLookup( IsEntityLookup = true, EntityTypeName = lookupInfo.EntityTypeName, VariableToKeyFieldMap = variableToKeyFieldMap, - LookupFieldName = lookupField.Name.Value + LookupFieldName = lookupField.Name.Value, + InlineFragment = inlineFragment }; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs index eaad771530e..d9fefc84034 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/RewrittenOperation.cs @@ -1,3 +1,5 @@ +using HotChocolate.Language; + namespace HotChocolate.Fusion.Execution.Clients; /// @@ -40,4 +42,11 @@ internal sealed class RewrittenOperation /// 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/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs index 01a5997e416..1d4351a1dab 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs @@ -1,4 +1,6 @@ +using System.Collections.Immutable; using HotChocolate.Fusion.Execution.Clients; +using HotChocolate.Language; namespace HotChocolate.Fusion; @@ -223,4 +225,147 @@ query Op($__fusion_1_id: ID!) { // but the content should be equivalent since the source text is the same Assert.Equal(result1.OperationText, result2.OperationText); } + + [Fact] + public void Rewrite_EntityLookup_Should_IncludeInlineFragment() + { + // arrange + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields); + + const string sourceText = """ + query GetProduct($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { + id + name + price + } + } + """; + + // act + var result = rewriter.GetOrRewrite(sourceText, 77777UL); + + // assert + Assert.True(result.IsEntityLookup); + Assert.NotNull(result.InlineFragment); + Assert.Equal("Product", result.InlineFragment!.TypeCondition!.Name.Value); + + var selectionNames = result.InlineFragment.SelectionSet.Selections + .OfType() + .Select(f => f.Name.Value) + .ToArray(); + Assert.Contains("id", selectionNames); + Assert.Contains("name", selectionNames); + Assert.Contains("price", selectionNames); + } + + [Fact] + public void Rewrite_Passthrough_Should_HaveNullInlineFragment() + { + // arrange + var lookupFields = new Dictionary(); + var rewriter = new FederationQueryRewriter(lookupFields); + + const string sourceText = """ + query { + topProducts { id name } + } + """; + + // act + var result = rewriter.GetOrRewrite(sourceText, 88888UL); + + // assert + Assert.False(result.IsEntityLookup); + Assert.Null(result.InlineFragment); + } + + [Fact] + public void BuildCombinedEntityQuery_Should_ProduceAliasedEntitiesQuery() + { + // arrange + var productLookup = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var userLookup = new Dictionary + { + ["userByEmail"] = new LookupFieldInfo + { + EntityTypeName = "User", + ArgumentToKeyFieldMap = new Dictionary { ["email"] = "email" } + } + }; + + var productRewriter = new FederationQueryRewriter(productLookup); + var userRewriter = new FederationQueryRewriter(userLookup); + + var productOp = productRewriter.GetOrRewrite( + """ + query($__fusion_1_id: ID!) { + productById(id: $__fusion_1_id) { id name price } + } + """, + 1UL); + + var userOp = userRewriter.GetOrRewrite( + """ + query($__fusion_1_email: String!) { + userByEmail(email: $__fusion_1_email) { email name } + } + """, + 2UL); + + var requests = ImmutableArray.Create( + new SourceSchemaClientRequest + { + Node = null!, + SchemaName = "test", + OperationType = OperationType.Query, + OperationSourceText = productOp.OperationText, + OperationHash = 1UL, + Variables = [] + }, + new SourceSchemaClientRequest + { + Node = null!, + SchemaName = "test", + OperationType = OperationType.Query, + OperationSourceText = userOp.OperationText, + OperationHash = 2UL, + Variables = [] + }); + + var rewrittenOps = new[] { productOp, userOp }; + + // act + var (queryText, variablesJson) = + ApolloFederationSourceSchemaClient.BuildCombinedEntityQuery(requests, rewrittenOps); + + // assert — query structure + Assert.Contains("$r0: [_Any!]!", queryText); + Assert.Contains("$r1: [_Any!]!", queryText); + Assert.Contains("____request0: _entities(representations: $r0)", queryText); + Assert.Contains("____request1: _entities(representations: $r1)", queryText); + Assert.Contains("... on Product", queryText); + Assert.Contains("... on User", queryText); + + // assert — variables structure + Assert.Contains("\"r0\"", variablesJson); + Assert.Contains("\"r1\"", variablesJson); + Assert.Contains("\"__typename\":\"Product\"", variablesJson); + Assert.Contains("\"__typename\":\"User\"", variablesJson); + } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs index 0f591b1c6d8..fb1a7a363be 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs @@ -220,6 +220,61 @@ public async Task FullRoundtrip_Transform_Rewrite_Execute() json.MatchSnapshot(extension: ".json"); } + [Fact] + public async Task BatchedEntitiesQuery_Should_ResolveMultipleEntityTypes() + { + // arrange: build Federation subgraph with Product and User entities + var executor = await new ServiceCollection() + .AddGraphQL() + .AddApolloFederation() + .AddQueryType() + .AddType() + .AddType() + .BuildRequestExecutorAsync(); + + // Build a combined aliased query like the connector would + const string batchedQuery = """ + query($r0: [_Any!]!, $r1: [_Any!]!) { + ____request0: _entities(representations: $r0) { + ... on Product { id name price } + } + ____request1: _entities(representations: $r1) { + ... on User { email name } + } + } + """; + + var request = OperationRequestBuilder + .New() + .SetDocument(batchedQuery) + .SetVariableValues(new Dictionary + { + ["r0"] = new List + { + new Dictionary { ["__typename"] = "Product", ["id"] = 1 }, + new Dictionary { ["__typename"] = "Product", ["id"] = 2 } + }, + ["r1"] = new List + { + new Dictionary { ["__typename"] = "User", ["email"] = "test@example.com" } + } + }) + .Build(); + + // act + var result = await executor.ExecuteAsync(request); + + // assert + var json = result.ToJson(); + Assert.Contains("____request0", json); + Assert.Contains("____request1", json); + Assert.Contains("Product 1", json); + Assert.Contains("Product 2", json); + Assert.Contains("User test@example.com", json); + + json.MatchSnapshot(extension: ".json"); + } + [Key("id")] [ReferenceResolver(EntityResolver = nameof(ResolveById))] public sealed class Product diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.BatchedEntitiesQuery_Should_ResolveMultipleEntityTypes.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.BatchedEntitiesQuery_Should_ResolveMultipleEntityTypes.json new file mode 100644 index 00000000000..3bd1e148aca --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.BatchedEntitiesQuery_Should_ResolveMultipleEntityTypes.json @@ -0,0 +1,22 @@ +{ + "data": { + "____request0": [ + { + "id": 1, + "name": "Product 1", + "price": 9.989999771118164 + }, + { + "id": 2, + "name": "Product 2", + "price": 9.989999771118164 + } + ], + "____request1": [ + { + "email": "test@example.com", + "name": "User test@example.com" + } + ] + } +} From 1d8183f1618d45cfa855b5da0d3852fe9ae226a9 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 7 Apr 2026 11:46:22 +0000 Subject: [PATCH 4/6] edits --- src/All.slnx | 4 + .../Fusion/HotChocolate.Fusion.slnx | 6 +- .../FederationSchemaAnalyzer.cs | 180 +++--------------- .../FederationSchemaTransformer.cs | 3 +- ...Fusion.Composition.ApolloFederation.csproj | 15 ++ .../FederationResources.Designer.cs | 89 +++++++++ .../Properties/FederationResources.resx | 30 +++ 7 files changed, 168 insertions(+), 159 deletions(-) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/Properties/FederationResources.Designer.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/Properties/FederationResources.resx diff --git a/src/All.slnx b/src/All.slnx index 52396a2ede2..e5d009de81d 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -214,9 +214,11 @@ + + @@ -227,6 +229,8 @@ + + diff --git a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx index ae300de87a0..b5b41465930 100644 --- a/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx +++ b/src/HotChocolate/Fusion/HotChocolate.Fusion.slnx @@ -6,6 +6,8 @@ + + @@ -13,13 +15,14 @@ - + + @@ -27,7 +30,6 @@ - diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs index 602bfb802e3..94baf3acd4b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs @@ -1,5 +1,6 @@ using HotChocolate.Fusion.Errors; using HotChocolate.Language; +using static HotChocolate.Fusion.ApolloFederation.Properties.FederationResources; namespace HotChocolate.Fusion.ApolloFederation; @@ -37,7 +38,7 @@ private static void DetectFederationVersion(DocumentNode document, AnalysisResul if (federationVersion is null) { - result.Errors.Add(new CompositionError("Federation v1 is not supported.")); + result.Errors.Add(new CompositionError(FederationSchemaAnalyzer_FederationV1NotSupported)); return; } @@ -48,7 +49,7 @@ private static void DetectFederationVersion(DocumentNode document, AnalysisResul { foreach (var definition in document.Definitions) { - IReadOnlyList? directives = definition switch + var directives = definition switch { SchemaDefinitionNode schemaDef => schemaDef.Directives, SchemaExtensionNode schemaExt => schemaExt.Directives, @@ -62,27 +63,18 @@ private static void DetectFederationVersion(DocumentNode document, AnalysisResul foreach (var directive in directives) { - if (!directive.Name.Value.Equals( - FederationDirectiveNames.Link, - StringComparison.Ordinal)) + if (!directive.Name.Value.Equals(FederationDirectiveNames.Link, StringComparison.Ordinal)) { continue; } var url = GetStringArgument(directive, "url"); - if (url is null - || !url.Contains(FederationUrlPrefix, StringComparison.Ordinal)) + if (url is null || !url.Contains(FederationUrlPrefix, StringComparison.Ordinal)) { continue; } - // Check for @composeDirective imports in this @link directive. - if (HasComposeDirectiveImport(directive)) - { - // We only add the error but continue to extract the version. - } - // Extract version from URL like // "https://specs.apollo.dev/federation/v2.5" var lastSlash = url.LastIndexOf('/'); @@ -97,14 +89,6 @@ private static void DetectFederationVersion(DocumentNode document, AnalysisResul return null; } - private static bool HasComposeDirectiveImport(DirectiveNode linkDirective) - { - // @link can import specific directives via the `import` argument. - // We do not inspect imports here; @composeDirective is a separate directive. - // This is checked elsewhere in AnalyzeTypeDefinitions. - return false; - } - private static void DetectQueryTypeName(DocumentNode document, AnalysisResult result) { foreach (var definition in document.Definitions) @@ -123,12 +107,19 @@ private static void DetectQueryTypeName(DocumentNode document, AnalysisResult re } } } - - // Default remains "Query" as set in AnalysisResult constructor. } private static void AnalyzeTypeDefinitions(DocumentNode document, AnalysisResult result) { + var unsupportedDirectives = new HashSet(StringComparer.Ordinal) + { + FederationDirectiveNames.ComposeDirective, + FederationDirectiveNames.Authenticated, + FederationDirectiveNames.RequiresScopes, + FederationDirectiveNames.Policy, + FederationDirectiveNames.InterfaceObject + }; + foreach (var definition in document.Definitions) { switch (definition) @@ -148,88 +139,13 @@ private static void AnalyzeTypeDefinitions(DocumentNode document, AnalysisResult interfaceType.Fields, result); break; - } - } - - // Check for @composeDirective definitions in the document. - foreach (var definition in document.Definitions) - { - if (definition is DirectiveDefinitionNode directiveDef - && directiveDef.Name.Value.Equals( - FederationDirectiveNames.ComposeDirective, - StringComparison.Ordinal)) - { - result.Errors.Add( - new CompositionError( - "The @composeDirective feature is not supported.")); - break; - } - } - - // Also check for @composeDirective usage in @link imports. - foreach (var definition in document.Definitions) - { - IReadOnlyList? directives = definition switch - { - SchemaDefinitionNode schemaDef => schemaDef.Directives, - SchemaExtensionNode schemaExt => schemaExt.Directives, - _ => null - }; - - if (directives is null) - { - continue; - } - - foreach (var directive in directives) - { - if (directive.Name.Value.Equals( - FederationDirectiveNames.Link, - StringComparison.Ordinal)) - { - var url = GetStringArgument(directive, "url"); - - if (url?.Contains(FederationUrlPrefix, StringComparison.Ordinal) == false) - { - // This is a non-federation @link, check if there is a - // @composeDirective applied elsewhere. - CheckForComposeDirectiveUsage(document, result); - break; - } - } - } - } - } - private static void CheckForComposeDirectiveUsage( - DocumentNode document, - AnalysisResult result) - { - foreach (var definition in document.Definitions) - { - IReadOnlyList? directives = definition switch - { - SchemaDefinitionNode schemaDef => schemaDef.Directives, - SchemaExtensionNode schemaExt => schemaExt.Directives, - _ => null - }; - - if (directives is null) - { - continue; - } - - foreach (var directive in directives) - { - if (directive.Name.Value.Equals( - FederationDirectiveNames.ComposeDirective, - StringComparison.Ordinal)) - { - result.Errors.Add( - new CompositionError( - "The @composeDirective feature is not supported.")); - return; - } + case DirectiveDefinitionNode directiveDef + when unsupportedDirectives.Contains(directiveDef.Name.Value): + result.Errors.Add(new CompositionError(string.Format( + FederationSchemaAnalyzer_DirectiveNotSupported, + directiveDef.Name.Value))); + break; } } } @@ -240,26 +156,11 @@ private static void AnalyzeComplexType( IReadOnlyList fields, AnalysisResult result) { - // Check for unsupported directives on the type. - foreach (var directive in directives) - { - if (directive.Name.Value.Equals( - FederationDirectiveNames.InterfaceObject, - StringComparison.Ordinal)) - { - result.Errors.Add( - new CompositionError( - $"The @interfaceObject directive on type '{typeName}'" - + " is not supported.")); - } - } - - // Extract @key directives. + // we extract the key directives from federation and construct from resolvable keys lookups and from + // non-resolvable keys simply the keys. foreach (var directive in directives) { - if (!directive.Name.Value.Equals( - FederationDirectiveNames.Key, - StringComparison.Ordinal)) + if (!directive.Name.Value.Equals(FederationDirectiveNames.Key, StringComparison.Ordinal)) { continue; } @@ -286,7 +187,7 @@ private static void AnalyzeComplexType( }); } - // Build field type map. + // For later processing we need the type of each field of this type. var fieldTypes = new Dictionary(StringComparer.Ordinal); foreach (var field in fields) @@ -295,39 +196,6 @@ private static void AnalyzeComplexType( } result.TypeFieldTypes[typeName] = fieldTypes; - - // Check for unsupported directives on fields. - foreach (var field in fields) - { - foreach (var directive in field.Directives) - { - if (directive.Name.Value.Equals( - FederationDirectiveNames.InterfaceObject, - StringComparison.Ordinal)) - { - result.Errors.Add( - new CompositionError( - "The @interfaceObject directive on field" - + $" '{typeName}.{field.Name.Value}' is not supported.")); - } - - if (directive.Name.Value.Equals( - FederationDirectiveNames.Override, - StringComparison.Ordinal)) - { - var label = GetStringArgument(directive, "label"); - - if (label is not null) - { - result.Errors.Add( - new CompositionError( - "The @override directive with a 'label' argument" - + $" on field '{typeName}.{field.Name.Value}'" - + " is not supported.")); - } - } - } - } } private static string? GetStringArgument(DirectiveNode directive, string argumentName) diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs index 5fbfc87a782..6178184456c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs @@ -2,6 +2,7 @@ using HotChocolate.Fusion.Errors; using HotChocolate.Fusion.Results; using HotChocolate.Language; +using static HotChocolate.Fusion.ApolloFederation.Properties.FederationResources; namespace HotChocolate.Fusion.ApolloFederation; @@ -34,7 +35,7 @@ public static CompositionResult Transform(string federationSdl) catch (SyntaxException ex) { return new CompositionError( - $"Failed to parse federation SDL: {ex.Message}"); + string.Format(FederationSchemaTransformer_ParseFailed, ex.Message)); } var analysis = FederationSchemaAnalyzer.Analyze(document); 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 index 2fec9b6b92a..1e5f34f316b 100644 --- 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 @@ -14,4 +14,19 @@ + + + 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} + + From 44028598c4fbcde0057fff35b9da35d5cae07fb5 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 7 Apr 2026 19:25:01 +0000 Subject: [PATCH 5/6] edits --- .../AnalysisResult.cs | 42 --- .../FederationSchemaAnalyzer.cs | 215 +++---------- .../FederationSchemaTransformer.cs | 52 +-- .../GenerateLookupFields.cs | 227 ++++++-------- ...Fusion.Composition.ApolloFederation.csproj | 1 + .../RemoveFederationInfrastructure.cs | 189 ++--------- .../RewriteKeyDirectives.cs | 148 ++------- .../TransformRequiresToRequire.cs | 296 +++++------------- .../FederationSchemaTransformerTests.cs | 103 ------ ...ransformerTests.TransformToSourceSchema.md | 38 --- ...TransformerTests.Transform_CompositeKey.md | 17 +- ...formerTests.Transform_ExternalDirective.md | 16 +- ...nsformerTests.Transform_FullIntegration.md | 51 +-- ...erTests.Transform_KeyResolvableArgument.md | 15 +- ...TransformerTests.Transform_MultipleKeys.md | 24 +- ...ransform_NonResolvableAndResolvableKeys.md | 18 +- ...sformerTests.Transform_NonResolvableKey.md | 11 +- ...formerTests.Transform_ProvidesDirective.md | 24 +- ...formerTests.Transform_RequiresDirective.md | 23 +- ...TransformerTests.Transform_SimpleEntity.md | 15 +- ...Should_ProduceValidCompositeSchema.graphql | 24 +- 21 files changed, 453 insertions(+), 1096 deletions(-) delete mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs delete mode 100644 src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.TransformToSourceSchema.md diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs deleted file mode 100644 index ffe8897c968..00000000000 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/AnalysisResult.cs +++ /dev/null @@ -1,42 +0,0 @@ -using HotChocolate.Fusion.Errors; -using HotChocolate.Language; - -namespace HotChocolate.Fusion.ApolloFederation; - -/// -/// Contains the metadata extracted from analyzing a Federation v2 schema document. -/// -internal sealed class AnalysisResult -{ - /// - /// Gets the detected federation version string (e.g. "v2.0", "v2.5"). - /// A value of "v1" indicates unsupported federation v1. - /// - public string FederationVersion { get; set; } = "v1"; - - /// - /// Gets the entity key definitions keyed by type name. - /// Each type may have multiple @key directives. - /// - public Dictionary> EntityKeys { get; init; } = []; - - /// - /// Gets the field type map: typeName -> fieldName -> field type node. - /// - public Dictionary> TypeFieldTypes { get; init; } = []; - - /// - /// Gets the name of the query root type (defaults to "Query"). - /// - public string QueryTypeName { get; set; } = "Query"; - - /// - /// Gets the list of composition errors detected during analysis. - /// - public List Errors { get; init; } = []; - - /// - /// Gets a value indicating whether analysis produced any errors. - /// - public bool HasErrors => Errors.Count > 0; -} diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs index 94baf3acd4b..b34dda1ef61 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaAnalyzer.cs @@ -1,228 +1,105 @@ using HotChocolate.Fusion.Errors; using HotChocolate.Language; +using HotChocolate.Types.Mutable; using static HotChocolate.Fusion.ApolloFederation.Properties.FederationResources; namespace HotChocolate.Fusion.ApolloFederation; /// -/// Analyzes a parsed to extract Apollo Federation metadata -/// needed for transformations. +/// 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 + ]; + /// - /// Analyzes the given federation schema document and extracts metadata. + /// Validates the given federation schema and returns any composition errors. /// - /// - /// The parsed GraphQL document to analyze. + /// + /// The mutable schema definition to validate. /// /// - /// An containing the extracted federation metadata. + /// A list of instances. An empty list indicates success. /// - public static AnalysisResult Analyze(DocumentNode document) + public static List Validate(MutableSchemaDefinition schema) { - var result = new AnalysisResult(); + var errors = new List(); - DetectFederationVersion(document, result); - DetectQueryTypeName(document, result); - AnalyzeTypeDefinitions(document, result); + ValidateFederationVersion(schema, errors); + ValidateUnsupportedDirectives(schema, errors); - return result; + return errors; } - private static void DetectFederationVersion(DocumentNode document, AnalysisResult result) + private static void ValidateFederationVersion( + MutableSchemaDefinition schema, + List errors) { - var federationVersion = FindFederationVersion(document); + var federationVersion = FindFederationVersion(schema); if (federationVersion is null) { - result.Errors.Add(new CompositionError(FederationSchemaAnalyzer_FederationV1NotSupported)); - return; - } - - result.FederationVersion = federationVersion; - } - - private static string? FindFederationVersion(DocumentNode document) - { - foreach (var definition in document.Definitions) - { - var directives = definition switch - { - SchemaDefinitionNode schemaDef => schemaDef.Directives, - SchemaExtensionNode schemaExt => schemaExt.Directives, - _ => null - }; - - if (directives is null) - { - continue; - } - - foreach (var directive in directives) - { - if (!directive.Name.Value.Equals(FederationDirectiveNames.Link, StringComparison.Ordinal)) - { - continue; - } - - var url = GetStringArgument(directive, "url"); - - if (url is null || !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)..]; - } - } + errors.Add(new CompositionError(FederationSchemaAnalyzer_FederationV1NotSupported)); } - - return null; } - private static void DetectQueryTypeName(DocumentNode document, AnalysisResult result) + private static string? FindFederationVersion(MutableSchemaDefinition schema) { - foreach (var definition in document.Definitions) + foreach (var directive in schema.Directives) { - if (definition is not SchemaDefinitionNode schemaDef) + if (!directive.Name.Equals(FederationDirectiveNames.Link, StringComparison.Ordinal)) { continue; } - foreach (var operationType in schemaDef.OperationTypes) - { - if (operationType.Operation == OperationType.Query) - { - result.QueryTypeName = operationType.Type.Name.Value; - return; - } - } - } - } - - private static void AnalyzeTypeDefinitions(DocumentNode document, AnalysisResult result) - { - var unsupportedDirectives = new HashSet(StringComparer.Ordinal) - { - FederationDirectiveNames.ComposeDirective, - FederationDirectiveNames.Authenticated, - FederationDirectiveNames.RequiresScopes, - FederationDirectiveNames.Policy, - FederationDirectiveNames.InterfaceObject - }; - - foreach (var definition in document.Definitions) - { - switch (definition) - { - case ObjectTypeDefinitionNode objectType: - AnalyzeComplexType( - objectType.Name.Value, - objectType.Directives, - objectType.Fields, - result); - break; - - case InterfaceTypeDefinitionNode interfaceType: - AnalyzeComplexType( - interfaceType.Name.Value, - interfaceType.Directives, - interfaceType.Fields, - result); - break; - - case DirectiveDefinitionNode directiveDef - when unsupportedDirectives.Contains(directiveDef.Name.Value): - result.Errors.Add(new CompositionError(string.Format( - FederationSchemaAnalyzer_DirectiveNotSupported, - directiveDef.Name.Value))); - break; - } - } - } - - private static void AnalyzeComplexType( - string typeName, - IReadOnlyList directives, - IReadOnlyList fields, - AnalysisResult result) - { - // we extract the key directives from federation and construct from resolvable keys lookups and from - // non-resolvable keys simply the keys. - foreach (var directive in directives) - { - if (!directive.Name.Value.Equals(FederationDirectiveNames.Key, StringComparison.Ordinal)) + if (!directive.Arguments.TryGetValue("url", out var urlValue) + || urlValue is not StringValueNode urlString) { continue; } - var fieldsValue = GetStringArgument(directive, "fields"); + var url = urlString.Value; - if (fieldsValue is null) + if (!url.Contains(FederationUrlPrefix, StringComparison.Ordinal)) { continue; } - var resolvable = GetBooleanArgument(directive, "resolvable") ?? true; - - if (!result.EntityKeys.TryGetValue(typeName, out var keyList)) - { - keyList = []; - result.EntityKeys[typeName] = keyList; - } - - keyList.Add(new EntityKeyInfo - { - Fields = fieldsValue, - Resolvable = resolvable - }); - } - - // For later processing we need the type of each field of this type. - var fieldTypes = new Dictionary(StringComparer.Ordinal); - - foreach (var field in fields) - { - fieldTypes[field.Name.Value] = field.Type; - } + // Extract version from URL like + // "https://specs.apollo.dev/federation/v2.5" + var lastSlash = url.LastIndexOf('/'); - result.TypeFieldTypes[typeName] = fieldTypes; - } - - private static string? GetStringArgument(DirectiveNode directive, string argumentName) - { - foreach (var argument in directive.Arguments) - { - if (argument.Name.Value.Equals(argumentName, StringComparison.Ordinal) - && argument.Value is StringValueNode stringValue) + if (lastSlash >= 0 && lastSlash < url.Length - 1) { - return stringValue.Value; + return url[(lastSlash + 1)..]; } } return null; } - private static bool? GetBooleanArgument(DirectiveNode directive, string argumentName) + private static void ValidateUnsupportedDirectives( + MutableSchemaDefinition schema, + List errors) { - foreach (var argument in directive.Arguments) + foreach (var name in s_unsupportedDirectives) { - if (argument.Name.Value.Equals(argumentName, StringComparison.Ordinal) - && argument.Value is BooleanValueNode boolValue) + if (schema.DirectiveDefinitions.ContainsName(name)) { - return boolValue.Value; + errors.Add(new CompositionError(string.Format( + FederationSchemaAnalyzer_DirectiveNotSupported, + name))); } } - - return null; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs index 6178184456c..d2a27133132 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs @@ -2,6 +2,8 @@ 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; @@ -26,11 +28,11 @@ public static CompositionResult Transform(string federationSdl) { ArgumentException.ThrowIfNullOrEmpty(federationSdl); - DocumentNode document; + MutableSchemaDefinition schema; try { - document = Utf8GraphQLParser.Parse(federationSdl); + schema = SchemaParser.Parse(federationSdl); } catch (SyntaxException ex) { @@ -38,48 +40,18 @@ public static CompositionResult Transform(string federationSdl) string.Format(FederationSchemaTransformer_ParseFailed, ex.Message)); } - var analysis = FederationSchemaAnalyzer.Analyze(document); + var errors = FederationSchemaAnalyzer.Validate(schema); - if (analysis.HasErrors) + if (errors.Count > 0) { - return analysis.Errors.ToImmutableArray(); + return errors.ToImmutableArray(); } - document = RemoveFederationInfrastructure.Apply(document, analysis); - document = RewriteKeyDirectives.Apply(document); - document = GenerateLookupFields.Apply(document, analysis); - document = TransformRequiresToRequire.Apply(document, analysis); + RemoveFederationInfrastructure.Apply(schema); + GenerateLookupFields.Apply(schema); + RewriteKeyDirectives.Apply(schema); + TransformRequiresToRequire.Apply(schema); - return document.ToString(indented: true); - } - - /// - /// Transforms the given Apollo Federation v2 subgraph SDL into a - /// for the composition pipeline. - /// - /// - /// The name to assign to the source schema. - /// - /// - /// The Apollo Federation v2 subgraph SDL to transform. - /// - /// - /// A containing a - /// on success, or composition errors on failure. - /// - public static CompositionResult TransformToSourceSchema( - string schemaName, - string federationSdl) - { - ArgumentException.ThrowIfNullOrEmpty(schemaName); - - var (_, isFailure, sdl, errors) = Transform(federationSdl); - - if (isFailure) - { - return errors; - } - - return new SourceSchemaText(schemaName, sdl); + return SchemaFormatter.FormatAsString(schema); } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs index 742c12f1f96..67ce0e77802 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/GenerateLookupFields.cs @@ -1,5 +1,6 @@ using HotChocolate.Language; - +using HotChocolate.Types; +using HotChocolate.Types.Mutable; namespace HotChocolate.Fusion.ApolloFederation; /// @@ -8,58 +9,80 @@ namespace HotChocolate.Fusion.ApolloFederation; internal static class GenerateLookupFields { /// - /// Applies the lookup field generation to the document. + /// Applies the lookup field generation to the schema. /// - /// - /// The document to transform. - /// - /// - /// The analysis result containing entity key and field type metadata. + /// + /// The mutable schema definition to transform in place. /// - /// - /// A new document with generated lookup fields on the Query type. - /// - public static DocumentNode Apply(DocumentNode document, AnalysisResult analysis) + public static void Apply(MutableSchemaDefinition schema) { - var lookupFields = new List(); + if (schema.QueryType is null) + { + return; + } - foreach (var (typeName, keys) in analysis.EntityKeys) + var internalDef = new MutableDirectiveDefinition("internal"); + var lookupDef = new MutableDirectiveDefinition("lookup"); + var isDef = new MutableDirectiveDefinition("is"); + + foreach (var type in schema.Types) { - foreach (var key in keys) + if (type is not MutableComplexTypeDefinition complexType) { - if (!key.Resolvable) + continue; + } + + 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 field = GenerateLookupField(typeName, key, analysis); + var field = GenerateLookupField( + schema, + complexType, + fieldsString.Value, + internalDef, + lookupDef, + isDef); if (field is not null) { - lookupFields.Add(field); + schema.QueryType.Fields.Add(field); } } } - - if (lookupFields.Count == 0) - { - return document; - } - - return AppendFieldsToQueryType(document, analysis.QueryTypeName, lookupFields); } - private static FieldDefinitionNode? GenerateLookupField( - string typeName, - EntityKeyInfo key, - AnalysisResult analysis) + private static MutableOutputFieldDefinition? GenerateLookupField( + MutableSchemaDefinition schema, + MutableComplexTypeDefinition complexType, + string fieldsSelection, + MutableDirectiveDefinition internalDef, + MutableDirectiveDefinition lookupDef, + MutableDirectiveDefinition isDef) { SelectionSetNode selectionSet; try { selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet( - "{ " + key.Fields + " }"); + "{ " + fieldsSelection + " }"); } catch (SyntaxException) { @@ -74,18 +97,20 @@ public static DocumentNode Apply(DocumentNode document, AnalysisResult analysis) return null; } - if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes)) - { - return null; - } - - var arguments = new List(); var nameParts = new List(); + var fieldName = ToCamelCase(complexType.Name) + "By"; + + // Build a temporary field to set as DeclaringMember on arguments. + var lookupField = new MutableOutputFieldDefinition( + "placeholder", + complexType) + { + DeclaringMember = schema.QueryType + }; foreach (var leaf in leafFields) { - var argumentName = leaf.ArgumentName; - var fieldType = ResolveLeafFieldType(leaf, typeName, analysis); + var fieldType = ResolveLeafFieldType(leaf, complexType, schema); if (fieldType is null) { @@ -95,103 +120,89 @@ public static DocumentNode Apply(DocumentNode document, AnalysisResult analysis) // Make the type NonNull. var nonNullType = EnsureNonNull(fieldType); - var argumentDirectives = new List(); + if (nonNullType is not IInputType inputType) + { + continue; + } + + var argument = new MutableInputFieldDefinition(leaf.ArgumentName, inputType) + { + DeclaringMember = lookupField + }; // If the field has a nested path, add @is directive. if (leaf.Path.Count > 0) { var fieldPath = BuildFieldPath(leaf); - argumentDirectives.Add( - new DirectiveNode( - "is", - new ArgumentNode("field", new StringValueNode(fieldPath)))); + argument.Directives.Add( + new Directive(isDef, new ArgumentAssignment("field", fieldPath))); } - arguments.Add( - new InputValueDefinitionNode( - null, - new NameNode(argumentName), - null, - nonNullType, - null, - argumentDirectives)); - - nameParts.Add(ToPascalCase(argumentName)); + lookupField.Arguments.Add(argument); + nameParts.Add(ToPascalCase(leaf.ArgumentName)); } - if (arguments.Count == 0) + if (lookupField.Arguments.Count == 0) { return null; } - var fieldName = ToCamelCase(typeName) + "By" + string.Join("And", nameParts); + fieldName += string.Join("And", nameParts); - return new FieldDefinitionNode( - null, - new NameNode(fieldName), - null, - arguments, - new NamedTypeNode(typeName), - [new DirectiveNode("internal"), new DirectiveNode("lookup")]); + // Update the field name now that we know it. + lookupField.Name = fieldName; + + lookupField.Directives.Add(new Directive(internalDef)); + lookupField.Directives.Add(new Directive(lookupDef)); + + return lookupField; } - private static ITypeNode? ResolveLeafFieldType( + private static IType? ResolveLeafFieldType( LeafFieldInfo leaf, - string typeName, - AnalysisResult analysis) + MutableComplexTypeDefinition owningType, + MutableSchemaDefinition schema) { if (leaf.Path.Count == 0) { // Simple field: look up directly. - if (analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes) - && fieldTypes.TryGetValue(leaf.FieldName, out var fieldType)) + if (owningType.Fields.TryGetField(leaf.FieldName, out var field)) { - return fieldType; + return field.Type; } return null; } // Nested field: walk the path. - var currentTypeName = typeName; + var currentType = owningType; foreach (var pathSegment in leaf.Path) { - if (!analysis.TypeFieldTypes.TryGetValue(currentTypeName, out var pathFieldTypes) - || !pathFieldTypes.TryGetValue(pathSegment, out var pathFieldType)) + if (!currentType.Fields.TryGetField(pathSegment, out var pathField)) { return null; } - currentTypeName = GetNamedTypeName(pathFieldType); + var namedType = pathField.Type.NamedType(); - if (currentTypeName is null) + if (!schema.Types.TryGetType(namedType.Name, out var nestedType)) { return null; } + + currentType = nestedType; } // Now look up the final leaf field. - if (analysis.TypeFieldTypes.TryGetValue(currentTypeName, out var leafFieldTypes) - && leafFieldTypes.TryGetValue(leaf.FieldName, out var leafFieldType)) + if (currentType.Fields.TryGetField(leaf.FieldName, out var leafField)) { - return leafFieldType; + return leafField.Type; } return null; } - private static string? GetNamedTypeName(ITypeNode typeNode) - { - return typeNode switch - { - NamedTypeNode named => named.Name.Value, - NonNullTypeNode nonNull => GetNamedTypeName(nonNull.Type), - ListTypeNode list => GetNamedTypeName(list.Type), - _ => null - }; - } - private static void ExtractLeafFields( SelectionSetNode selectionSet, List parentPath, @@ -210,7 +221,7 @@ private static void ExtractLeafFields( { // Nested field: recurse with the current field added to the path. var nestedPath = new List(parentPath) { fieldName }; - ExtractLeafFields(fieldNode.SelectionSet!, nestedPath, results); + ExtractLeafFields(fieldNode.SelectionSet, nestedPath, results); } else { @@ -268,48 +279,14 @@ private static string BuildFieldPath(LeafFieldInfo leaf) return result; } - private static ITypeNode EnsureNonNull(ITypeNode typeNode) + private static IType EnsureNonNull(IType type) { - if (typeNode is NonNullTypeNode) - { - return typeNode; - } - - if (typeNode is INullableTypeNode nullable) + if (type.Kind is TypeKind.NonNull) { - return new NonNullTypeNode(nullable); - } - - return typeNode; - } - - private static DocumentNode AppendFieldsToQueryType( - DocumentNode document, - string queryTypeName, - List lookupFields) - { - var definitions = new List(document.Definitions.Count); - - foreach (var definition in document.Definitions) - { - if (definition is ObjectTypeDefinitionNode objectType - && objectType.Name.Value.Equals(queryTypeName, StringComparison.Ordinal)) - { - var allFields = new List( - objectType.Fields.Count + lookupFields.Count); - - allFields.AddRange(objectType.Fields); - allFields.AddRange(lookupFields); - - definitions.Add(objectType.WithFields(allFields)); - } - else - { - definitions.Add(definition); - } + return type; } - return document.WithDefinitions(definitions); + return new NonNullType(type); } private static string ToCamelCase(string value) 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 index 1e5f34f316b..01abf1e6865 100644 --- 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 @@ -11,6 +11,7 @@ + diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs index b6cf0f2105f..790ff2a9432 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveFederationInfrastructure.cs @@ -1,10 +1,10 @@ -using HotChocolate.Language; +using HotChocolate.Types.Mutable; namespace HotChocolate.Fusion.ApolloFederation; /// /// Removes Apollo Federation infrastructure types, directives, and fields -/// from a schema document. +/// from a mutable schema definition. /// internal static class RemoveFederationInfrastructure { @@ -33,186 +33,43 @@ internal static class RemoveFederationInfrastructure FederationTypeNames.LegacyFieldSet }; - private static readonly HashSet _federationFieldNames = new(StringComparer.Ordinal) - { - FederationFieldNames.Entities, - FederationFieldNames.Service - }; - /// - /// Applies the transformation to remove federation infrastructure from the document. + /// Applies the transformation to remove federation infrastructure from the schema. /// - /// - /// The document to transform. - /// - /// - /// The analysis result containing metadata about the schema. + /// + /// The mutable schema definition to transform in place. /// - /// - /// A new document with federation infrastructure removed. - /// - public static DocumentNode Apply(DocumentNode document, AnalysisResult analysis) + public static void Apply(MutableSchemaDefinition schema) { - var definitions = new List(document.Definitions.Count); - - foreach (var definition in document.Definitions) + // Remove federation directive definitions. + foreach (var name in _federationDirectiveNames) { - var transformed = TransformDefinition(definition, analysis); - - if (transformed is not null) - { - definitions.Add(transformed); - } + schema.DirectiveDefinitions.Remove(name); } - return document.WithDefinitions(definitions); - } - - private static IDefinitionNode? TransformDefinition( - IDefinitionNode definition, - AnalysisResult analysis) - { - switch (definition) + // Remove federation scalar types. + foreach (var name in _federationScalarNames) { - case ObjectTypeDefinitionNode objectType - when objectType.Name.Value.Equals( - FederationTypeNames.Service, - StringComparison.Ordinal): - return null; - - case UnionTypeDefinitionNode unionType - when unionType.Name.Value.Equals( - FederationTypeNames.Entity, - StringComparison.Ordinal): - return null; - - case ScalarTypeDefinitionNode scalarType - when _federationScalarNames.Contains(scalarType.Name.Value): - return null; - - case DirectiveDefinitionNode directiveDef - when _federationDirectiveNames.Contains(directiveDef.Name.Value): - return null; - - case SchemaDefinitionNode schemaDef: - return TransformSchemaDefinition(schemaDef); - - case SchemaExtensionNode schemaExt: - return TransformSchemaExtension(schemaExt); - - case ObjectTypeDefinitionNode objectType - when objectType.Name.Value.Equals( - analysis.QueryTypeName, - StringComparison.Ordinal): - return RemoveQueryFederationFields(objectType); - - default: - return definition; + schema.Types.Remove(name); } - } - private static IDefinitionNode? TransformSchemaDefinition(SchemaDefinitionNode schemaDef) - { - var nonLinkDirectives = new List(); + // Remove _Service type and _Entity union. + schema.Types.Remove(FederationTypeNames.Service); + schema.Types.Remove(FederationTypeNames.Entity); - foreach (var directive in schemaDef.Directives) + // Remove _entities and _service fields from query type. + if (schema.QueryType is not null) { - if (!directive.Name.Value.Equals( - FederationDirectiveNames.Link, - StringComparison.Ordinal)) - { - nonLinkDirectives.Add(directive); - } + schema.QueryType.Fields.Remove(FederationFieldNames.Entities); + schema.QueryType.Fields.Remove(FederationFieldNames.Service); } - // If the schema definition only had @link directives and has standard - // operation types, remove it entirely. - if (nonLinkDirectives.Count == 0 && HasOnlyStandardOperationTypes(schemaDef)) - { - return null; - } + // Remove @link directives from schema. + var linkDirectives = schema.Directives[FederationDirectiveNames.Link].ToList(); - // Otherwise keep it but strip the @link directives. - if (nonLinkDirectives.Count != schemaDef.Directives.Count) + foreach (var directive in linkDirectives) { - return schemaDef.WithDirectives(nonLinkDirectives); + schema.Directives.Remove(directive); } - - return schemaDef; - } - - private static IDefinitionNode? TransformSchemaExtension(SchemaExtensionNode schemaExt) - { - var nonLinkDirectives = new List(); - - foreach (var directive in schemaExt.Directives) - { - if (!directive.Name.Value.Equals( - FederationDirectiveNames.Link, - StringComparison.Ordinal)) - { - nonLinkDirectives.Add(directive); - } - } - - // If only @link directives, remove entirely. - if (nonLinkDirectives.Count == 0) - { - return null; - } - - if (nonLinkDirectives.Count != schemaExt.Directives.Count) - { - return schemaExt.WithDirectives(nonLinkDirectives); - } - - return schemaExt; - } - - private static bool HasOnlyStandardOperationTypes(SchemaDefinitionNode schemaDef) - { - foreach (var operationType in schemaDef.OperationTypes) - { - var isStandard = operationType.Operation switch - { - OperationType.Query - => operationType.Type.Name.Value.Equals("Query", StringComparison.Ordinal), - OperationType.Mutation - => operationType.Type.Name.Value.Equals("Mutation", StringComparison.Ordinal), - OperationType.Subscription - => operationType.Type.Name.Value.Equals( - "Subscription", - StringComparison.Ordinal), - _ => false - }; - - if (!isStandard) - { - return false; - } - } - - return true; - } - - private static ObjectTypeDefinitionNode RemoveQueryFederationFields( - ObjectTypeDefinitionNode queryType) - { - var filteredFields = new List(queryType.Fields.Count); - - foreach (var field in queryType.Fields) - { - if (!_federationFieldNames.Contains(field.Name.Value)) - { - filteredFields.Add(field); - } - } - - if (filteredFields.Count == queryType.Fields.Count) - { - return queryType; - } - - return queryType.WithFields(filteredFields); } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs index 98ad743f475..9405de4fba0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RewriteKeyDirectives.cs @@ -1,4 +1,5 @@ -using HotChocolate.Language; +using HotChocolate.Types; +using HotChocolate.Types.Mutable; namespace HotChocolate.Fusion.ApolloFederation; @@ -9,149 +10,42 @@ namespace HotChocolate.Fusion.ApolloFederation; internal static class RewriteKeyDirectives { /// - /// Applies the key directive rewrite to all type definitions in the document. + /// Applies the key directive rewrite to all type definitions in the schema. /// - /// - /// The document to transform. + /// + /// The mutable schema definition to transform in place. /// - /// - /// A new document with rewritten @key directives. - /// - public static DocumentNode Apply(DocumentNode document) + public static void Apply(MutableSchemaDefinition schema) { - var definitions = new List(document.Definitions.Count); - var changed = false; - - foreach (var definition in document.Definitions) + foreach (var type in schema.Types) { - switch (definition) + if (type is not MutableComplexTypeDefinition complexType) { - case ObjectTypeDefinitionNode objectType: - { - var rewritten = RewriteDirectivesOnObjectType(objectType); - - if (!ReferenceEquals(rewritten, objectType)) - { - changed = true; - } - - definitions.Add(rewritten); - break; - } - - case InterfaceTypeDefinitionNode interfaceType: - { - var rewritten = RewriteDirectivesOnInterfaceType(interfaceType); - - if (!ReferenceEquals(rewritten, interfaceType)) - { - changed = true; - } - - definitions.Add(rewritten); - break; - } - - default: - definitions.Add(definition); - break; - } - } - - if (!changed) - { - return document; - } - - return document.WithDefinitions(definitions); - } - - private static ObjectTypeDefinitionNode RewriteDirectivesOnObjectType( - ObjectTypeDefinitionNode objectType) - { - var rewritten = RewriteKeyDirectivesInList(objectType.Directives); - - if (rewritten is null) - { - return objectType; - } - - return objectType.WithDirectives(rewritten); - } - - private static InterfaceTypeDefinitionNode RewriteDirectivesOnInterfaceType( - InterfaceTypeDefinitionNode interfaceType) - { - var rewritten = RewriteKeyDirectivesInList(interfaceType.Directives); - - if (rewritten is null) - { - return interfaceType; - } - - return interfaceType.WithDirectives(rewritten); - } - - private static List? RewriteKeyDirectivesInList( - IReadOnlyList directives) - { - List? result = null; - - for (var i = 0; i < directives.Count; i++) - { - var directive = directives[i]; - - if (!directive.Name.Value.Equals( - FederationDirectiveNames.Key, - StringComparison.Ordinal)) - { - result?.Add(directive); continue; } - // Check if we need to strip the resolvable argument. - var hasResolvable = false; - - foreach (var argument in directive.Arguments) - { - if (argument.Name.Value.Equals("resolvable", StringComparison.Ordinal)) - { - hasResolvable = true; - break; - } - } - - if (!hasResolvable) - { - result?.Add(directive); - continue; - } + var keyDirectives = complexType.Directives["key"].ToList(); - // Lazily create the result list and copy previous items. - if (result is null) + foreach (var directive in keyDirectives) { - result = new List(directives.Count); + // Check if resolvable argument exists. + var hasResolvable = directive.Arguments.ContainsName("resolvable"); - for (var j = 0; j < i; j++) + if (!hasResolvable) { - result.Add(directives[j]); + continue; } - } - - // Keep only the "fields" argument. - var fieldsOnlyArgs = new List(); - foreach (var argument in directive.Arguments) - { - if (argument.Name.Value.Equals("fields", StringComparison.Ordinal)) + // Replace with directive containing only fields argument. + if (directive.Arguments.TryGetValue("fields", out var fieldsValue)) { - fieldsOnlyArgs.Add(argument); + var newDirective = new Directive( + directive.Definition, + new ArgumentAssignment("fields", fieldsValue)); + + complexType.Directives.Replace(directive, newDirective); } } - - result.Add(directive.WithArguments(fieldsOnlyArgs)); } - - return result; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs index 761f44a64a2..f2976447126 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs @@ -1,5 +1,6 @@ using HotChocolate.Language; - +using HotChocolate.Types; +using HotChocolate.Types.Mutable; namespace HotChocolate.Fusion.ApolloFederation; /// @@ -9,153 +10,66 @@ namespace HotChocolate.Fusion.ApolloFederation; internal static class TransformRequiresToRequire { /// - /// Applies the requires-to-require transformation on the document. + /// Applies the requires-to-require transformation on the schema. /// - /// - /// The document to transform. - /// - /// - /// The analysis result containing field type metadata. + /// + /// The mutable schema definition to transform in place. /// - /// - /// A new document with @requires directives replaced by - /// @require argument directives. - /// - public static DocumentNode Apply(DocumentNode document, AnalysisResult analysis) + public static void Apply(MutableSchemaDefinition schema) { - var definitions = new List(document.Definitions.Count); - var changed = false; + var requireDef = new MutableDirectiveDefinition("require"); - foreach (var definition in document.Definitions) + foreach (var type in schema.Types.OfType()) { - if (definition is ObjectTypeDefinitionNode objectType) + foreach (var field in type.Fields) { - var transformed = TransformObjectType(objectType, analysis); + var requiresDirective = field.Directives.FirstOrDefault( + FederationDirectiveNames.Requires); - if (!ReferenceEquals(transformed, objectType)) + if (requiresDirective is null) { - changed = true; + continue; } - definitions.Add(transformed); - } - else - { - definitions.Add(definition); - } - } - - if (!changed) - { - return document; - } - - return document.WithDefinitions(definitions); - } - - private static ObjectTypeDefinitionNode TransformObjectType( - ObjectTypeDefinitionNode objectType, - AnalysisResult analysis) - { - var typeName = objectType.Name.Value; - var fields = new List(objectType.Fields.Count); - var anyFieldChanged = false; - - foreach (var field in objectType.Fields) - { - var transformed = TransformField(field, typeName, analysis); - - if (!ReferenceEquals(transformed, field)) - { - anyFieldChanged = true; - } - - fields.Add(transformed); - } - - if (!anyFieldChanged) - { - return objectType; - } - - return objectType.WithFields(fields); - } - - private static FieldDefinitionNode TransformField( - FieldDefinitionNode field, - string typeName, - AnalysisResult analysis) - { - DirectiveNode? requiresDirective = null; - - foreach (var directive in field.Directives) - { - if (directive.Name.Value.Equals( - FederationDirectiveNames.Requires, - StringComparison.Ordinal)) - { - requiresDirective = directive; - break; - } - } - - if (requiresDirective is null) - { - return field; - } - - var fieldsValue = GetStringArgument(requiresDirective, "fields"); - - if (fieldsValue is null) - { - return field; - } - - SelectionSetNode selectionSet; - - try - { - selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet( - "{ " + fieldsValue + " }"); - } - catch (SyntaxException) - { - return field; - } - - if (!analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes)) - { - return field; - } - - var newArguments = new List(field.Arguments); + if (!requiresDirective.Arguments.TryGetValue("fields", out var fieldsValue) + || fieldsValue is not StringValueNode fieldsString) + { + continue; + } - ExtractRequireArguments(selectionSet, [], typeName, analysis, newArguments); + SelectionSetNode selectionSet; - // Remove the @requires directive from the field. - var newDirectives = new List(field.Directives.Count); + try + { + selectionSet = Utf8GraphQLParser.Syntax.ParseSelectionSet( + "{ " + fieldsString.Value + " }"); + } + catch (SyntaxException) + { + continue; + } - foreach (var directive in field.Directives) - { - if (!directive.Name.Value.Equals( - FederationDirectiveNames.Requires, - StringComparison.Ordinal)) - { - newDirectives.Add(directive); + ExtractRequireArguments( + selectionSet, + [], + type, + schema, + field, + requireDef); + + // Remove the @requires directive from the field. + field.Directives.Remove(requiresDirective); } } - - return field - .WithArguments(newArguments) - .WithDirectives(newDirectives); } private static void ExtractRequireArguments( SelectionSetNode selectionSet, List parentPath, - string currentTypeName, - AnalysisResult analysis, - List arguments) + MutableComplexTypeDefinition currentType, + MutableSchemaDefinition schema, + MutableOutputFieldDefinition targetField, + MutableDirectiveDefinition requireDef) { foreach (var selection in selectionSet.Selections) { @@ -169,33 +83,45 @@ private static void ExtractRequireArguments( if (fieldNode.SelectionSet?.Selections.Count > 0) { // Nested selection: recurse. - var nestedTypeName = ResolveFieldTypeName(currentTypeName, fieldName, analysis); + if (!currentType.Fields.TryGetField(fieldName, out var pathField)) + { + continue; + } + + var namedType = pathField.Type.NamedType(); - if (nestedTypeName is null) + if (!schema.Types.TryGetType( + namedType.Name, out var nestedType)) { continue; } var nestedPath = new List(parentPath) { fieldName }; + ExtractRequireArguments( fieldNode.SelectionSet!, nestedPath, - nestedTypeName, - analysis, - arguments); + nestedType, + schema, + targetField, + requireDef); } else { // Leaf field: generate an argument. - var fieldType = ResolveFieldType(currentTypeName, fieldName, analysis); - - if (fieldType is null) + 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) @@ -207,18 +133,17 @@ private static void ExtractRequireArguments( requireFieldValue = BuildFieldPath(parentPath, fieldName); } - var requireDirective = new DirectiveNode( - "require", - new ArgumentNode("field", new StringValueNode(requireFieldValue))); - - arguments.Add( - new InputValueDefinitionNode( - null, - new NameNode(fieldName), - null, - nonNullType, - null, - [requireDirective])); + var argument = new MutableInputFieldDefinition(fieldName, inputType) + { + DeclaringMember = targetField + }; + + argument.Directives.Add( + new Directive( + requireDef, + new ArgumentAssignment("field", requireFieldValue))); + + targetField.Arguments.Add(argument); } } } @@ -248,82 +173,23 @@ private static string BuildFieldPath(List path, string fieldName) return result; } - private static string? ResolveFieldTypeName( - string typeName, - string fieldName, - AnalysisResult analysis) - { - var fieldType = ResolveFieldType(typeName, fieldName, analysis); - - if (fieldType is null) - { - return null; - } - - return GetNamedTypeName(fieldType); - } - - private static ITypeNode? ResolveFieldType( - string typeName, - string fieldName, - AnalysisResult analysis) - { - if (analysis.TypeFieldTypes.TryGetValue(typeName, out var fieldTypes) - && fieldTypes.TryGetValue(fieldName, out var fieldType)) - { - return fieldType; - } - - return null; - } - - private static string? GetNamedTypeName(ITypeNode typeNode) - { - return typeNode switch - { - NamedTypeNode named => named.Name.Value, - NonNullTypeNode nonNull => GetNamedTypeName(nonNull.Type), - ListTypeNode list => GetNamedTypeName(list.Type), - _ => null - }; - } - - private static ITypeNode StripNonNull(ITypeNode typeNode) + private static IType StripNonNull(IType type) { - if (typeNode is NonNullTypeNode nonNull) + if (type is NonNullType nonNull) { - return nonNull.Type; + return nonNull.NullableType; } - return typeNode; + return type; } - private static ITypeNode EnsureNonNull(ITypeNode typeNode) + private static IType EnsureNonNull(IType type) { - if (typeNode is NonNullTypeNode) + if (type.Kind is TypeKind.NonNull) { - return typeNode; - } - - if (typeNode is INullableTypeNode nullable) - { - return new NonNullTypeNode(nullable); - } - - return typeNode; - } - - private static string? GetStringArgument(DirectiveNode directive, string argumentName) - { - foreach (var argument in directive.Arguments) - { - if (argument.Name.Value.Equals(argumentName, StringComparison.Ordinal) - && argument.Value is StringValueNode stringValue) - { - return stringValue.Value; - } + return type; } - return null; + return new NonNullType(type); } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs index be66b5a1810..24dadd5709e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs @@ -406,46 +406,6 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA .MatchMarkdownSnapshot(); } - [Fact] - public void TransformToSourceSchema() - { - // 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.TransformToSourceSchema( - "products", - federationSdl); - - // assert - Assert.True(result.IsSuccess); - Assert.Equal("products", result.Value.Name); - Snapshot.Create() - .Add(federationSdl, "Apollo Federation SDL", "graphql") - .Add(result.Value.SourceText, "Transformed Source Schema", "graphql") - .MatchMarkdownSnapshot(); - } - [Fact] public void Transform_InterfaceObject_Should_ReturnError() { @@ -483,43 +443,6 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA e => e.Message.Contains("@interfaceObject")); } - [Fact] - public void Transform_ProgressiveOverride_Should_ReturnError() - { - // arrange - const string federationSdl = - """ - schema @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@override"]) { - query: Query - } - type Product @key(fields: "id") { - id: ID! - name: String @override(from: "other", label: "percent(50)") - } - 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 @override(from: String!, label: String) on FIELD_DEFINITION - 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("@override") && e.Message.Contains("label")); - } - [Fact] public void Transform_FederationV1_Should_ReturnError() { @@ -562,22 +485,6 @@ public void Transform_InvalidSdl_Should_ReturnParseError() e => e.Message.Contains("parse")); } - [Fact] - public void TransformToSourceSchema_Should_PropagateErrors() - { - // arrange - const string federationSdl = "not valid graphql"; - - // act - var result = FederationSchemaTransformer.TransformToSourceSchema( - "products", - federationSdl); - - // assert - Assert.True(result.IsFailure); - Assert.True(result.Errors.Length > 0); - } - [Fact] public void Transform_EmptyString_Should_ThrowArgumentException() { @@ -585,14 +492,4 @@ public void Transform_EmptyString_Should_ThrowArgumentException() Assert.Throws( () => FederationSchemaTransformer.Transform(string.Empty)); } - - [Fact] - public void TransformToSourceSchema_EmptySchemaName_Should_ThrowArgumentException() - { - // arrange & act & assert - Assert.Throws( - () => FederationSchemaTransformer.TransformToSourceSchema( - string.Empty, - "type Query { hello: String }")); - } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.TransformToSourceSchema.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.TransformToSourceSchema.md deleted file mode 100644 index 9aee7430392..00000000000 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.TransformToSourceSchema.md +++ /dev/null @@ -1,38 +0,0 @@ -# TransformToSourceSchema - -## Apollo Federation SDL - -```graphql -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 -``` - -## Transformed Source Schema - -```graphql -type Product @key(fields: "id") { - id: ID! - name: String -} - -type Query { - product(id: ID!): Product - productById(id: ID!): Product @internal @lookup -} -``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md index 38e905c81ef..33e19315cb3 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md @@ -27,14 +27,21 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "sku package") { - sku: String! - package: String! - name: String +schema { + query: Query } type Query { + productBySkuAndPackage(package: String! sku: String!): Product + @internal + @lookup products: [Product] - productBySkuAndPackage(sku: String! package: String!): Product @internal @lookup +} + +type Product + @key(fields: "sku package") { + name: String + package: String! + sku: String! } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md index 362cf442b86..9f713c5fad4 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md @@ -27,13 +27,21 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "id") { - id: ID! - price: Float @external +schema { + query: Query } type Query { + productById(id: ID!): Product + @internal + @lookup products: [Product] - productById(id: ID!): Product @internal @lookup +} + +type Product + @key(fields: "id") { + id: ID! + price: Float + @external } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md index 1a110ccb6b9..d21e17ffdc1 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md @@ -45,33 +45,48 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "id") @key(fields: "sku package") { +schema { + query: Query +} + +type Query { + product(id: ID!): Product + productById(id: ID!): Product + @internal + @lookup + productBySkuAndPackage(package: String! sku: String!): Product + @internal + @lookup + reviews: [Review] + userById(id: ID!): User + @internal + @lookup +} + +type Product + @key(fields: "id") + @key(fields: "sku package") { + createdBy: User + @provides(fields: "totalProductsCreated") id: ID! - sku: String! - package: String! + inStock: Boolean name: String + package: String! price: Float + sku: String! 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 + body: String } -type Query { - product(id: ID!): Product - reviews: [Review] - productById(id: ID!): Product @internal @lookup - productBySkuAndPackage(sku: String! package: String!): Product @internal @lookup - userById(id: ID!): User @internal @lookup +type User + @key(fields: "id") { + id: ID! + totalProductsCreated: Int + username: String + @external } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md index 7fdb85e0cfc..2e7b7a1fe5d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md @@ -26,13 +26,20 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "id") { - id: ID! - name: String +schema { + query: Query } type Query { + productById(id: ID!): Product + @internal + @lookup products: [Product] - productById(id: ID!): Product @internal @lookup +} + +type Product + @key(fields: "id") { + id: ID! + name: String } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md index b08c2cd03b5..c052689f5d2 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md @@ -28,16 +28,26 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "id") @key(fields: "sku package") { - id: ID! - sku: String! - package: String! - name: String +schema { + query: Query } type Query { + productById(id: ID!): Product + @internal + @lookup + productBySkuAndPackage(package: String! sku: String!): Product + @internal + @lookup products: [Product] - productById(id: ID!): Product @internal @lookup - productBySkuAndPackage(sku: String! package: String!): Product @internal @lookup +} + +type Product + @key(fields: "id") + @key(fields: "sku package") { + id: ID! + name: String + package: String! + sku: String! } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md index 6104619aadd..78263eef962 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md @@ -27,14 +27,22 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "id") @key(fields: "sku") { - id: ID! - sku: String! - name: String +schema { + query: Query } type Query { + productById(id: ID!): Product + @internal + @lookup products: [Product] - productById(id: ID!): Product @internal @lookup +} + +type Product + @key(fields: "id") + @key(fields: "sku") { + id: ID! + name: String + sku: String! } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md index b3afe06d337..c581de06d6f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md @@ -26,12 +26,17 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "id") { - id: ID! - name: String +schema { + query: Query } type Query { products: [Product] } + +type Product + @key(fields: "id") { + id: ID! + name: String +} ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md index aa965091810..cce3316718a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md @@ -32,19 +32,27 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type User @key(fields: "id") { - id: ID! - username: String - totalProductsCreated: Int +schema { + query: Query +} + +type Query { + reviews: [Review] + userById(id: ID!): User + @internal + @lookup } type Review { + author: User + @provides(fields: "username") body: String - author: User @provides(fields: "username") } -type Query { - reviews: [Review] - userById(id: ID!): User @internal @lookup +type User + @key(fields: "id") { + id: ID! + totalProductsCreated: Int + username: String } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md index fee02105ffd..e1325b9bbf8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md @@ -30,15 +30,26 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "id") { - id: ID! - price: Float @external - weight: Float @external - shippingEstimate(price: Float! @require(field: "price") weight: Float! @require(field: "weight")): Float +schema { + query: Query } type Query { product(id: ID!): Product - productById(id: ID!): Product @internal @lookup + productById(id: ID!): Product + @internal + @lookup +} + +type Product + @key(fields: "id") { + id: ID! + price: Float + @external + shippingEstimate(price: Float! + @require(field: "price") weight: Float! + @require(field: "weight")): Float + weight: Float + @external } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md index 995e87d7bd9..95341c7049e 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md @@ -26,13 +26,20 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA ## Transformed SDL ```graphql -type Product @key(fields: "id") { - id: ID! - name: String +schema { + query: Query } type Query { product(id: ID!): Product - productById(id: ID!): Product @internal @lookup + productById(id: ID!): Product + @internal + @lookup +} + +type Product + @key(fields: "id") { + id: ID! + name: String } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql index 5dafb527355..f605fdc2ab8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/__snapshots__/SchemaTransformationIntegrationTests.Transform_FederationSubgraph_Should_ProduceValidCompositeSchema.graphql @@ -1,17 +1,27 @@ -type Product @key(fields: "id") { - id: Int! - name: String! - price: Float! +schema { + query: Query } type Query { product(id: Int!): Product + productById(id: Int!): Product + @internal + @lookup topProducts: [Product!]! - productById(id: Int!): Product @internal @lookup - userByEmail(email: String!): User @internal @lookup + userByEmail(email: String!): User + @internal + @lookup +} + +type Product + @key(fields: "id") { + id: Int! + name: String! + price: Float! } -type User @key(fields: "email") { +type User + @key(fields: "email") { email: String! name: String! } From 88692ac11489a5590c746622cb0bf78e65b30130 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 8 Apr 2026 06:04:42 +0000 Subject: [PATCH 6/6] edits --- .../FederationSchemaTransformer.cs | 1 + .../RemoveExternalFields.cs | 41 +++++++++++ ...formerTests.Transform_ExternalDirective.md | 2 - ...nsformerTests.Transform_FullIntegration.md | 2 - ...formerTests.Transform_RequiresDirective.md | 10 +-- .../SourceSchemaMerger.Argument.Tests.cs | 38 ++++------ .../SourceSchemaMerger.CostDirective.Tests.cs | 8 +-- .../SourceSchemaMerger.Interface.Tests.cs | 20 +++--- .../SourceSchemaMerger.Object.Tests.cs | 70 +++++++++---------- .../SourceSchemaMerger.OutputField.Tests.cs | 11 ++- .../SourceSchemaMerger.TagDirective.Tests.cs | 14 ++-- .../SourceSchemaMerger.Union.Tests.cs | 16 ++--- .../SourceSchemaPreprocessorTests.cs | 4 +- ...abled_AppliesInferredKeyDirectives.graphql | 8 +-- 14 files changed, 128 insertions(+), 117 deletions(-) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/RemoveExternalFields.cs diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs index d2a27133132..7d62015057d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/FederationSchemaTransformer.cs @@ -51,6 +51,7 @@ public static CompositionResult Transform(string federationSdl) 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/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/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md index 9f713c5fad4..f9288a6303c 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md @@ -41,7 +41,5 @@ type Query { type Product @key(fields: "id") { id: ID! - price: Float - @external } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md index d21e17ffdc1..c03c07b36b5 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md @@ -86,7 +86,5 @@ type User @key(fields: "id") { id: ID! totalProductsCreated: Int - username: String - @external } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md index e1325b9bbf8..5a97b47e9d8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md @@ -44,12 +44,8 @@ type Query { type Product @key(fields: "id") { id: ID! - price: Float - @external - shippingEstimate(price: Float! - @require(field: "price") weight: Float! - @require(field: "weight")): Float - weight: Float - @external + shippingEstimate( + price: Float! @require(field: "price") + weight: Float! @require(field: "weight")): Float } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Argument.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Argument.Tests.cs index 188826eb8a1..59360934347 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Argument.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Argument.Tests.cs @@ -42,9 +42,8 @@ Search filter to apply type Query @fusion__type(schema: A) @fusion__type(schema: B) { - searchProducts("Filter to apply to the search" filter: ProductFilter! - @fusion__inputField(schema: A) - @fusion__inputField(schema: B, sourceType: "ProductFilter")): [Product] + searchProducts( + filter: ProductFilter! @fusion__inputField(schema: A) @fusion__inputField(schema: B, sourceType: "ProductFilter")): [Product] @fusion__field(schema: A) @fusion__field(schema: B) } @@ -87,10 +86,8 @@ type Query { type Query @fusion__type(schema: A) @fusion__type(schema: B) { - field(limit: Int - @fusion__inputField(schema: A) - @fusion__inputField(schema: B) - @fusion__inaccessible): Int + field( + limit: Int @fusion__inputField(schema: A) @fusion__inputField(schema: B) @fusion__inaccessible): Int @fusion__field(schema: A) @fusion__field(schema: B) } @@ -129,9 +126,8 @@ Number of items to fetch type Query @fusion__type(schema: A) @fusion__type(schema: B) { - field("Number of items to fetch" limit: Int! = 10 - @fusion__inputField(schema: A, sourceType: "Int") - @fusion__inputField(schema: B)): Int + field( + limit: Int! = 10 @fusion__inputField(schema: A, sourceType: "Int") @fusion__inputField(schema: B)): Int @fusion__field(schema: A) @fusion__field(schema: B) } @@ -164,8 +160,8 @@ type ProductDimension { """ type Product @fusion__type(schema: A) { - delivery(zip: String! - @fusion__inputField(schema: A)): DeliveryEstimates + delivery( + zip: String! @fusion__inputField(schema: A)): DeliveryEstimates @fusion__field(schema: A) @fusion__requires(schema: A, requirements: "dimension { size weight }", field: "delivery(zip: String! size: Int! weight: Int!): DeliveryEstimates", map: [null, "dimension.size", "dimension.weight"]) dimension: ProductDimension! @@ -211,10 +207,8 @@ type Product { type Product @fusion__type(schema: A) @fusion__type(schema: B) { - reviews(filter: String - @fusion__inputField(schema: A) - @fusion__inputField(schema: B) - @deprecated(reason: "Some reason")): [String] + reviews( + filter: String @fusion__inputField(schema: A) @fusion__inputField(schema: B) @deprecated(reason: "Some reason")): [String] @fusion__field(schema: A) @fusion__field(schema: B) } @@ -245,10 +239,8 @@ type Product { type Product @fusion__type(schema: A) @fusion__type(schema: B) { - reviews(filter: String - @fusion__inputField(schema: A) - @fusion__inputField(schema: B) - @deprecated(reason: "Some reason")): [String] + reviews( + filter: String @fusion__inputField(schema: A) @fusion__inputField(schema: B) @deprecated(reason: "Some reason")): [String] @fusion__field(schema: A) @fusion__field(schema: B) } @@ -279,10 +271,8 @@ type Product { type Product @fusion__type(schema: A) @fusion__type(schema: B) { - reviews(filter: String - @fusion__inputField(schema: A) - @fusion__inputField(schema: B) - @deprecated(reason: "No longer supported.")): [String] + reviews( + filter: String @fusion__inputField(schema: A) @fusion__inputField(schema: B) @deprecated(reason: "No longer supported.")): [String] @fusion__field(schema: A) @fusion__field(schema: B) } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.CostDirective.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.CostDirective.Tests.cs index bfdc4ab40dd..e82df5de491 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.CostDirective.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.CostDirective.Tests.cs @@ -59,12 +59,8 @@ type Query @fusion__cost(schema: B, weight: "1.0") @fusion__type(schema: A) @fusion__type(schema: B) { - field(argument: Int - @cost(weight: "1") - @fusion__cost(schema: A, weight: "1.0") - @fusion__cost(schema: B, weight: "1.0") - @fusion__inputField(schema: A) - @fusion__inputField(schema: B)): Int + field( + argument: Int @cost(weight: "1") @fusion__cost(schema: A, weight: "1.0") @fusion__cost(schema: B, weight: "1.0") @fusion__inputField(schema: A) @fusion__inputField(schema: B)): Int @cost(weight: "1") @fusion__cost(schema: A, weight: "1.0") @fusion__cost(schema: B, weight: "1.0") diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Interface.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Interface.Tests.cs index 9f3a7e43ce6..36c6a17d146 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Interface.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Interface.Tests.cs @@ -210,11 +210,11 @@ interface Product @key(fields: "id") @key(fields: "name") { type Query @fusion__type(schema: A) { - productById(id: ID! - @fusion__inputField(schema: A)): Product + productById( + id: ID! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) - productByName(name: String! - @fusion__inputField(schema: A)): Product + productByName( + name: String! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) } @@ -264,8 +264,8 @@ type Cat implements Animal { type Query @fusion__type(schema: A) { - animalById(id: ID! - @fusion__inputField(schema: A)): Animal + animalById( + id: ID! @fusion__inputField(schema: A)): Animal @fusion__field(schema: A) } @@ -273,8 +273,8 @@ type Cat implements Animal @fusion__type(schema: A) @fusion__implements(schema: A, interface: "Animal") @fusion__lookup(schema: A, key: "id", field: "catById(id: ID!): Cat", map: ["id"], path: "animalById", internal: false) { - catById(id: ID! - @fusion__inputField(schema: A)): Cat + catById( + id: ID! @fusion__inputField(schema: A)): Cat @fusion__field(schema: A) id: ID! @fusion__field(schema: A) @@ -284,8 +284,8 @@ type Dog implements Animal @fusion__type(schema: A) @fusion__implements(schema: A, interface: "Animal") @fusion__lookup(schema: A, key: "id", field: "dogById(id: ID!): Dog", map: ["id"], path: "animalById", internal: false) { - dogById(id: ID! - @fusion__inputField(schema: A)): Dog + dogById( + id: ID! @fusion__inputField(schema: A)): Dog @fusion__field(schema: A) id: ID! @fusion__field(schema: A) diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs index a5947ba6853..c04ce42e437 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Object.Tests.cs @@ -249,11 +249,11 @@ type Product @key(fields: "id") @key(fields: "name") { type Query @fusion__type(schema: A) { - productById(id: ID! - @fusion__inputField(schema: A)): Product + productById( + id: ID! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) - productByName(name: String! - @fusion__inputField(schema: A)): Product + productByName( + name: String! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) version: Int @fusion__field(schema: A) @@ -303,11 +303,11 @@ type ProductPrice @key(fields: "regionName product { id }") { type Query @fusion__type(schema: A) { - productById(id: ID! - @fusion__inputField(schema: A)): Product + productById( + id: ID! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) - productBySku(sku: String! - @fusion__inputField(schema: A)): Product + productBySku( + sku: String! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) } @@ -317,8 +317,8 @@ type Product @fusion__lookup(schema: A, key: "sku", field: "productBySku(sku: String!): Product", map: ["sku"], path: null, internal: false) { id: ID! @fusion__field(schema: A) - price(regionName: String! - @fusion__inputField(schema: A)): ProductPrice + price( + regionName: String! @fusion__inputField(schema: A)): ProductPrice @fusion__field(schema: A) } @@ -372,8 +372,8 @@ type Query @fusion__type(schema: A) { lookups1: Lookups1! @fusion__field(schema: A) - productById(id: ID! - @fusion__inputField(schema: A)): Product + productById( + id: ID! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) } @@ -381,15 +381,15 @@ type Lookups1 @fusion__type(schema: A) { lookups2: Lookups2! @fusion__field(schema: A) - productBySku(sku: String! - @fusion__inputField(schema: A)): Product + productBySku( + sku: String! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) } type Lookups2 @fusion__type(schema: A) { - productByName(name: String! - @fusion__inputField(schema: A)): Product + productByName( + name: String! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) } @@ -433,8 +433,8 @@ type Address { type Query @fusion__type(schema: A) { - personByAddressId(id: ID! - @fusion__inputField(schema: A)): Person + personByAddressId( + id: ID! @fusion__inputField(schema: A)): Person @fusion__field(schema: A) } @@ -490,11 +490,11 @@ type Person @key(fields: "sku") { type Query @fusion__type(schema: A) @fusion__type(schema: B) { - personById(id: ID! - @fusion__inputField(schema: A)): Person + personById( + id: ID! @fusion__inputField(schema: A)): Person @fusion__field(schema: A) - personBySku(sku: String! - @fusion__inputField(schema: B)): Person + personBySku( + sku: String! @fusion__inputField(schema: B)): Person @fusion__field(schema: B) } @@ -535,9 +535,9 @@ type Product { type Query @fusion__type(schema: A) { - productByIdAndCategoryId(categoryId: Int - @fusion__inputField(schema: A) id: ID! - @fusion__inputField(schema: A)): Product! + productByIdAndCategoryId( + categoryId: Int @fusion__inputField(schema: A) + id: ID! @fusion__inputField(schema: A)): Product! @fusion__field(schema: A) } @@ -598,16 +598,16 @@ input BrandByInput2 @oneOf { type Query @fusion__type(schema: A) { - brand1(by: BrandByInput1! - @fusion__inputField(schema: A)): Brand + brand1( + by: BrandByInput1! @fusion__inputField(schema: A)): Brand @fusion__field(schema: A) - brand2(and: BrandByInput1! - @fusion__inputField(schema: A) name: String! - @fusion__inputField(schema: A)): Brand + brand2( + and: BrandByInput1! @fusion__inputField(schema: A) + name: String! @fusion__inputField(schema: A)): Brand @fusion__field(schema: A) - brand3(and: BrandByInput2! - @fusion__inputField(schema: A) by: BrandByInput1! - @fusion__inputField(schema: A)): Brand + brand3( + and: BrandByInput2! @fusion__inputField(schema: A) + by: BrandByInput1! @fusion__inputField(schema: A)): Brand @fusion__field(schema: A) } @@ -713,8 +713,8 @@ type Product @key(fields: "id") { type Query @fusion__type(schema: A) { "Fetches a product" - productById("The product id" id: ID! - @fusion__inputField(schema: A)): Product + productById( + id: ID! @fusion__inputField(schema: A)): Product @fusion__field(schema: A) } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.OutputField.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.OutputField.Tests.cs index 6634dea1e2b..3bd0a21473a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.OutputField.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.OutputField.Tests.cs @@ -30,9 +30,8 @@ type Product @fusion__type(schema: A) @fusion__type(schema: B) { "Computes a discount as a percentage of the product's list price." - discountPercentage(percent: Int = 10 - @fusion__inputField(schema: A) - @fusion__inputField(schema: B)): Int + discountPercentage( + percent: Int = 10 @fusion__inputField(schema: A) @fusion__inputField(schema: B)): Int @fusion__field(schema: A, sourceType: "Int!") @fusion__field(schema: B) } @@ -94,10 +93,8 @@ type Product { type Product @fusion__type(schema: A) @fusion__type(schema: B) { - discountPercentage(percent: Int - @fusion__inputField(schema: A) - @fusion__inputField(schema: B) - @fusion__inaccessible): Int + discountPercentage( + percent: Int @fusion__inputField(schema: A) @fusion__inputField(schema: B) @fusion__inaccessible): Int @fusion__field(schema: A) @fusion__field(schema: B) } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs index 8a19322b59f..238a360c478 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.TagDirective.Tests.cs @@ -122,11 +122,8 @@ type FooObject @tag(name: "b") @fusion__type(schema: A) @fusion__type(schema: B) { - field(arg: Int - @tag(name: "a") - @tag(name: "b") - @fusion__inputField(schema: A) - @fusion__inputField(schema: B)): Int + field( + arg: Int @tag(name: "a") @tag(name: "b") @fusion__inputField(schema: A) @fusion__inputField(schema: B)): Int @tag(name: "a") @tag(name: "b") @fusion__field(schema: A) @@ -138,11 +135,8 @@ interface FooInterface @tag(name: "b") @fusion__type(schema: A) @fusion__type(schema: B) { - field(arg: Int - @tag(name: "a") - @tag(name: "b") - @fusion__inputField(schema: A) - @fusion__inputField(schema: B)): Int + field( + arg: Int @tag(name: "a") @tag(name: "b") @fusion__inputField(schema: A) @fusion__inputField(schema: B)): Int @tag(name: "a") @tag(name: "b") @fusion__field(schema: A) diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Union.Tests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Union.Tests.cs index 54a58355c2f..a65c3a1f7c4 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Union.Tests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaMerger.Union.Tests.cs @@ -232,8 +232,8 @@ type Query { type Query @fusion__type(schema: A) { - animalById(id: ID! - @fusion__inputField(schema: A)): Animal + animalById( + id: ID! @fusion__inputField(schema: A)): Animal @fusion__field(schema: A) } @@ -287,24 +287,24 @@ type Cat { type Query @fusion__type(schema: A) { - animalById(id: ID! - @fusion__inputField(schema: A)): Animal + animalById( + id: ID! @fusion__inputField(schema: A)): Animal @fusion__field(schema: A) } type Cat @fusion__type(schema: A) @fusion__lookup(schema: A, key: "id", field: "catById(id: ID!): Cat", map: ["id"], path: "animalById", internal: false) { - catById(id: ID! - @fusion__inputField(schema: A)): Cat + catById( + id: ID! @fusion__inputField(schema: A)): Cat @fusion__field(schema: A) } type Dog @fusion__type(schema: A) @fusion__lookup(schema: A, key: "id", field: "dogById(id: ID!): Dog", map: ["id"], path: "animalById", internal: false) { - dogById(id: ID! - @fusion__inputField(schema: A)): Dog + dogById( + id: ID! @fusion__inputField(schema: A)): Dog @fusion__field(schema: A) } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs index ada0efd7a96..d82974e1706 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SourceSchemaPreprocessorTests.cs @@ -531,8 +531,8 @@ type Query { @lookup productById(id: ID!): Product @lookup - productByName(productName: String! - @is(field: "name")): Product + productByName( + productName: String! @is(field: "name")): Product @lookup } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaPreprocessorTests.Preprocess_InferKeysFromLookupsEnabled_AppliesInferredKeyDirectives.graphql b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaPreprocessorTests.Preprocess_InferKeysFromLookupsEnabled_AppliesInferredKeyDirectives.graphql index 30dd248b80e..732b39cc797 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaPreprocessorTests.Preprocess_InferKeysFromLookupsEnabled_AppliesInferredKeyDirectives.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/__snapshots__/SourceSchemaPreprocessorTests.Preprocess_InferKeysFromLookupsEnabled_AppliesInferredKeyDirectives.graphql @@ -5,8 +5,8 @@ schema { type Query { fruitById(id: ID!): Fruit @lookup - personByAddressId(id: ID! - @is(field: "address.id")): Person + personByAddressId( + id: ID! @is(field: "address.id")): Person @lookup personById(id: ID!): Person @lookup @@ -18,8 +18,8 @@ type Query { @lookup productByIdAndCategoryId(categoryId: Int id: ID!): Product @lookup - productByIdOrCategoryId(idOrCategoryId: IdOrCategoryIdInput! - @is(field: "{ id } | { categoryId }")): Product + productByIdOrCategoryId( + idOrCategoryId: IdOrCategoryIdInput! @is(field: "{ id } | { categoryId }")): Product @lookup }