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