diff --git a/src/HotChocolate/ApolloFederation/src/ApolloFederation/FederationTypeInterceptor.cs b/src/HotChocolate/ApolloFederation/src/ApolloFederation/FederationTypeInterceptor.cs index 3acd3733273..aedba659ba5 100644 --- a/src/HotChocolate/ApolloFederation/src/ApolloFederation/FederationTypeInterceptor.cs +++ b/src/HotChocolate/ApolloFederation/src/ApolloFederation/FederationTypeInterceptor.cs @@ -564,8 +564,11 @@ private void AddToUnionIfHasTypeLevelKeyDirective( ObjectType objectType, ObjectTypeConfiguration objectTypeCfg) { - if (objectTypeCfg.Directives.FirstOrDefault(d => d.Value is KeyDirective) is { } keyDirective - && ((KeyDirective)keyDirective.Value).Resolvable) + // Apollo Federation adds a type to the '_Entity' union as soon as it + // carries any resolvable '@key'. Scanning only the first key directive + // miscategorizes types whose first declared key is marked + // 'resolvable: false' even though a later key is resolvable. + if (objectTypeCfg.Directives.Any(d => d.Value is KeyDirective keyDirective && keyDirective.Resolvable)) { _entityTypes.Add(objectType); return; diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs index f2976447126..0dd5efce4cc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition.ApolloFederation/TransformRequiresToRequire.cs @@ -114,10 +114,12 @@ private static void ExtractRequireArguments( continue; } - var fieldType = sourceField.Type; - var nonNullType = EnsureNonNull(StripNonNull(fieldType)); - - if (nonNullType is not IInputType inputType) + // Mirror the source field's nullability on the generated + // argument. The composed schema validator compares this + // argument against the owning source field as-is, so + // wrapping a nullable source in NonNull here would make + // every post-merge validation reject the composition. + if (sourceField.Type is not IInputType inputType) { continue; } @@ -172,24 +174,4 @@ private static string BuildFieldPath(List path, string fieldName) return result; } - - private static IType StripNonNull(IType type) - { - if (type is NonNullType nonNull) - { - return nonNull.NullableType; - } - - return type; - } - - private static IType EnsureNonNull(IType type) - { - if (type.Kind is TypeKind.NonNull) - { - return type; - } - - return new NonNullType(type); - } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationClientConfigurationParser.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationClientConfigurationParser.cs index 38fde097b4d..8b8c77ee521 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationClientConfigurationParser.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationClientConfigurationParser.cs @@ -59,12 +59,14 @@ private static ApolloFederationSourceSchemaClientConfiguration CreateConfigurati } var lookups = ParseLookups(schemaName, federation); + var entityRequires = ParseEntityRequires(schemaName, federation); return new ApolloFederationSourceSchemaClientConfiguration( schemaName, clientName, new Uri(url), - lookups); + lookups, + entityRequires); } private static Dictionary ParseLookups( @@ -107,6 +109,83 @@ private static Dictionary ParseLookups( return lookups; } + private static Dictionary ParseEntityRequires( + string schemaName, + JsonElement federation) + { + if (!federation.TryGetProperty("entityTypes", out var entityTypesElement) + || entityTypesElement.ValueKind != JsonValueKind.Object) + { + return []; + } + + var entityRequires = new Dictionary(StringComparer.Ordinal); + + foreach (var entityType in entityTypesElement.EnumerateObject()) + { + if (entityType.Value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Source schema '{schemaName}' entity type '{entityType.Name}' must be a JSON object."); + } + + if (!entityType.Value.TryGetProperty("fields", out var fieldsElement) + || fieldsElement.ValueKind != JsonValueKind.Object) + { + continue; + } + + var fields = new Dictionary>(StringComparer.Ordinal); + + foreach (var field in fieldsElement.EnumerateObject()) + { + if (field.Value.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"Source schema '{schemaName}' entity type '{entityType.Name}' " + + $"field '{field.Name}' must be a JSON object."); + } + + if (!field.Value.TryGetProperty("requires", out var requiresElement) + || requiresElement.ValueKind != JsonValueKind.Object) + { + continue; + } + + var requires = new Dictionary(StringComparer.Ordinal); + + foreach (var argument in requiresElement.EnumerateObject()) + { + if (argument.Value.ValueKind != JsonValueKind.String + || argument.Value.GetString() is not { Length: > 0 } path) + { + throw new InvalidOperationException( + $"Source schema '{schemaName}' entity type '{entityType.Name}' " + + $"field '{field.Name}' require argument '{argument.Name}' " + + "must map to a non-empty string representing the field path."); + } + + requires[argument.Name] = path; + } + + if (requires.Count > 0) + { + fields[field.Name] = requires; + } + } + + if (fields.Count > 0) + { + entityRequires[entityType.Name] = new EntityRequiresInfo + { + Fields = fields + }; + } + } + + return entityRequires; + } + private static Dictionary ParseArguments( string schemaName, string lookupName, diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs index c9f915e9a90..0eed66f4900 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientConfiguration.cs @@ -21,23 +21,32 @@ public sealed class ApolloFederationSourceSchemaClientConfiguration : ISourceSch /// The lookup field metadata used to rewrite Fusion planner queries into /// Apollo Federation _entities queries. /// + /// + /// Per-entity-type metadata describing the @require arguments on + /// each entity field. Enables the rewriter to strip synthetic require + /// arguments from outgoing queries and to inject the bound variable + /// values into the _entities representation body. + /// /// The supported operation types. internal ApolloFederationSourceSchemaClientConfiguration( string name, string httpClientName, Uri baseAddress, IReadOnlyDictionary lookups, + IReadOnlyDictionary entityRequires, SupportedOperationType supportedOperations = SupportedOperationType.Query | SupportedOperationType.Mutation) { ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(httpClientName); ArgumentNullException.ThrowIfNull(baseAddress); ArgumentNullException.ThrowIfNull(lookups); + ArgumentNullException.ThrowIfNull(entityRequires); Name = name; HttpClientName = httpClientName; BaseAddress = baseAddress; Lookups = lookups; + EntityRequires = entityRequires; SupportedOperations = supportedOperations; } @@ -60,6 +69,12 @@ internal ApolloFederationSourceSchemaClientConfiguration( /// internal IReadOnlyDictionary Lookups { get; } + /// + /// Gets the per-entity-type @require argument metadata keyed by + /// entity type name (e.g. "Product"). + /// + internal IReadOnlyDictionary EntityRequires { 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 index 0a1d6465277..ecc47a80935 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientFactory.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/ApolloFederationSourceSchemaClientFactory.cs @@ -33,7 +33,7 @@ protected override ISourceSchemaClient CreateClient( var queryRewriter = _rewritersBySchema.GetOrAdd( configuration.Name, - static (_, config) => new FederationQueryRewriter(config.Lookups), + static (_, config) => new FederationQueryRewriter(config.Lookups, config.EntityRequires), configuration); var graphQLClient = GraphQLHttpClient.Create(httpClient, disposeHttpClient: true); diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs index 79074b4b1a6..94f53e0af1e 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/FederationQueryRewriter.cs @@ -19,6 +19,7 @@ internal sealed class FederationQueryRewriter { private readonly ConcurrentDictionary _cache = new(); private readonly IReadOnlyDictionary _lookupFields; + private readonly IReadOnlyDictionary _entityRequires; /// /// Initializes a new instance of . @@ -27,10 +28,18 @@ internal sealed class FederationQueryRewriter /// A dictionary mapping query field names (e.g. "productById") to their /// describing the entity type and key argument mappings. /// - public FederationQueryRewriter(IReadOnlyDictionary lookupFields) + /// + /// A dictionary keyed by entity type name (e.g. "Product") that + /// describes the @require arguments declared on each entity field. + /// + public FederationQueryRewriter( + IReadOnlyDictionary lookupFields, + IReadOnlyDictionary entityRequires) { ArgumentNullException.ThrowIfNull(lookupFields); + ArgumentNullException.ThrowIfNull(entityRequires); _lookupFields = lookupFields; + _entityRequires = entityRequires; } /// @@ -57,7 +66,7 @@ private RewrittenOperation Rewrite(string operationSourceText) && selections[0] is FieldNode lookupField && _lookupFields.TryGetValue(lookupField.Name.Value, out var lookupInfo)) { - return RewriteEntityLookup(lookupField, lookupInfo); + return RewriteEntityLookup(lookupField, lookupInfo, _entityRequires); } // Not an entity lookup, pass through unchanged. @@ -73,7 +82,8 @@ private RewrittenOperation Rewrite(string operationSourceText) private static RewrittenOperation RewriteEntityLookup( FieldNode lookupField, - LookupFieldInfo lookupInfo) + LookupFieldInfo lookupInfo, + IReadOnlyDictionary entityRequires) { // 1. Build the variable-to-key-field mapping by inspecting the lookup field's arguments. // The planner passes arguments like: productById(id: $__fusion_1_id) @@ -89,7 +99,24 @@ private static RewrittenOperation RewriteEntityLookup( } } - // 2. Build the _entities query AST. + // 2. Project any '@require' arguments declared on the lookup field's + // selection into the representation. The planner emits the require + // arguments as a variable reference on each inline-fragment field; + // we strip those arguments from the outgoing selection and record + // the variable-to-representation mapping so the client can splice + // the bound variable value onto the representation body. + // + // The walk only descends into the lookup field's top-level selection + // set. Nested '@require' paths (require arguments on fields nested + // inside other selections) are not yet handled; the composer does + // not currently generate them for any enabled compliance suite. + var innerSelections = StripRequireArguments( + lookupField.SelectionSet, + lookupInfo.EntityTypeName, + entityRequires, + variableToKeyFieldMap); + + // 3. Build the _entities query AST. // query($representations: [_Any!]!) { // _entities(representations: $representations) { // ... on EntityType { } @@ -113,8 +140,7 @@ private static RewrittenOperation RewriteEntityLookup( location: null, typeCondition: new NamedTypeNode(lookupInfo.EntityTypeName), directives: [], - selectionSet: lookupField.SelectionSet - ?? new SelectionSetNode(Array.Empty())); + selectionSet: innerSelections); // The _entities field: _entities(representations: $representations) { ... on Product { ... } } var entitiesField = new FieldNode( @@ -148,6 +174,100 @@ private static RewrittenOperation RewriteEntityLookup( }; } + /// + /// Returns the lookup field's inner selection set with every + /// @require-tagged argument removed. The stripped variables are + /// recorded into so that the + /// client merges them into the _entities representation body. + /// + private static SelectionSetNode StripRequireArguments( + SelectionSetNode? selectionSet, + string entityTypeName, + IReadOnlyDictionary entityRequires, + Dictionary variableToKeyFieldMap) + { + if (selectionSet is null) + { + return new SelectionSetNode(Array.Empty()); + } + + if (!entityRequires.TryGetValue(entityTypeName, out var requiresInfo) + || requiresInfo.Fields.Count == 0) + { + return selectionSet; + } + + var selections = selectionSet.Selections; + List? rewritten = null; + + for (var i = 0; i < selections.Count; i++) + { + var selection = selections[i]; + + if (selection is not FieldNode fieldNode + || !requiresInfo.Fields.TryGetValue(fieldNode.Name.Value, out var requiresArgs)) + { + rewritten?.Add(selection); + continue; + } + + // Walk the field's arguments and drop any that match a require + // argument name; record the bound variable name against the + // require field path so the client can inject it into the + // representation. + var arguments = fieldNode.Arguments; + List? retained = null; + + for (var j = 0; j < arguments.Count; j++) + { + var argument = arguments[j]; + + if (requiresArgs.TryGetValue(argument.Name.Value, out var requireFieldPath) + && argument.Value is VariableNode variable) + { + variableToKeyFieldMap[variable.Name.Value] = requireFieldPath; + + if (retained is null) + { + retained = new List(arguments.Count); + for (var k = 0; k < j; k++) + { + retained.Add(arguments[k]); + } + } + + continue; + } + + retained?.Add(argument); + } + + if (retained is null) + { + rewritten?.Add(selection); + continue; + } + + if (rewritten is null) + { + rewritten = new List(selections.Count); + for (var k = 0; k < i; k++) + { + rewritten.Add(selections[k]); + } + } + + rewritten.Add(fieldNode.WithArguments(retained)); + } + + if (rewritten is null) + { + return selectionSet; + } + + return new SelectionSetNode(rewritten); + } + private static OperationDefinitionNode GetOperationDefinition(DocumentNode document) { for (var i = 0; i < document.Definitions.Count; i++) diff --git a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs index b3a2783457e..63e3fc15335 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Connectors.ApolloFederation/LookupFieldInfo.cs @@ -17,3 +17,28 @@ internal sealed class LookupFieldInfo /// public required IReadOnlyDictionary ArgumentToKeyFieldMap { get; init; } } + +/// +/// Describes the @require(field: ...) arguments declared on the fields +/// of a single entity type. For every entity field that carries synthetic +/// @require arguments (generated by the composer from +/// @requires(fields: ...) on the owning subgraph), this maps the +/// argument name to the entity-representation field path that the Apollo +/// Federation runtime expects to receive on the _entities +/// representation. +/// +/// The rewriter uses this to strip the @require arguments from the +/// inline-fragment FieldNode at the lookup-field selection and to +/// record a variable-to-representation mapping that +/// merges into the outgoing representation body. +/// +/// +internal sealed class EntityRequiresInfo +{ + /// + /// Maps entity field name (e.g. "isExpensive") to a dictionary of + /// argument name (e.g. "price") to require field path + /// (e.g. "price"). + /// + public required IReadOnlyDictionary> Fields { get; init; } +} 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 0e5f9d8537b..91b7bc537e5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj @@ -9,6 +9,7 @@ + diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs index 3a41754d08f..eeb0a0e84ed 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/FederationSchemaTransformerTests.cs @@ -11,19 +11,25 @@ public void Transform_SimpleEntity() 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 """; @@ -48,20 +54,26 @@ public void Transform_CompositeKey() 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 """; @@ -86,21 +98,27 @@ public void Transform_MultipleKeys() 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 """; @@ -125,21 +143,27 @@ public void Transform_RequiresDirective() 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 @@ -157,6 +181,56 @@ directive @link(url: String! import: [String!]) repeatable on SCHEMA .MatchMarkdownSnapshot(); } + [Fact] + public void Transform_Should_Generate_Nullable_RequireArgument_When_SourceField_Is_Nullable() + { + // arrange: 'price' and 'weight' on the owning type are nullable so the + // generated '@require' arguments must mirror that nullability. Wrapping + // them in NonNull would cause post-merge validation to reject the + // composition because the composed schema's field types stay nullable. + 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); + Assert.Contains("price: Float @require(field: \"price\")", result.Value); + Assert.Contains("weight: Float @require(field: \"weight\")", result.Value); + Assert.DoesNotContain("price: Float!", result.Value); + Assert.DoesNotContain("weight: Float!", result.Value); + } + [Fact] public void Transform_ProvidesDirective() { @@ -166,24 +240,31 @@ public void Transform_ProvidesDirective() 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 @@ -209,19 +290,25 @@ public void Transform_ExternalDirective() 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 @@ -247,19 +334,25 @@ public void Transform_NonResolvableKey() 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 """; @@ -284,6 +377,7 @@ public void Transform_FullIntegration() 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! @@ -294,25 +388,32 @@ type Product @key(fields: "id") @key(fields: "sku package") { 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 @@ -340,19 +441,25 @@ public void Transform_KeyResolvableArgument() 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 """; @@ -377,20 +484,26 @@ public void Transform_NonResolvableAndResolvableKeys() 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 """; @@ -415,19 +528,25 @@ public void Transform_InterfaceObject_Should_ReturnError() 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 @@ -453,9 +572,11 @@ type Product @key(fields: "id") { id: ID! name: String } + type Query { product(id: ID!): Product } + directive @key(fields: String!) repeatable on OBJECT | INTERFACE """; @@ -502,23 +623,30 @@ public void Transform_NestedObjectKey() 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 """; @@ -543,21 +671,28 @@ public void Transform_NestedListKey() 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 """; @@ -582,30 +717,38 @@ public void Transform_DeeplyNestedListKey() 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 diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md index 4adfeb8f517..6403323baa7 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_CompositeKey.md @@ -6,20 +6,26 @@ 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 ``` 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 index a8372b0203f..28a48704d0c 100644 --- 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 @@ -6,30 +6,38 @@ 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 diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md index 1b8766e7eca..d1f5508f680 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ExternalDirective.md @@ -6,19 +6,25 @@ 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 diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md index 7e82dcdde67..7d5f9a47923 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_FullIntegration.md @@ -6,6 +6,7 @@ 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! @@ -16,25 +17,32 @@ type Product @key(fields: "id") @key(fields: "sku package") { 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 diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md index b1fa4e8cc2e..010ebd25b0f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_KeyResolvableArgument.md @@ -6,19 +6,25 @@ 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 ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md index 7be250e0e24..6c3cb64850a 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_MultipleKeys.md @@ -6,21 +6,27 @@ 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 ``` 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 index fca1ba22741..00ebde534e9 100644 --- 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 @@ -6,21 +6,28 @@ 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 ``` 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 index a10e4debb9b..c0b3758d232 100644 --- 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 @@ -6,23 +6,30 @@ 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 ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md index a933cbd3c8e..da6b41838e1 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableAndResolvableKeys.md @@ -6,20 +6,26 @@ 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 ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md index f0a36759d95..f836ee812ba 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_NonResolvableKey.md @@ -6,19 +6,25 @@ 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 ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md index 4eb3f8b77dd..baa308ca522 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_ProvidesDirective.md @@ -6,24 +6,31 @@ 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 diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md index 6dbea89a9bc..88eff946e4d 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_RequiresDirective.md @@ -6,21 +6,27 @@ 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 @@ -42,8 +48,8 @@ type Query { type Product @key(fields: "id") { id: ID! shippingEstimate( - price: Float! @require(field: "price") - weight: Float! @require(field: "weight") + price: Float @require(field: "price") + weight: Float @require(field: "weight") ): Float } ``` diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md index f1c3e9e8cd4..bf850ccd926 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.ApolloFederation.Tests/__snapshots__/FederationSchemaTransformerTests.Transform_SimpleEntity.md @@ -6,19 +6,25 @@ 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 ``` 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 index 352b9c6a313..72a476972d5 100644 --- 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 @@ -43,6 +43,7 @@ public static void Assert( { var actualDataText = actualData?.ToJsonString(s_indented) ?? "null"; var expectedDataText = expectedData?.ToJsonString(s_indented) ?? "null"; + var errorsText = actual["errors"]?.ToJsonString(s_indented) ?? ""; Xunit.Assert.Fail( $""" @@ -53,6 +54,9 @@ Data payload did not match. Actual: {actualDataText} + + Errors: + {errorsText} """); } } 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 index 3eb8d94b081..4afa81e0d95 100644 --- 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 @@ -82,6 +82,7 @@ public static async Task ComposeAsync( gatewayServices .AddGraphQLGateway() .AddApolloFederationSupport() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) .AddInMemoryConfiguration(schemaDocument, settings); var services = gatewayServices.BuildServiceProvider(); @@ -120,11 +121,13 @@ private static async Task BuildSubgraphInfoAsync(SubgraphHost host var compositeSdl = transformResult.Value; var lookups = ExtractLookups(compositeSdl); + var entityRequires = ExtractEntityRequires(compositeSdl, lookups); return new SubgraphInfo( host.Name, compositeSdl, lookups, + entityRequires, new Uri(DefaultBaseAddress)); } @@ -276,6 +279,37 @@ private static JsonDocumentOwner BuildGatewaySettings(IReadOnlyList 0) + { + writer.WriteStartObject("entityTypes"); + + foreach (var (entityTypeName, fields) in subgraph.EntityRequires) + { + writer.WriteStartObject(entityTypeName); + writer.WriteStartObject("fields"); + + foreach (var (fieldName, requires) in fields) + { + writer.WriteStartObject(fieldName); + writer.WriteStartObject("requires"); + + foreach (var (argumentName, requireField) in requires) + { + writer.WriteString(argumentName, requireField); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + } + writer.WriteEndObject(); writer.WriteEndObject(); @@ -335,6 +369,109 @@ private static IReadOnlyDictionary ExtractLookups(s return lookups; } + /// + /// + /// Walks every entity type (any object type that is the declared target + /// of a @lookup field) and extracts its per-field @require + /// argument metadata. + /// + /// + /// For each field that carries one or more @require(field: ...) + /// arguments, returns a mapping of field name to a dictionary of + /// argument name to representation field path. The connector rewriter + /// uses this map to strip the synthetic require arguments from outgoing + /// queries and inject the bound variable values onto the + /// _entities representation body. + /// + /// + private static IReadOnlyDictionary>> + ExtractEntityRequires( + string compositeSdl, + IReadOnlyDictionary lookups) + { + var entityTypeNames = new HashSet(StringComparer.Ordinal); + + foreach (var (_, settings) in lookups) + { + entityTypeNames.Add(settings.EntityTypeName); + } + + if (entityTypeNames.Count == 0) + { + return new Dictionary>>( + StringComparer.Ordinal); + } + + var document = Utf8GraphQLParser.Parse(compositeSdl); + + var entityRequires = + new Dictionary>>( + StringComparer.Ordinal); + + foreach (var definition in document.Definitions) + { + if (definition is not ObjectTypeDefinitionNode objectType + || !entityTypeNames.Contains(objectType.Name.Value)) + { + continue; + } + + var fieldMap = new Dictionary>( + StringComparer.Ordinal); + + foreach (var field in objectType.Fields) + { + var requires = new Dictionary(StringComparer.Ordinal); + + foreach (var argument in field.Arguments) + { + var requireFieldPath = GetRequireFieldPath(argument.Directives); + + if (requireFieldPath is null) + { + continue; + } + + requires[argument.Name.Value] = requireFieldPath; + } + + if (requires.Count > 0) + { + fieldMap[field.Name.Value] = requires; + } + } + + if (fieldMap.Count > 0) + { + entityRequires[objectType.Name.Value] = fieldMap; + } + } + + return entityRequires; + } + + private static string? GetRequireFieldPath(IReadOnlyList directives) + { + foreach (var directive in directives) + { + if (!string.Equals(directive.Name.Value, "require", StringComparison.Ordinal)) + { + continue; + } + + foreach (var argument in directive.Arguments) + { + if (string.Equals(argument.Name.Value, "field", StringComparison.Ordinal) + && argument.Value is StringValueNode stringValue) + { + return stringValue.Value; + } + } + } + + return null; + } + private static string? FindRootQueryName(DocumentNode document) { foreach (var definition in document.Definitions) @@ -457,6 +594,7 @@ private sealed record SubgraphInfo( string Name, string CompositeSdl, IReadOnlyDictionary Lookups, + IReadOnlyDictionary>> EntityRequires, Uri BaseAddress); private sealed class TestSubgraphHttpClientFactory : IHttpClientFactory diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/AData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/AData.cs new file mode 100644 index 00000000000..58062bd0cbc --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/AData.cs @@ -0,0 +1,19 @@ +namespace HotChocolate.Fusion.Suites.EnumIntersection.A; + +/// +/// Seed data for the a subgraph. Subgraph a can only +/// surface the REGULAR enum value because it never declared +/// ANONYMOUS; u2 therefore reaches the gateway with +/// type = null. +/// +internal static class AData +{ + public static readonly IReadOnlyList Users = + [ + new User { Id = "u1", Type = UserTypeEnum.REGULAR }, + new User { Id = "u2", Type = null } + ]; + + 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/EnumIntersection/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/ASubgraph.cs new file mode 100644 index 00000000000..37104487baf --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/ASubgraph.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.EnumIntersection.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// enum-intersection audit suite. Only declares the +/// REGULAR value of the UserType enum. +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/EnumIntersection/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/QueryType.cs new file mode 100644 index 00000000000..782f04eed00 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/QueryType.cs @@ -0,0 +1,19 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.EnumIntersection.A; + +/// +/// Root Query for the a subgraph. Exposes users: [User]. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("users") + .Type>() + .Resolve(_ => AData.Users); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/User.cs new file mode 100644 index 00000000000..b20602f9922 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/User.cs @@ -0,0 +1,11 @@ +namespace HotChocolate.Fusion.Suites.EnumIntersection.A; + +/// +/// The User entity as projected by the a subgraph. +/// +public sealed class User +{ + public string? Id { get; init; } + + public UserTypeEnum? Type { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserGraphType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserGraphType.cs new file mode 100644 index 00000000000..ceb0a40651f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserGraphType.cs @@ -0,0 +1,26 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.EnumIntersection.A; + +/// +/// Apollo Federation descriptor for the User entity in subgraph +/// a (@key(fields: "id")). Owns type as shareable. +/// +public sealed class UserGraphType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("User"); + + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(u => u.Id).Type(); + descriptor.Field(u => u.Type).Shareable().Type(); + } + + private static User? ResolveById(string id) + => AData.ById.TryGetValue(id, out var u) ? u : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserType.cs new file mode 100644 index 00000000000..78bcaba74e4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserType.cs @@ -0,0 +1,9 @@ +namespace HotChocolate.Fusion.Suites.EnumIntersection.A; + +/// +/// The UserType enum as projected by the a subgraph. +/// +public enum UserTypeEnum +{ + REGULAR +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserTypeType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserTypeType.cs new file mode 100644 index 00000000000..5baae8f8241 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/A/UserTypeType.cs @@ -0,0 +1,15 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.EnumIntersection.A; + +/// +/// Descriptor for the UserType enum in subgraph a: only +/// REGULAR is declared. +/// +public sealed class UserTypeType : EnumType +{ + protected override void Configure(IEnumTypeDescriptor descriptor) + { + descriptor.Name("UserType"); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/BData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/BData.cs new file mode 100644 index 00000000000..0a040dc3516 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/BData.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Suites.EnumIntersection.B; + +/// +/// Seed data for the b subgraph. Both REGULAR and +/// ANONYMOUS values are present in the source-side enum here. +/// +internal static class BData +{ + public static readonly IReadOnlyList Users = + [ + new User { Id = "u1", Type = UserTypeEnum.REGULAR }, + new User { Id = "u2", Type = UserTypeEnum.ANONYMOUS } + ]; + + 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/EnumIntersection/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/BSubgraph.cs new file mode 100644 index 00000000000..8e2585f23ac --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/BSubgraph.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.EnumIntersection.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// enum-intersection audit suite. Declares both +/// ANONYMOUS @inaccessible and REGULAR on the +/// UserType enum. +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/EnumIntersection/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/QueryType.cs new file mode 100644 index 00000000000..958d9d2ad0c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/QueryType.cs @@ -0,0 +1,31 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.EnumIntersection.B; + +/// +/// Root Query for the b subgraph. Exposes +/// usersByType(type: UserType!): [User!] and +/// usersB: [User!]. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("usersByType") + .Argument("type", a => a.Type>()) + .Type>>() + .Resolve(ctx => + { + var requested = ctx.ArgumentValue("type"); + return BData.Users.Where(u => u.Type == requested).ToArray(); + }); + + descriptor + .Field("usersB") + .Type>>() + .Resolve(_ => BData.Users); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/User.cs new file mode 100644 index 00000000000..eb473e7b014 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/User.cs @@ -0,0 +1,11 @@ +namespace HotChocolate.Fusion.Suites.EnumIntersection.B; + +/// +/// The User entity as projected by the b subgraph. +/// +public sealed class User +{ + public string? Id { get; init; } + + public UserTypeEnum? Type { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserGraphType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserGraphType.cs new file mode 100644 index 00000000000..d000b1cf9ff --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserGraphType.cs @@ -0,0 +1,27 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.EnumIntersection.B; + +/// +/// Apollo Federation descriptor for the User entity in subgraph +/// b (extend type User @key(fields: "id")). +/// +public sealed class UserGraphType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("User"); + + descriptor + .ExtendServiceType() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(u => u.Id).Type(); + descriptor.Field(u => u.Type).Shareable().Type(); + } + + private static User? ResolveById(string id) + => BData.ById.TryGetValue(id, out var u) ? u : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserType.cs new file mode 100644 index 00000000000..7e3042ff6e9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserType.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.EnumIntersection.B; + +/// +/// The UserType enum as projected by the b subgraph. +/// ANONYMOUS is marked @inaccessible so the supergraph +/// only exposes REGULAR. +/// +public enum UserTypeEnum +{ + ANONYMOUS, + REGULAR +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserTypeType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserTypeType.cs new file mode 100644 index 00000000000..ac12f513a5e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/B/UserTypeType.cs @@ -0,0 +1,20 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.EnumIntersection.B; + +/// +/// Descriptor for the UserType enum in subgraph b: +/// declares both ANONYMOUS (marked @inaccessible) and +/// REGULAR. +/// +public sealed class UserTypeType : EnumType +{ + protected override void Configure(IEnumTypeDescriptor descriptor) + { + descriptor.Name("UserType"); + + descriptor.Value(UserTypeEnum.ANONYMOUS).Inaccessible(); + descriptor.Value(UserTypeEnum.REGULAR); + } +} 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 index a2b451520e2..73e326b6d3e 100644 --- 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 @@ -1,10 +1,116 @@ +using HotChocolate.Fusion.Suites.EnumIntersection.A; +using HotChocolate.Fusion.Suites.EnumIntersection.B; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the enum-intersection suite from +/// graphql-hive/federation-gateway-audit. Two Apollo Federation +/// subgraphs declare overlapping enum values: subgraph a only +/// declares REGULAR; subgraph b declares both +/// ANONYMOUS @inaccessible and REGULAR. The supergraph +/// must intersect to REGULAR only. +/// public sealed class EnumIntersectionTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync)); + + /// + /// Plain id selection. The planner picks one subgraph and returns + /// both users. + /// + [Fact] + public Task Users_Returns_Ids_Only() => RunAsync( + query: """ + query { + users { id } + } + """, + expectedData: """ + { + "users": [ + { "id": "u1" }, + { "id": "u2" } + ] + } + """); + + /// + /// Walking type from subgraph a's Query.users + /// surfaces null for u2 because subgraph a + /// cannot project the ANONYMOUS value. + /// + [Fact(Skip = "Gateway does not surface an error when an enum value is null because the source subgraph cannot project it. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Users_Type_Returns_Null_For_Subgraph_A_Side() => RunAsync( + query: """ + query { + users { id type } + } + """, + expectedData: """ + { + "users": [ + { "id": "u1", "type": "REGULAR" }, + { "id": "u2", "type": null } + ] + } + """, + expectsErrors: true); + + /// + /// Walking type from subgraph b's Query.usersB + /// surfaces null for u2 because ANONYMOUS is + /// inaccessible in the supergraph. + /// + [Fact(Skip = "Gateway forwards inaccessible enum values from the source subgraph instead of nulling them in the response. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task UsersB_Type_Returns_Null_For_Inaccessible_Value() => RunAsync( + query: """ + query { + usersB { id type } + } + """, + expectedData: """ + { + "usersB": [ + { "id": "u1", "type": "REGULAR" }, + { "id": "u2", "type": null } + ] + } + """); + + /// + /// Filtering by the public REGULAR value works. + /// + [Fact] + public Task UsersByType_Regular_Returns_Single_User() => RunAsync( + query: """ + query { + usersByType(type: REGULAR) { id type } + } + """, + expectedData: """ + { + "usersByType": [ + { "id": "u1", "type": "REGULAR" } + ] + } + """); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// Filtering by the inaccessible ANONYMOUS value must be + /// rejected by validation. The data payload is null and the + /// response carries errors. + /// + [Fact] + public Task UsersByType_Anonymous_Is_Rejected() => RunAsync( + query: """ + query { + usersByType(type: ANONYMOUS) { id type } + } + """, + expectedData: "null", + expectsErrors: true); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/a.graphql new file mode 100644 index 00000000000..a4cae68dd3c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/a.graphql @@ -0,0 +1,18 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Query { + users: [User] +} + +type User @key(fields: "id") { + id: ID + type: UserType @shareable +} + +enum UserType { + REGULAR +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/b.graphql new file mode 100644 index 00000000000..9d5865f5132 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/b.graphql @@ -0,0 +1,20 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@inaccessible", "@shareable"] + ) + +type Query { + usersByType(type: UserType!): [User!] + usersB: [User!] +} + +extend type User @key(fields: "id") { + id: ID + type: UserType @shareable +} + +enum UserType { + ANONYMOUS @inaccessible + REGULAR +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/data.json new file mode 100644 index 00000000000..c7f928dadb7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/data.json @@ -0,0 +1,6 @@ +{ + "users": [ + { "id": "u1", "type": "REGULAR" }, + { "id": "u2", "type": "ANONYMOUS" } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/tests.json new file mode 100644 index 00000000000..8947f37b7cc --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/EnumIntersection/Reference/tests.json @@ -0,0 +1,53 @@ +[ + { + "query": "query {\n users { id }\n}\n", + "expected": { + "data": { + "users": [ + { "id": "u1" }, + { "id": "u2" } + ] + } + } + }, + { + "query": "query {\n users { id type }\n}\n", + "expected": { + "errors": true, + "data": { + "users": [ + { "id": "u1", "type": "REGULAR" }, + { "id": "u2", "type": null } + ] + } + } + }, + { + "query": "query {\n usersB { id type }\n}\n", + "expected": { + "data": { + "usersB": [ + { "id": "u1", "type": "REGULAR" }, + { "id": "u2", "type": null } + ] + } + } + }, + { + "query": "query {\n usersByType(type: REGULAR) { id type }\n}\n", + "expected": { + "data": { + "usersByType": [ + { "id": "u1", "type": "REGULAR" } + ] + } + } + }, + { + "query": "query {\n usersByType(type: ANONYMOUS) { id type }\n}\n", + "expected": { + "errors": true, + "data": null + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/AData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/AData.cs new file mode 100644 index 00000000000..0f280f53da6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/AData.cs @@ -0,0 +1,27 @@ +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.A; + +/// +/// Seed data for the a subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/fed2-external-extends/data.ts. +/// The a subgraph only ever projects id and rid +/// (plus, on the providedRandomUser path, name); other +/// fields are owned by subgraph b. +/// +internal static class AData +{ + /// + /// The seeded entities, ordered by id. + /// + public static readonly IReadOnlyList Users = + [ + new User { Id = "u1", Rid = "u1-rid", Name = "u1-name" }, + new User { Id = "u2", Rid = "u2-rid", Name = "u2-name" } + ]; + + /// + /// 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/Fed2ExternalExtends/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/ASubgraph.cs new file mode 100644 index 00000000000..b25628df0de --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/ASubgraph.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// fed2-external-extends audit suite. Owns the User.rid +/// field, declares the federated User type as an extension via +/// @extends, and exposes randomUser plus +/// providedRandomUser. The subgraph runs in-process under +/// . +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/Fed2ExternalExtends/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/QueryType.cs new file mode 100644 index 00000000000..aea5b7dbfa5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/QueryType.cs @@ -0,0 +1,36 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.A; + +/// +/// Root Query type for the a subgraph. Exposes +/// randomUser: User and providedRandomUser: User @provides(fields: +/// "name"). The providedRandomUser path returns a user with the +/// otherwise external name field already populated so the gateway +/// can read name without dispatching a separate entity call to +/// subgraph b. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("randomUser") + .Type() + .Resolve(_ => new User { Id = AData.Users[0].Id, Rid = AData.Users[0].Rid }); + + descriptor + .Field("providedRandomUser") + .Type() + .Provides("name") + .Resolve(_ => new User + { + Id = AData.Users[0].Id, + Rid = AData.Users[0].Rid, + Name = AData.Users[0].Name + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/User.cs new file mode 100644 index 00000000000..c7da005744d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/User.cs @@ -0,0 +1,18 @@ +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.A; + +/// +/// The User entity as projected by the a subgraph +/// (@key(fields: "id") @extends). Subgraph a only owns the +/// rid field; id and name are external. Carries an +/// optional so the @provides(fields: "name") +/// path on providedRandomUser can ship the value alongside the +/// entity reference. +/// +public sealed class User +{ + public string Id { get; init; } = default!; + + public string? Rid { get; init; } + + public string? Name { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/UserType.cs new file mode 100644 index 00000000000..82a8754d66d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/A/UserType.cs @@ -0,0 +1,32 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.A; + +/// +/// Apollo Federation descriptor for the User entity as extended by +/// the a subgraph. Mirrors the audit SDL: +/// type User @key(fields: "id") @extends { id: ID! @external, name: +/// String! @external, rid: ID }. The reference resolver returns the +/// seeded by id so subsequent paths can fetch +/// rid. +/// +public sealed class UserType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .ExtendServiceType() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(u => u.Id).External().Type>(); + descriptor.Field(u => u.Name).External().Type>(); + descriptor.Field(u => u.Rid).Type(); + } + + private static User? ResolveById(string id) + => AData.ById.TryGetValue(id, out var user) + ? new User { Id = user.Id, Rid = user.Rid } + : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/BData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/BData.cs new file mode 100644 index 00000000000..fd99a4615d6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/BData.cs @@ -0,0 +1,24 @@ +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.B; + +/// +/// Seed data for the b subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/fed2-external-extends/data.ts. +/// +internal static class BData +{ + /// + /// The seeded entities, ordered by id. + /// + public static readonly IReadOnlyList Users = + [ + new User("u1", "u1-name", "u1-nickname"), + new User("u2", "u2-name", "u2-nickname") + ]; + + /// + /// 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/Fed2ExternalExtends/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/BSubgraph.cs new file mode 100644 index 00000000000..f4f296dacd9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/BSubgraph.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// fed2-external-extends audit suite. Owns the User entity +/// (name is shareable) and exposes userById. The subgraph +/// runs in-process under . +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/Fed2ExternalExtends/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/QueryType.cs new file mode 100644 index 00000000000..adfa592dc29 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/QueryType.cs @@ -0,0 +1,27 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.B; + +/// +/// Root Query type for the b subgraph. Exposes +/// userById(id: ID): User. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("userById") + .Argument("id", a => a.Type()) + .Type() + .Resolve(ctx => + { + var id = ctx.ArgumentValue("id"); + return id is not null && BData.ById.TryGetValue(id, out var user) + ? user + : null; + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/User.cs new file mode 100644 index 00000000000..d9e13b69519 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/User.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.B; + +/// +/// The User entity as projected by the b subgraph +/// (@key(fields: "id")). Owns id, name (shareable), +/// and nickname. +/// +public sealed record User(string Id, string Name, string? Nickname); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/UserType.cs new file mode 100644 index 00000000000..b7a8caf5024 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/B/UserType.cs @@ -0,0 +1,28 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtends.B; + +/// +/// Apollo Federation descriptor for the User entity owned by the +/// b subgraph. Mirrors the audit SDL: +/// type User @key(fields: "id") { id: ID!, name: String! @shareable, +/// nickname: String }. The reference resolver looks up users by +/// id. +/// +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.Name).Shareable().Type>(); + descriptor.Field(u => u.Nickname).Type(); + } + + private static User? ResolveById(string id) + => BData.ById.TryGetValue(id, out var user) ? user : null; +} 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 index 3ec6c24421a..19a14f70112 100644 --- 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 @@ -1,10 +1,134 @@ +using HotChocolate.Fusion.Suites.Fed2ExternalExtends.A; +using HotChocolate.Fusion.Suites.Fed2ExternalExtends.B; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the fed2-external-extends suite from +/// graphql-hive/federation-gateway-audit. Two Apollo Federation +/// subgraphs share the User entity. Subgraph a uses the +/// Federation v1 style @extends directive together with +/// @external markers to project only its own field rid; +/// subgraph b owns the rest of the entity. The audit verifies that +/// the gateway can route the external name field to b, and +/// that subgraph a's providedRandomUser field lets +/// @provides(fields: "name") ship name alongside the +/// reference so no entity call to b is needed. +/// public sealed class Fed2ExternalExtendsTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync)); + + /// + /// Combined query: randomUser from subgraph a needs the + /// external name from b via the entity lookup; + /// userById resolves entirely in b. + /// + [Fact(Skip = "Federation transformer generates a 'userById' lookup field from User @key(\"id\") that collides with subgraph b's user-declared Query.userById root field. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task RandomUser_And_UserById_Resolve_Across_Subgraphs() => RunAsync( + query: """ + query { + randomUser { + id + name + } + userById(id: "u2") { + id + name + nickname + } + } + """, + expectedData: """ + { + "randomUser": { + "id": "u1", + "name": "u1-name" + }, + "userById": { + "id": "u2", + "name": "u2-name", + "nickname": "u2-nickname" + } + } + """); + + /// + /// randomUser with id and rid only stays inside + /// subgraph a. + /// + [Fact(Skip = "Federation transformer generates a 'userById' lookup field from User @key(\"id\") that collides with subgraph b's user-declared Query.userById root field. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task RandomUser_Returns_Local_Rid_Without_Entity_Call() => RunAsync( + query: """ + query { + randomUser { + id + rid + } + } + """, + expectedData: """ + { + "randomUser": { + "id": "u1", + "rid": "u1-rid" + } + } + """); + + /// + /// randomUser with the external name selection forces + /// the gateway to issue an entity call to subgraph b for + /// name, then merge with rid from subgraph a. + /// + [Fact(Skip = "Federation transformer generates a 'userById' lookup field from User @key(\"id\") that collides with subgraph b's user-declared Query.userById root field. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task RandomUser_Resolves_External_Name_Via_Entity_Call() => RunAsync( + query: """ + query { + randomUser { + id + rid + name + } + } + """, + expectedData: """ + { + "randomUser": { + "id": "u1", + "rid": "u1-rid", + "name": "u1-name" + } + } + """); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// providedRandomUser uses + /// @provides(fields: "name") so the gateway should accept the + /// name shipped from subgraph a and skip the entity + /// call to subgraph b. + /// + [Fact(Skip = "Federation transformer generates a 'userById' lookup field from User @key(\"id\") that collides with subgraph b's user-declared Query.userById root field. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task ProvidedRandomUser_Uses_Local_Name_Via_Provides() => RunAsync( + query: """ + query { + providedRandomUser { + id + rid + name + } + } + """, + expectedData: """ + { + "providedRandomUser": { + "id": "u1", + "rid": "u1-rid", + "name": "u1-name" + } + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/a.graphql new file mode 100644 index 00000000000..a2a29a98537 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/a.graphql @@ -0,0 +1,16 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@extends", "@provides"] + ) + +type Query { + randomUser: User + providedRandomUser: User @provides(fields: "name") +} + +type User @key(fields: "id") @extends { + id: ID! @external + name: String! @external + rid: ID +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/b.graphql new file mode 100644 index 00000000000..1ac34f82c94 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/b.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Query { + userById(id: ID): User +} + +type User @key(fields: "id") { + id: ID! + name: String! @shareable + nickname: String +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/data.json new file mode 100644 index 00000000000..eb345fc7e41 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/data.json @@ -0,0 +1,6 @@ +{ + "users": [ + { "id": "u1", "rid": "u1-rid", "name": "u1-name", "nickname": "u1-nickname" }, + { "id": "u2", "rid": "u2-rid", "name": "u2-name", "nickname": "u2-nickname" } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/tests.json new file mode 100644 index 00000000000..156ee7536ac --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtends/Reference/tests.json @@ -0,0 +1,53 @@ +[ + { + "query": "query {\n randomUser {\n id\n name\n }\n userById(id: \"u2\") {\n id\n name\n nickname\n }\n}\n", + "expected": { + "data": { + "randomUser": { + "id": "u1", + "name": "u1-name" + }, + "userById": { + "id": "u2", + "name": "u2-name", + "nickname": "u2-nickname" + } + } + } + }, + { + "query": "query {\n randomUser {\n id\n rid\n }\n}\n", + "expected": { + "data": { + "randomUser": { + "id": "u1", + "rid": "u1-rid" + } + } + } + }, + { + "query": "query {\n randomUser {\n id\n rid\n name\n }\n}\n", + "expected": { + "data": { + "randomUser": { + "id": "u1", + "rid": "u1-rid", + "name": "u1-name" + } + } + } + }, + { + "query": "query {\n providedRandomUser {\n id\n rid\n name\n }\n}\n", + "expected": { + "data": { + "providedRandomUser": { + "id": "u1", + "rid": "u1-rid", + "name": "u1-name" + } + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/AData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/AData.cs new file mode 100644 index 00000000000..cb6fb4205d2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/AData.cs @@ -0,0 +1,27 @@ +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.A; + +/// +/// Seed data for the a subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/fed2-external-extension/data.ts. +/// The a subgraph only ever projects id and rid +/// (plus, on the providedRandomUser path, name); other +/// fields are owned by subgraph b. +/// +internal static class AData +{ + /// + /// The seeded entities, ordered by id. + /// + public static readonly IReadOnlyList Users = + [ + new User { Id = "u1", Rid = "u1-rid", Name = "u1-name" }, + new User { Id = "u2", Rid = "u2-rid", Name = "u2-name" } + ]; + + /// + /// 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/Fed2ExternalExtension/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/ASubgraph.cs new file mode 100644 index 00000000000..5c6a0c799a5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/ASubgraph.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// fed2-external-extension audit suite. Owns the User.rid +/// field, declares the federated User as an extension via the +/// Federation v2 extend type form, and exposes randomUser +/// plus providedRandomUser. The subgraph runs in-process under +/// . +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/Fed2ExternalExtension/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/QueryType.cs new file mode 100644 index 00000000000..6f42208ed26 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/QueryType.cs @@ -0,0 +1,36 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.A; + +/// +/// Root Query type for the a subgraph. Exposes +/// randomUser: User and providedRandomUser: User @provides(fields: +/// "name"). The providedRandomUser path returns a user with the +/// otherwise external name field already populated so the gateway +/// can read name without dispatching a separate entity call to +/// subgraph b. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("randomUser") + .Type() + .Resolve(_ => new User { Id = AData.Users[0].Id, Rid = AData.Users[0].Rid }); + + descriptor + .Field("providedRandomUser") + .Type() + .Provides("name") + .Resolve(_ => new User + { + Id = AData.Users[0].Id, + Rid = AData.Users[0].Rid, + Name = AData.Users[0].Name + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/User.cs new file mode 100644 index 00000000000..e73abc14534 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/User.cs @@ -0,0 +1,18 @@ +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.A; + +/// +/// The User entity as projected by the a subgraph +/// (extend type User @key(fields: "id")). Subgraph a only +/// owns the rid field; id and name are external. +/// Carries an optional so the +/// @provides(fields: "name") path on providedRandomUser +/// can ship the value alongside the entity reference. +/// +public sealed class User +{ + public string Id { get; init; } = default!; + + public string? Rid { get; init; } + + public string? Name { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/UserType.cs new file mode 100644 index 00000000000..5f31b3c9852 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/A/UserType.cs @@ -0,0 +1,32 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.A; + +/// +/// Apollo Federation descriptor for the User entity as projected +/// by the a subgraph. Mirrors the audit SDL: +/// extend type User @key(fields: "id") { id: ID! @external, name: +/// String! @external, rid: ID }. The reference resolver returns the +/// seeded by id so subsequent paths can fetch +/// rid. +/// +public sealed class UserType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .ExtendServiceType() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(u => u.Id).External().Type>(); + descriptor.Field(u => u.Name).External().Type>(); + descriptor.Field(u => u.Rid).Type(); + } + + private static User? ResolveById(string id) + => AData.ById.TryGetValue(id, out var user) + ? new User { Id = user.Id, Rid = user.Rid } + : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/BData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/BData.cs new file mode 100644 index 00000000000..9dc3ba5ec1c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/BData.cs @@ -0,0 +1,24 @@ +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.B; + +/// +/// Seed data for the b subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/fed2-external-extension/data.ts. +/// +internal static class BData +{ + /// + /// The seeded entities, ordered by id. + /// + public static readonly IReadOnlyList Users = + [ + new User("u1", "u1-name", "u1-nickname"), + new User("u2", "u2-name", "u2-nickname") + ]; + + /// + /// 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/Fed2ExternalExtension/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/BSubgraph.cs new file mode 100644 index 00000000000..25eec7c9956 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/BSubgraph.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// fed2-external-extension audit suite. Owns the User +/// entity (name is shareable) and exposes userById. The +/// subgraph runs in-process under . +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/Fed2ExternalExtension/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/QueryType.cs new file mode 100644 index 00000000000..0d53757e797 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/QueryType.cs @@ -0,0 +1,27 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.B; + +/// +/// Root Query type for the b subgraph. Exposes +/// userById(id: ID): User. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("userById") + .Argument("id", a => a.Type()) + .Type() + .Resolve(ctx => + { + var id = ctx.ArgumentValue("id"); + return id is not null && BData.ById.TryGetValue(id, out var user) + ? user + : null; + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/User.cs new file mode 100644 index 00000000000..945ee5795fd --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/User.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.B; + +/// +/// The User entity as projected by the b subgraph +/// (@key(fields: "id")). Owns id, name (shareable), +/// and nickname. +/// +public sealed record User(string Id, string Name, string? Nickname); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/UserType.cs new file mode 100644 index 00000000000..f6c355add79 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/B/UserType.cs @@ -0,0 +1,28 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Fed2ExternalExtension.B; + +/// +/// Apollo Federation descriptor for the User entity owned by the +/// b subgraph. Mirrors the audit SDL: +/// type User @key(fields: "id") { id: ID!, name: String! @shareable, +/// nickname: String }. The reference resolver looks up users by +/// id. +/// +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.Name).Shareable().Type>(); + descriptor.Field(u => u.Nickname).Type(); + } + + private static User? ResolveById(string id) + => BData.ById.TryGetValue(id, out var user) ? user : null; +} 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 index fb75187b9cc..8cd732143b2 100644 --- 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 @@ -1,10 +1,134 @@ +using HotChocolate.Fusion.Suites.Fed2ExternalExtension.A; +using HotChocolate.Fusion.Suites.Fed2ExternalExtension.B; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the fed2-external-extension suite from +/// graphql-hive/federation-gateway-audit. Two Apollo Federation +/// subgraphs share the User entity. Subgraph a uses the +/// Federation v2 extend type User @key(...) form together with +/// @external markers to project only its own field rid; +/// subgraph b owns the rest of the entity. The audit verifies that +/// the gateway can route the external name field to b, and +/// that subgraph a's providedRandomUser field lets +/// @provides(fields: "name") ship name alongside the +/// reference so no entity call to b is needed. +/// public sealed class Fed2ExternalExtensionTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync)); + + /// + /// Combined query: randomUser from subgraph a needs the + /// external name from b via the entity lookup; + /// userById resolves entirely in b. + /// + [Fact(Skip = "Federation transformer generates a 'userById' lookup field from User @key(\"id\") that collides with subgraph b's user-declared Query.userById root field. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task RandomUser_And_UserById_Resolve_Across_Subgraphs() => RunAsync( + query: """ + query { + randomUser { + id + name + } + userById(id: "u2") { + id + name + nickname + } + } + """, + expectedData: """ + { + "randomUser": { + "id": "u1", + "name": "u1-name" + }, + "userById": { + "id": "u2", + "name": "u2-name", + "nickname": "u2-nickname" + } + } + """); + + /// + /// randomUser with id and rid only stays inside + /// subgraph a. + /// + [Fact(Skip = "Federation transformer generates a 'userById' lookup field from User @key(\"id\") that collides with subgraph b's user-declared Query.userById root field. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task RandomUser_Returns_Local_Rid_Without_Entity_Call() => RunAsync( + query: """ + query { + randomUser { + id + rid + } + } + """, + expectedData: """ + { + "randomUser": { + "id": "u1", + "rid": "u1-rid" + } + } + """); + + /// + /// randomUser with the external name selection forces + /// the gateway to issue an entity call to subgraph b for + /// name, then merge with rid from subgraph a. + /// + [Fact(Skip = "Federation transformer generates a 'userById' lookup field from User @key(\"id\") that collides with subgraph b's user-declared Query.userById root field. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task RandomUser_Resolves_External_Name_Via_Entity_Call() => RunAsync( + query: """ + query { + randomUser { + id + rid + name + } + } + """, + expectedData: """ + { + "randomUser": { + "id": "u1", + "rid": "u1-rid", + "name": "u1-name" + } + } + """); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// providedRandomUser uses + /// @provides(fields: "name") so the gateway should accept the + /// name shipped from subgraph a and skip the entity + /// call to subgraph b. + /// + [Fact(Skip = "Federation transformer generates a 'userById' lookup field from User @key(\"id\") that collides with subgraph b's user-declared Query.userById root field. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task ProvidedRandomUser_Uses_Local_Name_Via_Provides() => RunAsync( + query: """ + query { + providedRandomUser { + id + rid + name + } + } + """, + expectedData: """ + { + "providedRandomUser": { + "id": "u1", + "rid": "u1-rid", + "name": "u1-name" + } + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/a.graphql new file mode 100644 index 00000000000..f62d9509bb3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/a.graphql @@ -0,0 +1,16 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@provides"] + ) + +type Query { + randomUser: User + providedRandomUser: User @provides(fields: "name") +} + +extend type User @key(fields: "id") { + id: ID! @external + name: String! @external + rid: ID +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/b.graphql new file mode 100644 index 00000000000..1ac34f82c94 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/b.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Query { + userById(id: ID): User +} + +type User @key(fields: "id") { + id: ID! + name: String! @shareable + nickname: String +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/data.json new file mode 100644 index 00000000000..eb345fc7e41 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/data.json @@ -0,0 +1,6 @@ +{ + "users": [ + { "id": "u1", "rid": "u1-rid", "name": "u1-name", "nickname": "u1-nickname" }, + { "id": "u2", "rid": "u2-rid", "name": "u2-name", "nickname": "u2-nickname" } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/tests.json new file mode 100644 index 00000000000..156ee7536ac --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Fed2ExternalExtension/Reference/tests.json @@ -0,0 +1,53 @@ +[ + { + "query": "query {\n randomUser {\n id\n name\n }\n userById(id: \"u2\") {\n id\n name\n nickname\n }\n}\n", + "expected": { + "data": { + "randomUser": { + "id": "u1", + "name": "u1-name" + }, + "userById": { + "id": "u2", + "name": "u2-name", + "nickname": "u2-nickname" + } + } + } + }, + { + "query": "query {\n randomUser {\n id\n rid\n }\n}\n", + "expected": { + "data": { + "randomUser": { + "id": "u1", + "rid": "u1-rid" + } + } + } + }, + { + "query": "query {\n randomUser {\n id\n rid\n name\n }\n}\n", + "expected": { + "data": { + "randomUser": { + "id": "u1", + "rid": "u1-rid", + "name": "u1-name" + } + } + } + }, + { + "query": "query {\n providedRandomUser {\n id\n rid\n name\n }\n}\n", + "expected": { + "data": { + "providedRandomUser": { + "id": "u1", + "rid": "u1-rid", + "name": "u1-name" + } + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/AData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/AData.cs new file mode 100644 index 00000000000..156106af796 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/AData.cs @@ -0,0 +1,15 @@ +namespace HotChocolate.Fusion.Suites.IncludeSkip.A; + +/// +/// Seed data for the a subgraph. +/// +internal static class AData +{ + public static readonly IReadOnlyList Products = + [ + new Product { Id = "p1", Price = 699.99 } + ]; + + public static readonly IReadOnlyDictionary ById = + Products.ToDictionary(static p => p.Id, StringComparer.Ordinal); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/ASubgraph.cs new file mode 100644 index 00000000000..b4f34a40902 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/ASubgraph.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// include-skip audit suite. Owns Product.price via +/// Query.product. +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/IncludeSkip/A/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/Product.cs new file mode 100644 index 00000000000..2dbb5593dd2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/Product.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.IncludeSkip.A; + +/// +/// The Product entity in the a subgraph +/// (@key(fields: "id")). Owns price. +/// +public sealed class Product +{ + public string Id { get; init; } = default!; + + public double Price { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/ProductType.cs new file mode 100644 index 00000000000..0465a6435bb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/ProductType.cs @@ -0,0 +1,24 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.A; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// a subgraph (@key(fields: "id")). Owns price. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).Type>(); + descriptor.Field(p => p.Price).Type>(); + } + + private static Product? ResolveById(string id) + => AData.ById.TryGetValue(id, out var p) ? p : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/QueryType.cs new file mode 100644 index 00000000000..0dc88d5f713 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/A/QueryType.cs @@ -0,0 +1,20 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.A; + +/// +/// Root Query for the a subgraph. Exposes +/// product: Product. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("product") + .Type() + .Resolve(_ => AData.Products[0]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/BSubgraph.cs new file mode 100644 index 00000000000..de70029cb69 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/BSubgraph.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// include-skip audit suite. Owns Product.isExpensive +/// (@requires(price)). +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/IncludeSkip/B/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/Product.cs new file mode 100644 index 00000000000..20f0d4d4064 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/Product.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.Fusion.Suites.IncludeSkip.B; + +/// +/// The Product entity in the b subgraph +/// (@key(fields: "id")). Owns isExpensive; price +/// is external. +/// +public sealed class Product +{ + public string Id { get; set; } = default!; + + public double? Price { get; set; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/ProductType.cs new file mode 100644 index 00000000000..99c53a02b8d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/ProductType.cs @@ -0,0 +1,38 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.B; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// b subgraph (@key(fields: "id")). Owns +/// isExpensive via @requires(fields: "price"). +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).Type>(); + descriptor.Field(p => p.Price).External().Type>(); + + descriptor + .Field("isExpensive") + .Type>() + .Requires("price") + .Resolve(ctx => + { + var product = ctx.Parent(); + if (product.Price is not double price) + { + throw new InvalidOperationException("Price is missing."); + } + return price > 500d; + }); + } + + private static Product ResolveById(string id) => new() { Id = id }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/QueryType.cs new file mode 100644 index 00000000000..85f07a3b4f6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/B/QueryType.cs @@ -0,0 +1,17 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.B; + +/// +/// Root Query placeholder for the b subgraph. +/// +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/IncludeSkip/C/CSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/CSubgraph.cs new file mode 100644 index 00000000000..3d7a8cba31a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/CSubgraph.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.C; + +/// +/// Builds the c Apollo Federation subgraph for the +/// include-skip audit suite. Owns the include/skip toggles plus +/// the never-called twins, all of which depend on isExpensive +/// via @requires. +/// +public static class CSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "c"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/IncludeSkip/C/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/Product.cs new file mode 100644 index 00000000000..9319bb1de45 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/Product.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.IncludeSkip.C; + +/// +/// The Product entity in the c subgraph +/// (@key(fields: "id")). Owns include, skip, +/// neverCalledInclude, neverCalledSkip; isExpensive +/// is external. +/// +public sealed class Product +{ + public string Id { get; set; } = default!; + + public bool? IsExpensive { get; set; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/ProductType.cs new file mode 100644 index 00000000000..0528ecfa292 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/ProductType.cs @@ -0,0 +1,67 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.C; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// c subgraph (@key(fields: "id")). Owns +/// include/skip and the never-called twins, all of which +/// require isExpensive from subgraph b. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).Type>(); + descriptor.Field(p => p.IsExpensive).External().Type>(); + + descriptor + .Field("include") + .Type>() + .Requires("isExpensive") + .Resolve(ctx => + { + var product = ctx.Parent(); + if (product.IsExpensive is null) + { + throw new InvalidOperationException("isExpensive is missing."); + } + return true; + }); + + descriptor + .Field("skip") + .Type>() + .Requires("isExpensive") + .Resolve(ctx => + { + var product = ctx.Parent(); + if (product.IsExpensive is null) + { + throw new InvalidOperationException("isExpensive is missing."); + } + return true; + }); + + descriptor + .Field("neverCalledInclude") + .Type>() + .Requires("isExpensive") + .Resolve(_ => throw new InvalidOperationException( + "neverCalledInclude should not be called.")); + + descriptor + .Field("neverCalledSkip") + .Type>() + .Requires("isExpensive") + .Resolve(_ => throw new InvalidOperationException( + "neverCalledSkip should not be called.")); + } + + private static Product ResolveById(string id) => new() { Id = id }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/QueryType.cs new file mode 100644 index 00000000000..505a974c483 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/C/QueryType.cs @@ -0,0 +1,17 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.IncludeSkip.C; + +/// +/// Root Query placeholder for the c subgraph. +/// +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/IncludeSkip/IncludeSkipTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/IncludeSkipTests.cs index c6f7a010b76..34ae229f399 100644 --- 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 @@ -1,10 +1,105 @@ +using HotChocolate.Fusion.Suites.IncludeSkip.A; +using HotChocolate.Fusion.Suites.IncludeSkip.B; +using HotChocolate.Fusion.Suites.IncludeSkip.C; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the include-skip suite from +/// graphql-hive/federation-gateway-audit. Three Apollo Federation +/// subgraphs share the Product entity. The audit verifies that +/// @include and @skip short-circuit downstream resolvers +/// (the neverCalledInclude and neverCalledSkip resolvers +/// throw if invoked) and that toggled fields still receive their +/// @requires dependencies. +/// public sealed class IncludeSkipTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync), + (CSubgraph.Name, CSubgraph.BuildAsync)); + + /// + /// @include(if: false) should keep the planner from invoking + /// the neverCalledInclude resolver. + /// + [Fact] + public Task NeverCalledInclude_Is_Skipped_When_Include_False() => RunAsync( + query: """ + query ($bool: Boolean = false) { + product { + price + neverCalledInclude @include(if: $bool) + } + } + """, + expectedData: """ + { + "product": { "price": 699.99 } + } + """); + + /// + /// @skip(if: true) should keep the planner from invoking + /// the neverCalledSkip resolver. + /// + [Fact] + public Task NeverCalledSkip_Is_Skipped_When_Skip_True() => RunAsync( + query: """ + query ($bool: Boolean = true) { + product { + price + neverCalledSkip @skip(if: $bool) + } + } + """, + expectedData: """ + { + "product": { "price": 699.99 } + } + """); + + /// + /// @include(if: true) keeps the field active. The chained + /// @requires path must still deliver isExpensive to + /// c's include resolver. + /// + [Fact] + public Task Include_Resolves_Through_Requires_Chain() => RunAsync( + query: """ + query ($bool: Boolean = true) { + product { + price + include @include(if: $bool) + } + } + """, + expectedData: """ + { + "product": { "price": 699.99, "include": true } + } + """); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// @skip(if: false) keeps the field active. The chained + /// @requires path must still deliver isExpensive to + /// c's skip resolver. + /// + [Fact] + public Task Skip_Resolves_Through_Requires_Chain() => RunAsync( + query: """ + query ($bool: Boolean = false) { + product { + price + skip @skip(if: $bool) + } + } + """, + expectedData: """ + { + "product": { "price": 699.99, "skip": true } + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/a.graphql new file mode 100644 index 00000000000..abe43edf464 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/a.graphql @@ -0,0 +1,11 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Query { + product: Product +} + +type Product @key(fields: "id") { + id: ID! + price: Float! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/b.graphql new file mode 100644 index 00000000000..24bfdad4093 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/b.graphql @@ -0,0 +1,11 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@requires"] + ) + +type Product @key(fields: "id") { + id: ID! + price: Float! @external + isExpensive: Boolean! @requires(fields: "price") +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/c.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/c.graphql new file mode 100644 index 00000000000..784be8facf6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/c.graphql @@ -0,0 +1,14 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@requires"] + ) + +type Product @key(fields: "id") { + id: ID! + isExpensive: Boolean! @external + include: Boolean! @requires(fields: "isExpensive") + skip: Boolean! @requires(fields: "isExpensive") + neverCalledInclude: Boolean! @requires(fields: "isExpensive") + neverCalledSkip: Boolean! @requires(fields: "isExpensive") +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/data.json new file mode 100644 index 00000000000..7ac48d89c65 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/data.json @@ -0,0 +1,5 @@ +{ + "products": [ + { "id": "p1", "price": 699.99 } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/tests.json new file mode 100644 index 00000000000..929c3484589 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/IncludeSkip/Reference/tests.json @@ -0,0 +1,34 @@ +[ + { + "query": "query ($bool: Boolean = false) {\n product {\n price\n neverCalledInclude @include(if: $bool)\n }\n}\n", + "expected": { + "data": { + "product": { "price": 699.99 } + } + } + }, + { + "query": "query ($bool: Boolean = true) {\n product {\n price\n neverCalledSkip @skip(if: $bool)\n }\n}\n", + "expected": { + "data": { + "product": { "price": 699.99 } + } + } + }, + { + "query": "query ($bool: Boolean = true) {\n product {\n price\n include @include(if: $bool)\n }\n}\n", + "expected": { + "data": { + "product": { "price": 699.99, "include": true } + } + } + }, + { + "query": "query ($bool: Boolean = false) {\n product {\n price\n skip @skip(if: $bool)\n }\n}\n", + "expected": { + "data": { + "product": { "price": 699.99, "skip": true } + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/AData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/AData.cs new file mode 100644 index 00000000000..9bd2c7f104f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/AData.cs @@ -0,0 +1,16 @@ +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.A; + +/// +/// Seed data for the a subgraph. +/// +internal static class AData +{ + public static readonly IReadOnlyList Users = + [ + new User { Id = "u1", Name = "u1-name" }, + new User { Id = "u2", Name = "u2-name" } + ]; + + 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/InputObjectIntersection/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/ASubgraph.cs new file mode 100644 index 00000000000..05e4646f38d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/ASubgraph.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// input-object-intersection audit suite. +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/InputObjectIntersection/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/QueryType.cs new file mode 100644 index 00000000000..f6d9139b115 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/QueryType.cs @@ -0,0 +1,21 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.A; + +/// +/// Root Query for the a subgraph. Exposes +/// usersInA(filter: UsersFilter!). +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("usersInA") + .Argument("filter", a => a.Type>()) + .Type>>() + .Resolve(_ => AData.Users); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/User.cs new file mode 100644 index 00000000000..f4974f07701 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/User.cs @@ -0,0 +1,11 @@ +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.A; + +/// +/// The User entity in the a subgraph. +/// +public sealed class User +{ + public string Id { get; init; } = default!; + + public string Name { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UserType.cs new file mode 100644 index 00000000000..abc44145ae7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UserType.cs @@ -0,0 +1,24 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.A; + +/// +/// Apollo Federation descriptor for the User entity in subgraph +/// a (@key(fields: "id")). +/// +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.Name).Shareable().Type>(); + } + + private static User? ResolveById(string id) + => AData.ById.TryGetValue(id, out var u) ? u : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UsersFilter.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UsersFilter.cs new file mode 100644 index 00000000000..358dc24e803 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UsersFilter.cs @@ -0,0 +1,7 @@ +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.A; + +/// +/// The UsersFilter input object in the a subgraph: only +/// declares first. +/// +public sealed record UsersFilter(int First); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UsersFilterType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UsersFilterType.cs new file mode 100644 index 00000000000..af602b8b6ca --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/A/UsersFilterType.cs @@ -0,0 +1,16 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.A; + +/// +/// Descriptor for the UsersFilter input object in subgraph +/// a. +/// +public sealed class UsersFilterType : InputObjectType +{ + protected override void Configure(IInputObjectTypeDescriptor descriptor) + { + descriptor.Name("UsersFilter"); + descriptor.Field(f => f.First).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/BData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/BData.cs new file mode 100644 index 00000000000..ead34f209eb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/BData.cs @@ -0,0 +1,16 @@ +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.B; + +/// +/// Seed data for the b subgraph. +/// +internal static class BData +{ + public static readonly IReadOnlyList Users = + [ + new User { Id = "u1", Name = "u1-name" }, + new User { Id = "u2", Name = "u2-name" } + ]; + + 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/InputObjectIntersection/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/BSubgraph.cs new file mode 100644 index 00000000000..02e9a0fc1b6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/BSubgraph.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// input-object-intersection audit suite. +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/InputObjectIntersection/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/QueryType.cs new file mode 100644 index 00000000000..5519c3fc96a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/QueryType.cs @@ -0,0 +1,21 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.B; + +/// +/// Root Query for the b subgraph. Exposes +/// usersInB(filter: UsersFilter!). +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("usersInB") + .Argument("filter", a => a.Type>()) + .Type>>() + .Resolve(_ => BData.Users); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/User.cs new file mode 100644 index 00000000000..5f12a4b2b89 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/User.cs @@ -0,0 +1,11 @@ +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.B; + +/// +/// The User entity in the b subgraph. +/// +public sealed class User +{ + public string Id { get; init; } = default!; + + public string Name { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UserType.cs new file mode 100644 index 00000000000..349e9514ba6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UserType.cs @@ -0,0 +1,24 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.B; + +/// +/// Apollo Federation descriptor for the User entity in subgraph +/// b (@key(fields: "id")). +/// +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.Name).Shareable().Type>(); + } + + private static User? ResolveById(string id) + => BData.ById.TryGetValue(id, out var u) ? u : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UsersFilter.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UsersFilter.cs new file mode 100644 index 00000000000..7f3c897428b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UsersFilter.cs @@ -0,0 +1,8 @@ +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.B; + +/// +/// The UsersFilter input object in the b subgraph: declares +/// both offset and first. Composition takes the +/// intersection of input fields, so only first is exposed. +/// +public sealed record UsersFilter(int First, int? Offset); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UsersFilterType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UsersFilterType.cs new file mode 100644 index 00000000000..8c1a06c7a17 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/B/UsersFilterType.cs @@ -0,0 +1,17 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.InputObjectIntersection.B; + +/// +/// Descriptor for the UsersFilter input object in subgraph +/// b. +/// +public sealed class UsersFilterType : InputObjectType +{ + protected override void Configure(IInputObjectTypeDescriptor descriptor) + { + descriptor.Name("UsersFilter"); + descriptor.Field(f => f.Offset).Type(); + descriptor.Field(f => f.First).Type>(); + } +} 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 index f08e944b231..072702bfcd2 100644 --- 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 @@ -1,10 +1,66 @@ +using HotChocolate.Fusion.Suites.InputObjectIntersection.A; +using HotChocolate.Fusion.Suites.InputObjectIntersection.B; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the input-object-intersection suite from +/// graphql-hive/federation-gateway-audit. Two Apollo Federation +/// subgraphs each declare a UsersFilter input object with a +/// different field set; the supergraph must expose only the +/// intersection (first only). +/// public sealed class InputObjectIntersectionTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync)); + + /// + /// The first field is in the intersection so the query + /// succeeds. + /// + [Fact] + public Task UsersInA_With_First_Returns_All_Users() => RunAsync( + query: """ + query { + usersInA(filter: { first: 1 }) { id } + } + """, + expectedData: """ + { + "usersInA": [ + { "id": "u1" }, + { "id": "u2" } + ] + } + """); + + /// + /// offset is only declared by subgraph b and therefore + /// not part of the supergraph's intersection. Validation must + /// reject the query. + /// + [Fact] + public Task UsersInA_With_Offset_Is_Rejected() => RunAsync( + query: """ + query { + usersInA(filter: { first: 1, offset: 2 }) { id } + } + """, + expectsErrors: true); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// Even on subgraph b's own root field, the supergraph + /// intersection forbids offset. + /// + [Fact] + public Task UsersInB_With_Offset_Is_Rejected() => RunAsync( + query: """ + query { + usersInB(filter: { first: 1, offset: 2 }) { id } + } + """, + expectsErrors: true); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/a.graphql new file mode 100644 index 00000000000..8314b1bc22d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/a.graphql @@ -0,0 +1,18 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +input UsersFilter { + first: Int! +} + +type User @key(fields: "id") { + id: ID! + name: String! @shareable +} + +type Query { + usersInA(filter: UsersFilter!): [User!] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/b.graphql new file mode 100644 index 00000000000..822d785862c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/b.graphql @@ -0,0 +1,19 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +input UsersFilter { + offset: Int + first: Int! +} + +type User @key(fields: "id") { + id: ID! + name: String! @shareable +} + +type Query { + usersInB(filter: UsersFilter!): [User!] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/data.json new file mode 100644 index 00000000000..8d45d9de89b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/data.json @@ -0,0 +1,6 @@ +{ + "users": [ + { "id": "u1", "name": "u1-name" }, + { "id": "u2", "name": "u2-name" } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/tests.json new file mode 100644 index 00000000000..092e849b91c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/InputObjectIntersection/Reference/tests.json @@ -0,0 +1,25 @@ +[ + { + "query": "query {\n usersInA(filter: { first: 1 }) { id }\n}\n", + "expected": { + "data": { + "usersInA": [ + { "id": "u1" }, + { "id": "u2" } + ] + } + } + }, + { + "query": "query {\n usersInA(filter: { first: 1, offset: 2 }) { id }\n}\n", + "expected": { + "errors": true + } + }, + { + "query": "query {\n usersInB(filter: { first: 1, offset: 2 }) { id }\n}\n", + "expected": { + "errors": true + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/A.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/A.cs new file mode 100644 index 00000000000..174aa579e1a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/A.cs @@ -0,0 +1,16 @@ +namespace HotChocolate.Fusion.Suites.KeysMashup.A; + +/// +/// The A entity in the a subgraph (multiple keys, only +/// id is resolvable). +/// +public sealed class A +{ + public string Id { get; init; } = default!; + + public string PId { get; init; } = default!; + + public CompositeID CompositeId { get; init; } = default!; + + public string Name { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/AData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/AData.cs new file mode 100644 index 00000000000..3d462a21c16 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/AData.cs @@ -0,0 +1,38 @@ +namespace HotChocolate.Fusion.Suites.KeysMashup.A; + +/// +/// Seed data for the a subgraph. +/// +internal static class AData +{ + public static readonly IReadOnlyList Items = + [ + new A + { + Id = "1", + PId = "a.1.pId", + Name = "a.1", + CompositeId = new CompositeID + { + One = "a.1.compositeId.one", + Two = "a.1.compositeId.two", + Three = "a.1.compositeId.three" + } + }, + new A + { + Id = "2", + PId = "a.2.pId", + Name = "a.2", + CompositeId = new CompositeID + { + One = "a.2.compositeId.one", + Two = "a.2.compositeId.two", + Three = "a.2.compositeId.three" + } + } + ]; + + public static readonly IReadOnlyDictionary ById = + Items.ToDictionary(static a => a.Id, StringComparer.Ordinal); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/ASubgraph.cs new file mode 100644 index 00000000000..446b2be154f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/ASubgraph.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.KeysMashup.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// keys-mashup audit suite. Owns the A entity with four +/// keys (only id is resolvable) plus the name field. +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/KeysMashup/A/AType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/AType.cs new file mode 100644 index 00000000000..4c59a92a7a6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/AType.cs @@ -0,0 +1,34 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.KeysMashup.A; + +/// +/// Apollo Federation descriptor for the A entity in subgraph +/// a. Declares four keys (id, pId, composite, deeply composite), +/// only the id key is resolvable; the other keys exist solely so +/// the planner can recognize them on the supergraph side. +/// +public sealed class AType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("A"); + + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Key("pId", resolvable: false); + descriptor.Key("compositeId { one two }", resolvable: false); + descriptor.Key("id compositeId { two three }", resolvable: false); + + descriptor.Field(a => a.Id).Type>(); + descriptor.Field(a => a.PId).Name("pId").Type>(); + descriptor.Field(a => a.CompositeId).Name("compositeId").Type>(); + descriptor.Field(a => a.Name).Type>(); + } + + private static A? ResolveById(string id) + => AData.ById.TryGetValue(id, out var a) ? a : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/CompositeID.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/CompositeID.cs new file mode 100644 index 00000000000..3b0eb6eb7b4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/CompositeID.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.Fusion.Suites.KeysMashup.A; + +/// +/// The CompositeID value type owned by the a subgraph. +/// +public sealed class CompositeID +{ + public string One { get; init; } = default!; + + public string Two { get; init; } = default!; + + public string Three { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/CompositeIDType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/CompositeIDType.cs new file mode 100644 index 00000000000..badf1a01ed4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/CompositeIDType.cs @@ -0,0 +1,17 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.KeysMashup.A; + +/// +/// Descriptor for the CompositeID value type in subgraph a. +/// +public sealed class CompositeIDType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("CompositeID"); + descriptor.Field(c => c.One).Type>(); + descriptor.Field(c => c.Two).Type>(); + descriptor.Field(c => c.Three).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/QueryType.cs new file mode 100644 index 00000000000..3ef3c72eec1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/A/QueryType.cs @@ -0,0 +1,17 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.KeysMashup.A; + +/// +/// Root Query placeholder for the a subgraph. +/// +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/KeysMashup/B/A.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/A.cs new file mode 100644 index 00000000000..6d163ba5fa0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/A.cs @@ -0,0 +1,16 @@ +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// The A entity in the b subgraph (multiple keys; only +/// id compositeId { two three } is resolvable). +/// +public sealed class A +{ + public string Id { get; set; } = default!; + + public string PId { get; set; } = default!; + + public CompositeID CompositeId { get; set; } = default!; + + public string? Name { get; set; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/AType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/AType.cs new file mode 100644 index 00000000000..528bfe5f99a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/AType.cs @@ -0,0 +1,63 @@ +using HotChocolate.ApolloFederation.Resolvers; +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// Apollo Federation descriptor for the A entity in subgraph +/// b. Declares four keys, the id compositeId { two three } +/// key is resolvable. name is external; nameInB uses +/// @requires(fields: "name"). +/// +public sealed class AType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("A"); + + descriptor.Key("compositeId { one two }", resolvable: false); + descriptor + .Key("id compositeId { two three }") + .ResolveReferenceWith(_ => ResolveByIdAndComposite(default!, default!, default!)); + descriptor.Key("pId", resolvable: false); + descriptor.Key("id", resolvable: false); + + descriptor.Field(a => a.Id).Type>(); + descriptor.Field(a => a.PId).Name("pId").Type>(); + descriptor.Field(a => a.CompositeId).Name("compositeId").Type>(); + descriptor.Field(a => a.Name).External().Type>(); + + descriptor + .Field("nameInB") + .Type>() + .Requires("name") + .Resolve(ctx => + { + var entity = ctx.Parent(); + if (entity.Name is not { Length: > 0 } name) + { + throw new InvalidOperationException("A.name was not provided."); + } + return $"b.a.nameInB {name}"; + }); + } + + private static A? ResolveByIdAndComposite( + string id, + [Map("compositeId.two")] string two, + [Map("compositeId.three")] string three) + { + if (!BData.AById.TryGetValue(id, out var entity)) + { + return null; + } + + return new A + { + Id = entity.Id, + PId = entity.PId, + CompositeId = entity.CompositeId + }; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/B.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/B.cs new file mode 100644 index 00000000000..9c2141ca4f5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/B.cs @@ -0,0 +1,11 @@ +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// The B entity in the b subgraph (@key(fields: "id")). +/// +public sealed class B +{ + public string Id { get; init; } = default!; + + public IReadOnlyList A { get; init; } = []; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/BData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/BData.cs new file mode 100644 index 00000000000..9385508040b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/BData.cs @@ -0,0 +1,41 @@ +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// Seed data for the b subgraph. +/// +internal static class BData +{ + public static readonly IReadOnlyDictionary AById = + new Dictionary(StringComparer.Ordinal) + { + ["1"] = new A + { + Id = "1", + PId = "a.1.pId", + CompositeId = new CompositeID + { + One = "a.1.compositeId.one", + Two = "a.1.compositeId.two", + Three = "a.1.compositeId.three" + } + }, + ["2"] = new A + { + Id = "2", + PId = "a.2.pId", + CompositeId = new CompositeID + { + One = "a.2.compositeId.one", + Two = "a.2.compositeId.two", + Three = "a.2.compositeId.three" + } + } + }; + + public static readonly IReadOnlyDictionary ById = + new Dictionary(StringComparer.Ordinal) + { + ["100"] = new B { Id = "100", A = [AById["1"]] }, + ["200"] = new B { Id = "200", A = [AById["1"], AById["2"]] } + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/BSubgraph.cs new file mode 100644 index 00000000000..1e7c057fa6b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/BSubgraph.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// keys-mashup audit suite. Owns the B entity, the +/// A.nameInB field via @requires(name), and a separate +/// projection of A with four keys. +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/KeysMashup/B/BType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/BType.cs new file mode 100644 index 00000000000..c2259d5c3a5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/BType.cs @@ -0,0 +1,26 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// Apollo Federation descriptor for the B entity in subgraph +/// b (@key(fields: "id")). +/// +public sealed class BType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("B"); + + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(b => b.Id).Type>(); + descriptor.Field(b => b.A).Type>>>(); + } + + private static B? ResolveById(string id) + => BData.ById.TryGetValue(id, out var b) ? b : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/CompositeID.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/CompositeID.cs new file mode 100644 index 00000000000..ed932cf0bc4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/CompositeID.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// The CompositeID value type owned by the b subgraph. +/// +public sealed class CompositeID +{ + public string One { get; init; } = default!; + + public string Two { get; init; } = default!; + + public string Three { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/CompositeIDType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/CompositeIDType.cs new file mode 100644 index 00000000000..eda84bb344d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/CompositeIDType.cs @@ -0,0 +1,17 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// Descriptor for the CompositeID value type in subgraph b. +/// +public sealed class CompositeIDType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("CompositeID"); + descriptor.Field(c => c.One).Type>(); + descriptor.Field(c => c.Two).Type>(); + descriptor.Field(c => c.Three).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/QueryType.cs new file mode 100644 index 00000000000..3e1eeba84e8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/B/QueryType.cs @@ -0,0 +1,19 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.KeysMashup.B; + +/// +/// Root Query for the b subgraph. Exposes b: B. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("b") + .Type() + .Resolve(_ => BData.ById["100"]); + } +} 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 index 569498ccd2e..121714c6497 100644 --- 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 @@ -1,10 +1,53 @@ +using HotChocolate.Fusion.Suites.KeysMashup.A; +using HotChocolate.Fusion.Suites.KeysMashup.B; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the keys-mashup suite from +/// graphql-hive/federation-gateway-audit. Two Apollo Federation +/// subgraphs share the A entity through four overlapping keys +/// (only one is resolvable on each side) plus a deeply nested key. +/// Subgraph b exposes nameInB via @requires(name). +/// public sealed class KeysMashupTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync)); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// Walks b.a[].name (from subgraph a) and + /// b.a[].nameInB (from subgraph b's + /// @requires(name)). + /// + [Fact] + public Task B_Resolves_A_Name_And_NameInB_Via_Requires() => RunAsync( + query: """ + query { + b { + id + a { + id + name + nameInB + } + } + } + """, + expectedData: """ + { + "b": { + "id": "100", + "a": [ + { + "id": "1", + "name": "a.1", + "nameInB": "b.a.nameInB a.1" + } + ] + } + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/a.graphql new file mode 100644 index 00000000000..04c7f48a231 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/a.graphql @@ -0,0 +1,19 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@key"]) + +type A + @key(fields: "id", resolvable: true) + @key(fields: "pId", resolvable: false) + @key(fields: "compositeId { one two }", resolvable: false) + @key(fields: "id compositeId { two three }", resolvable: false) { + id: ID! + pId: ID! + compositeId: CompositeID! + name: String! +} + +type CompositeID { + one: ID! + two: ID! + three: ID! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/b.graphql new file mode 100644 index 00000000000..4140cec890a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/b.graphql @@ -0,0 +1,32 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.5" + import: ["@key", "@requires", "@external"] + ) + +type Query { + b: B +} + +type B @key(fields: "id") { + id: ID! + a: [A!]! +} + +type A + @key(fields: "compositeId { one two }", resolvable: false) + @key(fields: "id compositeId { two three }", resolvable: true) + @key(fields: "pId", resolvable: false) + @key(fields: "id", resolvable: false) { + id: ID! + pId: ID! + compositeId: CompositeID! + name: String! @external + nameInB: String! @requires(fields: "name") +} + +type CompositeID { + one: ID! + two: ID! + three: ID! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/data.json new file mode 100644 index 00000000000..203ed6d37a5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/data.json @@ -0,0 +1,28 @@ +{ + "a": { + "1": { + "id": "1", + "pId": "a.1.pId", + "name": "a.1", + "compositeId": { + "one": "a.1.compositeId.one", + "two": "a.1.compositeId.two", + "three": "a.1.compositeId.three" + } + }, + "2": { + "id": "2", + "pId": "a.2.pId", + "name": "a.2", + "compositeId": { + "one": "a.2.compositeId.one", + "two": "a.2.compositeId.two", + "three": "a.2.compositeId.three" + } + } + }, + "b": { + "100": { "id": "100", "a": ["1"] }, + "200": { "id": "200", "a": ["1", "2"] } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/tests.json new file mode 100644 index 00000000000..a1e690916d0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/KeysMashup/Reference/tests.json @@ -0,0 +1,19 @@ +[ + { + "query": "query {\n b {\n id\n a {\n id\n name\n nameInB\n }\n }\n}\n", + "expected": { + "data": { + "b": { + "id": "100", + "a": [ + { + "id": "1", + "name": "a.1", + "nameInB": "b.a.nameInB a.1" + } + ] + } + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/ASubgraph.cs new file mode 100644 index 00000000000..b06e7249263 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/ASubgraph.cs @@ -0,0 +1,51 @@ +using HotChocolate.Fusion.Suites.Mutations.Shared; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Mutations.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// mutations audit suite. Owns Product.name, Product.price, +/// the shareable Mutation.addCategory, and the Mutation.multiply +/// counter operation. +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// Starts the subgraph's with the supplied + /// shared and returns a + /// that routes /graphql requests to the + /// in-process pipeline. + /// + public static async Task BuildAsync(MutationsState state) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddSingleton(state); + + builder.Services + .AddRouting() + .AddGraphQLServer() + .AddApolloFederation() + .AddQueryType() + .AddMutationType() + .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/Mutations/A/AddProductInput.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/AddProductInput.cs new file mode 100644 index 00000000000..034611ebcfb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/AddProductInput.cs @@ -0,0 +1,6 @@ +namespace HotChocolate.Fusion.Suites.Mutations.A; + +/// +/// Input shape for Mutation.addProduct. +/// +public sealed record AddProductInput(string Name, double Price); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/AddProductInputType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/AddProductInputType.cs new file mode 100644 index 00000000000..0b92a3ff406 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/AddProductInputType.cs @@ -0,0 +1,15 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.A; + +/// +/// Descriptor for the AddProductInput input object on subgraph a. +/// +public sealed class AddProductInputType : InputObjectType +{ + protected override void Configure(IInputObjectTypeDescriptor descriptor) + { + descriptor.Field(i => i.Name).Type>(); + descriptor.Field(i => i.Price).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/CategoryType.cs new file mode 100644 index 00000000000..252c2f0fc1a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/CategoryType.cs @@ -0,0 +1,27 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Fusion.Suites.Mutations.Shared; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.A; + +/// +/// Apollo Federation descriptor for the Category entity in the +/// a subgraph (@key(fields: "id")). Carries only the id +/// field; name is owned by subgraph b. +/// +public sealed class CategoryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!, default!)); + + descriptor.Field(c => c.Id).Type>(); + descriptor.Ignore(c => c.Name); + } + + private static Category? ResolveById(string id, [Service] MutationsState state) + => state.GetCategories().FirstOrDefault( + c => string.Equals(c.Id, id, StringComparison.Ordinal)); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/MutationType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/MutationType.cs new file mode 100644 index 00000000000..59556f4a0c3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/MutationType.cs @@ -0,0 +1,55 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Fusion.Suites.Mutations.Shared; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.A; + +/// +/// Root Mutation for the a subgraph. Exposes addProduct, +/// multiply, and the shareable addCategory. +/// +public sealed class MutationType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Mutation); + + descriptor + .Field("addProduct") + .Argument("input", a => a.Type>()) + .Type>() + .Resolve(ctx => + { + var state = ctx.Service(); + var input = ctx.ArgumentValue("input"); + return state.AddProduct(input.Name, input.Price); + }); + + descriptor + .Field("addCategory") + .Argument("name", a => a.Type>()) + .Argument("requestId", a => a.Type>()) + .Type>() + .Shareable() + .Resolve(ctx => + { + var state = ctx.Service(); + var name = ctx.ArgumentValue("name"); + var requestId = ctx.ArgumentValue("requestId"); + return state.AddCategory(name, requestId); + }); + + descriptor + .Field("multiply") + .Argument("by", a => a.Type>()) + .Argument("requestId", a => a.Type>()) + .Type>() + .Resolve(ctx => + { + var state = ctx.Service(); + var by = ctx.ArgumentValue("by"); + var requestId = ctx.ArgumentValue("requestId"); + return state.MultiplyNumber(by, requestId); + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/ProductType.cs new file mode 100644 index 00000000000..5a6117f5667 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/ProductType.cs @@ -0,0 +1,42 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Fusion.Suites.Mutations.Shared; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.A; + +/// +/// Apollo Federation descriptor for the Product entity owned by the +/// a subgraph (@key(fields: "id")). Owns name and +/// price. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!, default!)); + + descriptor.Field(p => p.Id).Type>(); + descriptor.Field(p => p.Name).Type>(); + descriptor.Field(p => p.Price).Type>(); + } + + private static Product? ResolveById(string id, [Service] MutationsState state) + { + state.InitProducts(); + var product = state.GetProducts().FirstOrDefault( + p => string.Equals(p.Id, id, StringComparison.Ordinal)); + + if (product is null) + { + return null; + } + + // Mirror the audit's behavior: a's _resolveReference deletes the + // product after returning it. This catches planners that issue + // redundant entity calls. + state.DeleteProduct(product.Id); + return product; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/QueryType.cs new file mode 100644 index 00000000000..862328ad122 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/A/QueryType.cs @@ -0,0 +1,39 @@ +using HotChocolate.Fusion.Suites.Mutations.Shared; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.A; + +/// +/// Root Query for the a subgraph. Exposes +/// product(id: ID!): Product! and products: [Product!]!. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("product") + .Argument("id", a => a.Type>()) + .Type>() + .Resolve(ctx => + { + var state = ctx.Service(); + state.InitProducts(); + var id = ctx.ArgumentValue("id"); + return state.GetProducts().FirstOrDefault( + p => string.Equals(p.Id, id, StringComparison.Ordinal)); + }); + + descriptor + .Field("products") + .Type>>>() + .Resolve(ctx => + { + var state = ctx.Service(); + state.InitProducts(); + return state.GetProducts(); + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/BSubgraph.cs new file mode 100644 index 00000000000..2515764082a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/BSubgraph.cs @@ -0,0 +1,49 @@ +using HotChocolate.Fusion.Suites.Mutations.Shared; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Mutations.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// mutations audit suite. Owns Product.isExpensive, +/// Product.isAvailable, and Category.name, plus the +/// Mutation.delete and shareable Mutation.addCategory. +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// Starts the subgraph's with the supplied + /// shared and returns a + /// . + /// + public static async Task BuildAsync(MutationsState state) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddSingleton(state); + + builder.Services + .AddRouting() + .AddGraphQLServer() + .AddApolloFederation() + .AddQueryType() + .AddMutationType() + .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/Mutations/B/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/CategoryType.cs new file mode 100644 index 00000000000..2c6e570cdfb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/CategoryType.cs @@ -0,0 +1,27 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Fusion.Suites.Mutations.Shared; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.B; + +/// +/// Apollo Federation descriptor for the Category entity in the +/// b subgraph (@key(fields: "id")). Owns the name +/// field on top of the shared id contributed by subgraph a. +/// +public sealed class CategoryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!, default!)); + + descriptor.Field(c => c.Id).Type>(); + descriptor.Field(c => c.Name).Type>(); + } + + private static Category? ResolveById(string id, [Service] MutationsState state) + => state.GetCategories().FirstOrDefault( + c => string.Equals(c.Id, id, StringComparison.Ordinal)); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/MutationType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/MutationType.cs new file mode 100644 index 00000000000..1fb6d310982 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/MutationType.cs @@ -0,0 +1,42 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Fusion.Suites.Mutations.Shared; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.B; + +/// +/// Root Mutation for the b subgraph. Exposes delete +/// (clears a counter) and the shareable addCategory. +/// +public sealed class MutationType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Mutation); + + descriptor + .Field("delete") + .Argument("requestId", a => a.Type>()) + .Type>() + .Resolve(ctx => + { + var state = ctx.Service(); + var requestId = ctx.ArgumentValue("requestId"); + return state.DeleteNumber(requestId); + }); + + descriptor + .Field("addCategory") + .Argument("name", a => a.Type>()) + .Argument("requestId", a => a.Type>()) + .Type>() + .Shareable() + .Resolve(ctx => + { + var state = ctx.Service(); + var name = ctx.ArgumentValue("name"); + var requestId = ctx.ArgumentValue("requestId"); + return state.AddCategory(name, requestId); + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/Product.cs new file mode 100644 index 00000000000..bf85531afa1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/Product.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Suites.Mutations.B; + +/// +/// The Product projection used by the b subgraph. The +/// Price field is marked external in the SDL: it travels in via the +/// entity reference so @requires can use it. +/// uses a public setter so the federation runtime can populate it from the +/// representation. +/// +public sealed class Product +{ + public string Id { get; set; } = default!; + + public double? Price { get; set; } + + public bool IsAvailable { get; set; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/ProductType.cs new file mode 100644 index 00000000000..52ee7492352 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/ProductType.cs @@ -0,0 +1,62 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Fusion.Suites.Mutations.Shared; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.B; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// b subgraph (@key(fields: "id")). Owns +/// isExpensive (which uses @requires(fields: "price")) and +/// isAvailable; price is external. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!, default!)); + + descriptor.Field(p => p.Id).Type>(); + descriptor.Field(p => p.Price).External().Type>(); + descriptor.Field(p => p.IsAvailable).Type>(); + + descriptor + .Field("isExpensive") + .Type>() + .Requires("price") + .Resolve(ctx => + { + var product = ctx.Parent(); + if (product.Price is not double price) + { + throw new InvalidOperationException("Price is not available."); + } + + return price > 100d; + }); + } + + private static Product? ResolveById(string id, [Service] MutationsState state) + { + var product = state.GetProducts().FirstOrDefault( + p => string.Equals(p.Id, id, StringComparison.Ordinal)); + + if (product is null) + { + return null; + } + + // Mirror the audit's behavior: b's _resolveReference deletes the + // product after returning it. + state.DeleteProduct(product.Id); + + return new Product + { + Id = product.Id, + Price = product.Price, + IsAvailable = true + }; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/QueryType.cs new file mode 100644 index 00000000000..d706e1e6771 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/B/QueryType.cs @@ -0,0 +1,19 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.B; + +/// +/// Root Query placeholder for the b subgraph. The subgraph +/// declares no user-facing query fields; +/// 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/Mutations/C/CSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/C/CSubgraph.cs new file mode 100644 index 00000000000..97cad74a032 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/C/CSubgraph.cs @@ -0,0 +1,46 @@ +using HotChocolate.Fusion.Suites.Mutations.Shared; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Mutations.C; + +/// +/// Builds the c Apollo Federation subgraph for the +/// mutations audit suite. Owns the Mutation.add counter +/// operation only. +/// +public static class CSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "c"; + + /// + /// Starts the subgraph's with the supplied + /// shared and returns a + /// . + /// + public static async Task BuildAsync(MutationsState state) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddSingleton(state); + + builder.Services + .AddRouting() + .AddGraphQLServer() + .AddApolloFederation() + .AddQueryType() + .AddMutationType(); + + 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/Mutations/C/MutationType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/C/MutationType.cs new file mode 100644 index 00000000000..6337a5a13fb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/C/MutationType.cs @@ -0,0 +1,29 @@ +using HotChocolate.Fusion.Suites.Mutations.Shared; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.C; + +/// +/// Root Mutation for the c subgraph. Exposes add +/// (increments a counter keyed by requestId). +/// +public sealed class MutationType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Mutation); + + descriptor + .Field("add") + .Argument("num", a => a.Type>()) + .Argument("requestId", a => a.Type>()) + .Type>() + .Resolve(ctx => + { + var state = ctx.Service(); + var num = ctx.ArgumentValue("num"); + var requestId = ctx.ArgumentValue("requestId"); + return state.AddNumber(num, requestId); + }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/C/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/C/QueryType.cs new file mode 100644 index 00000000000..0381935c150 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/C/QueryType.cs @@ -0,0 +1,25 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Mutations.C; + +/// +/// Root Query placeholder for the c subgraph. The subgraph +/// declares no user-facing query fields. The federation infrastructure +/// (_service, _entities) is stripped during composition, so a +/// single inaccessible _noop field is added to keep the Query +/// type non-empty for source schema validation. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("_noop") + .Type() + .Inaccessible() + .Resolve(_ => (string?)null); + } +} 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 index 8d859dfe615..62a43272ecb 100644 --- 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 @@ -1,10 +1,131 @@ +using HotChocolate.Fusion.Suites.Mutations.A; +using HotChocolate.Fusion.Suites.Mutations.B; +using HotChocolate.Fusion.Suites.Mutations.C; +using HotChocolate.Fusion.Suites.Mutations.Shared; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the mutations suite from +/// graphql-hive/federation-gateway-audit. Three Apollo Federation +/// subgraphs (a, b, c) share a single mutable state +/// object so cross-subgraph mutation ordering can be verified end-to-end. +/// public sealed class MutationsTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + { + var state = new MutationsState(); + return FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, () => ASubgraph.BuildAsync(state)), + (BSubgraph.Name, () => BSubgraph.BuildAsync(state)), + (CSubgraph.Name, () => CSubgraph.BuildAsync(state))); + } + + /// + /// addProduct in subgraph a creates the entity; subgraph + /// b contributes isExpensive (via @requires(price)) + /// and isAvailable through an entity reference. + /// + [Fact] + public Task AddProduct_Composes_From_Two_Subgraphs() => RunAsync( + query: """ + mutation { + addProduct(input: { name: "new", price: 599.99 }) { + name + price + isExpensive + isAvailable + } + } + """, + expectedData: """ + { + "addProduct": { + "name": "new", + "price": 599.99, + "isExpensive": true, + "isAvailable": true + } + } + """); + + /// + /// Query.product in a returns the seeded product; the + /// planner enriches it with isExpensive and isAvailable + /// from b. + /// + [Fact] + public Task Product_Composes_From_Two_Subgraphs() => RunAsync( + query: """ + query { + product(id: "p1") { + id + name + price + isExpensive + isAvailable + } + } + """, + expectedData: """ + { + "product": { + "id": "p1", + "name": "p1-name", + "price": 9.99, + "isExpensive": false, + "isAvailable": true + } + } + """); + + /// + /// Mixed-subgraph mutation chain. The four operations route to c, + /// a, c, and b; GraphQL requires serial execution + /// of mutation root fields so the running tally reaches the expected + /// final value before delete consumes it. + /// + [Fact] + public Task Mutation_Chain_Executes_In_Order_Across_Three_Subgraphs() => RunAsync( + query: """ + mutation { + five: add(num: 5, requestId: "r1") + ten: multiply(by: 2, requestId: "r1") + twelve: add(num: 2, requestId: "r1") + final: delete(requestId: "r1") + } + """, + expectedData: """ + { + "five": 5, + "ten": 10, + "twelve": 12, + "final": 12 + } + """); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// Shareable addCategory mutation. The planner picks one + /// subgraph (b owns the name field) and the result + /// includes both id and name. + /// + [Fact] + public Task AddCategory_Routes_Shareable_Mutation_To_Owning_Subgraph() => RunAsync( + query: """ + mutation { + addCategory(name: "new", requestId: "r2") { + id + name + } + } + """, + expectedData: """ + { + "addCategory": { + "id": "c-added-r2", + "name": "new" + } + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/a.graphql new file mode 100644 index 00000000000..a922d21b8a4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/a.graphql @@ -0,0 +1,31 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Mutation { + addProduct(input: AddProductInput!): Product! + addCategory(name: String!, requestId: String!): Category! @shareable + multiply(by: Int!, requestId: String!): Int! +} + +type Query { + product(id: ID!): Product! + products: [Product!]! +} + +input AddProductInput { + name: String! + price: Float! +} + +type Product @key(fields: "id") { + id: ID! + name: String! + price: Float! +} + +type Category @key(fields: "id") { + id: ID! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/b.graphql new file mode 100644 index 00000000000..ec73d8e19fc --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/b.graphql @@ -0,0 +1,22 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@requires", "@shareable"] + ) + +type Product @key(fields: "id") { + id: ID! + price: Float! @external + isExpensive: Boolean! @requires(fields: "price") + isAvailable: Boolean! +} + +type Mutation { + delete(requestId: String!): Int! + addCategory(name: String!, requestId: String!): Category! @shareable +} + +type Category @key(fields: "id") { + id: ID! + name: String! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/c.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/c.graphql new file mode 100644 index 00000000000..a37fa948046 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/c.graphql @@ -0,0 +1,6 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Mutation { + add(num: Int!, requestId: String!): Int! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/data.json new file mode 100644 index 00000000000..bf9df183f97 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/data.json @@ -0,0 +1,3 @@ +{ + "initialProduct": { "id": "p1", "name": "p1-name", "price": 9.99 } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/tests.json new file mode 100644 index 00000000000..23c8ae42cf2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Reference/tests.json @@ -0,0 +1,51 @@ +[ + { + "query": "mutation {\n addProduct(input: { name: \"new\", price: 599.99 }) {\n name\n price\n isExpensive\n isAvailable\n }\n}\n", + "expected": { + "data": { + "addProduct": { + "name": "new", + "price": 599.99, + "isExpensive": true, + "isAvailable": true + } + } + } + }, + { + "query": "query {\n product(id: \"p1\") {\n id\n name\n price\n isExpensive\n isAvailable\n }\n}\n", + "expected": { + "data": { + "product": { + "id": "p1", + "name": "p1-name", + "price": 9.99, + "isExpensive": false, + "isAvailable": true + } + } + } + }, + { + "query": "mutation {\n five: add(num: 5, requestId: \"r1\")\n ten: multiply(by: 2, requestId: \"r1\")\n twelve: add(num: 2, requestId: \"r1\")\n final: delete(requestId: \"r1\")\n}\n", + "expected": { + "data": { + "five": 5, + "ten": 10, + "twelve": 12, + "final": 12 + } + } + }, + { + "query": "mutation {\n addCategory(name: \"new\", requestId: \"r2\") {\n id\n name\n }\n}\n", + "expected": { + "data": { + "addCategory": { + "id": "c-added-r2", + "name": "new" + } + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Shared/MutationsState.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Shared/MutationsState.cs new file mode 100644 index 00000000000..df294b37fcd --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Mutations/Shared/MutationsState.cs @@ -0,0 +1,141 @@ +using System.Collections.Concurrent; + +namespace HotChocolate.Fusion.Suites.Mutations.Shared; + +/// +/// Per-gateway mutable state shared across the a, b, and +/// c Apollo Federation subgraphs. The audit's data.ts +/// module-level singletons are emulated here so cross-subgraph mutation +/// ordering and shared category lists behave identically. +/// +public sealed class MutationsState +{ + private readonly List _products = []; + private readonly List _categories = []; + private readonly ConcurrentDictionary _numbers = new(StringComparer.Ordinal); + private readonly object _lock = new(); + private bool _initialized; + + /// + /// Returns a snapshot of the current product list. + /// + public IReadOnlyList GetProducts() + { + lock (_lock) + { + return [.. _products]; + } + } + + /// + /// Returns a snapshot of the current category list. + /// + public IReadOnlyList GetCategories() + { + lock (_lock) + { + return [.. _categories]; + } + } + + /// + /// Seeds the initial product (id p1) once per state instance. + /// + public void InitProducts() + { + lock (_lock) + { + if (_initialized) + { + return; + } + + _products.Add(new Product("p1", "p1-name", 9.99)); + _initialized = true; + } + } + + /// + /// Adds a product, returning the newly created . + /// + public Product AddProduct(string name, double price) + { + lock (_lock) + { + var product = new Product($"p-added-{_products.Count}", name, price); + _products.Add(product); + return product; + } + } + + /// + /// Removes the product with the given id. No-op when absent. + /// + public void DeleteProduct(string id) + { + lock (_lock) + { + _products.RemoveAll(p => string.Equals(p.Id, id, StringComparison.Ordinal)); + } + } + + /// + /// Adds a category. Throws when the same + /// has already been used so the planner cannot accidentally call the + /// shareable mutation more than once. + /// + public Category AddCategory(string name, string requestId) + { + lock (_lock) + { + var id = $"c-added-{requestId}"; + + if (_categories.Any(c => string.Equals(c.Id, id, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + "Category with this requestId was already added."); + } + + var category = new Category(id, name); + _categories.Add(category); + return category; + } + } + + /// + /// Adds to the running tally for + /// and returns the new total. + /// + public int AddNumber(int num, string requestId) + { + return _numbers.AddOrUpdate(requestId, num, (_, current) => current + num); + } + + /// + /// Multiplies the running tally for and + /// returns the result. + /// + public int MultiplyNumber(int by, string requestId) + { + return _numbers.AddOrUpdate(requestId, 0, (_, current) => current * by); + } + + /// + /// Removes the running tally for and + /// returns its last value (or 0 when absent). + /// + public int DeleteNumber(string requestId) + { + return _numbers.TryRemove(requestId, out var value) ? value : 0; + } +} + +/// +/// Product record shared across the mutations suite subgraphs. +/// +public sealed record Product(string Id, string Name, double Price); + +/// +/// Category record shared across the mutations suite subgraphs. +/// +public sealed record Category(string Id, string Name); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/Category.cs new file mode 100644 index 00000000000..13a9369852a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/Category.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// The Category projection used by the node subgraph. +/// Carries only the federated key field. +/// +public sealed class Category : INode +{ + public string Id { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/CategoryType.cs new file mode 100644 index 00000000000..0d96d013e6d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/CategoryType.cs @@ -0,0 +1,24 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// Apollo Federation descriptor for the Category entity in the +/// node subgraph (@key(fields: "id")). +/// +public sealed class CategoryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Implements() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(c => c.Id).Type>(); + } + + private static Category? ResolveById(string id) + => NodeData.CategoriesById.TryGetValue(id, out var c) ? c : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/INode.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/INode.cs new file mode 100644 index 00000000000..228619fafac --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/INode.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// The federated Node interface as projected by the node +/// subgraph: a single id field shared by every implementer. +/// +public interface INode +{ + string Id { get; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/NodeData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/NodeData.cs new file mode 100644 index 00000000000..6c1df0c6fc7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/NodeData.cs @@ -0,0 +1,26 @@ +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// Seed data for the node subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/node/data.ts. +/// +internal static class NodeData +{ + public static readonly IReadOnlyList Products = + [ + new Product { Id = "p-1" }, + new Product { Id = "p-2" } + ]; + + public static readonly IReadOnlyList Categories = + [ + new Category { Id = "pc-1" }, + new Category { Id = "c-2" } + ]; + + public static readonly IReadOnlyDictionary ProductsById = + Products.ToDictionary(static p => p.Id, StringComparer.Ordinal); + + 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/Node/Node/NodeSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/NodeSubgraph.cs new file mode 100644 index 00000000000..900fa6f3596 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/NodeSubgraph.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// Builds the node Apollo Federation subgraph for the +/// node audit suite. Owns the Query.productNode and +/// Query.categoryNode root fields returning the federated +/// Node interface. +/// +public static class NodeSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "node"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/Node/Node/NodeType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/NodeType.cs new file mode 100644 index 00000000000..2a1206472ef --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/NodeType.cs @@ -0,0 +1,16 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// Descriptor for the Node interface: the single id field +/// shared by all implementers. +/// +public sealed class NodeType : InterfaceType +{ + protected override void Configure(IInterfaceTypeDescriptor descriptor) + { + descriptor.Name("Node"); + descriptor.Field(n => n.Id).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/Product.cs new file mode 100644 index 00000000000..2dcb36516bb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/Product.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// The Product projection used by the node subgraph. +/// Carries only the federated key field. +/// +public sealed class Product : INode +{ + public string Id { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/ProductType.cs new file mode 100644 index 00000000000..52d12d3a325 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/ProductType.cs @@ -0,0 +1,26 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// node subgraph (@key(fields: "id")). Carries only the key +/// field; name and price are owned by the types +/// subgraph. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Implements() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).Type>(); + } + + private static Product? ResolveById(string id) + => NodeData.ProductsById.TryGetValue(id, out var p) ? p : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/QueryType.cs new file mode 100644 index 00000000000..4302dd19efb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Node/QueryType.cs @@ -0,0 +1,25 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.Node; + +/// +/// Root Query for the node subgraph. Exposes +/// productNode: Node and categoryNode: Node. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("productNode") + .Type() + .Resolve(_ => (INode)NodeData.Products[0]); + + descriptor + .Field("categoryNode") + .Type() + .Resolve(_ => (INode)NodeData.Categories[0]); + } +} 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 index 3a118913fa6..bfee66968b7 100644 --- 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 @@ -1,10 +1,53 @@ +using HotChocolate.Fusion.Suites.Node.Node; +using HotChocolate.Fusion.Suites.Node.NodeTwo; +using HotChocolate.Fusion.Suites.Node.Types; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the node suite from +/// graphql-hive/federation-gateway-audit. Three Apollo Federation +/// subgraphs (node, node-two, types) project the same +/// federated Node interface implemented by Product and +/// Category. The node subgraph owns root fields returning +/// the interface; the types subgraph contributes scalar fields on +/// the concrete implementers. +/// public sealed class NodeTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (NodeSubgraph.Name, NodeSubgraph.BuildAsync), + (NodeTwoSubgraph.Name, NodeTwoSubgraph.BuildAsync), + (TypesSubgraph.Name, TypesSubgraph.BuildAsync)); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// productNode in the node subgraph returns the federated + /// interface; the planner enriches the concrete Product with + /// name and price from the types subgraph. + /// + [Fact] + public Task ProductNode_Resolves_Interface_Through_Concrete_Type() => RunAsync( + query: """ + { + productNode { + ... on Product { + id + name + __typename + price + } + } + } + """, + expectedData: """ + { + "productNode": { + "id": "p-1", + "name": "Product 1", + "__typename": "Product", + "price": 10 + } + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/Category.cs new file mode 100644 index 00000000000..e55a12293b9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/Category.cs @@ -0,0 +1,9 @@ +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// The Category projection used by the node-two subgraph. +/// +public sealed class Category : INode +{ + public string Id { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/CategoryType.cs new file mode 100644 index 00000000000..1775a9bab6c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/CategoryType.cs @@ -0,0 +1,24 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// Apollo Federation descriptor for the Category entity in the +/// node-two subgraph (@key(fields: "id")). +/// +public sealed class CategoryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Implements() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(c => c.Id).Type>(); + } + + private static Category? ResolveById(string id) + => NodeTwoData.CategoriesById.TryGetValue(id, out var c) ? c : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/INode.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/INode.cs new file mode 100644 index 00000000000..3a4f9a3ba65 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/INode.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// The federated Node interface as projected by the node-two +/// subgraph. +/// +public interface INode +{ + string Id { get; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/NodeTwoData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/NodeTwoData.cs new file mode 100644 index 00000000000..e01b182633c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/NodeTwoData.cs @@ -0,0 +1,21 @@ +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// Seed data for the node-two subgraph. +/// +internal static class NodeTwoData +{ + public static readonly IReadOnlyDictionary ProductsById = + new Dictionary(StringComparer.Ordinal) + { + ["p-1"] = new Product { Id = "p-1" }, + ["p-2"] = new Product { Id = "p-2" } + }; + + public static readonly IReadOnlyDictionary CategoriesById = + new Dictionary(StringComparer.Ordinal) + { + ["pc-1"] = new Category { Id = "pc-1" }, + ["c-2"] = new Category { Id = "c-2" } + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/NodeTwoSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/NodeTwoSubgraph.cs new file mode 100644 index 00000000000..ab8085a49a6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/NodeTwoSubgraph.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// Builds the node-two Apollo Federation subgraph for the +/// node audit suite. Mirrors node minus the root query +/// fields, so the planner has an alternative entity provider for +/// Product and Category. +/// +public static class NodeTwoSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "node-two"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/Node/NodeTwo/NodeType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/NodeType.cs new file mode 100644 index 00000000000..954b0d9316a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/NodeType.cs @@ -0,0 +1,16 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// Descriptor for the Node interface in the node-two +/// subgraph: a single id field shared by all implementers. +/// +public sealed class NodeType : InterfaceType +{ + protected override void Configure(IInterfaceTypeDescriptor descriptor) + { + descriptor.Name("Node"); + descriptor.Field(n => n.Id).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/Product.cs new file mode 100644 index 00000000000..9cff2f22f28 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/Product.cs @@ -0,0 +1,9 @@ +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// The Product projection used by the node-two subgraph. +/// +public sealed class Product : INode +{ + public string Id { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/ProductType.cs new file mode 100644 index 00000000000..6e93559eb80 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/ProductType.cs @@ -0,0 +1,24 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// node-two subgraph (@key(fields: "id")). +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Implements() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).Type>(); + } + + private static Product? ResolveById(string id) + => NodeTwoData.ProductsById.TryGetValue(id, out var p) ? p : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/QueryType.cs new file mode 100644 index 00000000000..d274136e532 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/NodeTwo/QueryType.cs @@ -0,0 +1,18 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.NodeTwo; + +/// +/// Root Query placeholder for the node-two subgraph. The +/// subgraph declares no user-facing query fields. +/// +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/Node/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/data.json new file mode 100644 index 00000000000..f92bb21c805 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/data.json @@ -0,0 +1,10 @@ +{ + "products": [ + { "id": "p-1", "name": "Product 1", "price": 10.0 }, + { "id": "p-2", "name": "Product 2", "price": 20.0 } + ], + "categories": [ + { "id": "pc-1", "name": "Category 1" }, + { "id": "c-2", "name": "Category 2" } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/node-two.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/node-two.graphql new file mode 100644 index 00000000000..bc904d6cd37 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/node-two.graphql @@ -0,0 +1,14 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +interface Node { + id: ID! +} + +type Product implements Node @key(fields: "id") { + id: ID! +} + +type Category implements Node @key(fields: "id") { + id: ID! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/node.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/node.graphql new file mode 100644 index 00000000000..9f1d26017bb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/node.graphql @@ -0,0 +1,19 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Query { + productNode: Node + categoryNode: Node +} + +interface Node { + id: ID! +} + +type Product implements Node @key(fields: "id") { + id: ID! +} + +type Category implements Node @key(fields: "id") { + id: ID! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/tests.json new file mode 100644 index 00000000000..621370ee2e6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/tests.json @@ -0,0 +1,15 @@ +[ + { + "query": "{\n productNode {\n ... on Product {\n id\n name\n __typename\n price\n }\n }\n}\n", + "expected": { + "data": { + "productNode": { + "id": "p-1", + "name": "Product 1", + "__typename": "Product", + "price": 10 + } + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/types.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/types.graphql new file mode 100644 index 00000000000..34dc3789873 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Reference/types.graphql @@ -0,0 +1,20 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +interface Node { + id: ID! +} + +type Product implements Node @key(fields: "id") @shareable { + id: ID! + name: String! + price: Float! +} + +type Category implements Node @key(fields: "id") { + id: ID! + name: String! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/Category.cs new file mode 100644 index 00000000000..5a37e3f6792 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/Category.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// The Category projection used by the types subgraph. +/// Owns name in addition to the federated key. +/// +public sealed class Category : INode +{ + public string Id { get; init; } = default!; + + public string Name { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/CategoryType.cs new file mode 100644 index 00000000000..388ce41e828 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/CategoryType.cs @@ -0,0 +1,25 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// Apollo Federation descriptor for the Category entity in the +/// types subgraph (@key(fields: "id")). Owns name. +/// +public sealed class CategoryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Implements() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(c => c.Id).Type>(); + descriptor.Field(c => c.Name).Type>(); + } + + private static Category? ResolveById(string id) + => TypesData.CategoriesById.TryGetValue(id, out var c) ? c : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/INode.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/INode.cs new file mode 100644 index 00000000000..b21ae68153b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/INode.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// The federated Node interface as projected by the types +/// subgraph. +/// +public interface INode +{ + string Id { get; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/NodeType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/NodeType.cs new file mode 100644 index 00000000000..e935185d14c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/NodeType.cs @@ -0,0 +1,15 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// Descriptor for the Node interface in the types subgraph. +/// +public sealed class NodeType : InterfaceType +{ + protected override void Configure(IInterfaceTypeDescriptor descriptor) + { + descriptor.Name("Node"); + descriptor.Field(n => n.Id).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/Product.cs new file mode 100644 index 00000000000..a26059cdd71 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/Product.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// The Product projection used by the types subgraph. +/// Owns name and price in addition to the federated key. +/// +public sealed class Product : INode +{ + public string Id { get; init; } = default!; + + public string Name { get; init; } = default!; + + public double Price { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/ProductType.cs new file mode 100644 index 00000000000..579a7231e20 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/ProductType.cs @@ -0,0 +1,28 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// types subgraph (@key(fields: "id") @shareable). Owns +/// name and price. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Implements() + .Shareable() + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).Type>(); + descriptor.Field(p => p.Name).Type>(); + descriptor.Field(p => p.Price).Type>(); + } + + private static Product? ResolveById(string id) + => TypesData.ProductsById.TryGetValue(id, out var p) ? p : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/QueryType.cs new file mode 100644 index 00000000000..74fb3da2354 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/QueryType.cs @@ -0,0 +1,18 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// Root Query placeholder for the types subgraph. The +/// subgraph declares no user-facing query fields. +/// +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/Node/Types/TypesData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/TypesData.cs new file mode 100644 index 00000000000..8cb15729274 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/TypesData.cs @@ -0,0 +1,22 @@ +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// Seed data for the types subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/node/data.ts. +/// +internal static class TypesData +{ + public static readonly IReadOnlyDictionary ProductsById = + new Dictionary(StringComparer.Ordinal) + { + ["p-1"] = new Product { Id = "p-1", Name = "Product 1", Price = 10.0 }, + ["p-2"] = new Product { Id = "p-2", Name = "Product 2", Price = 20.0 } + }; + + public static readonly IReadOnlyDictionary CategoriesById = + new Dictionary(StringComparer.Ordinal) + { + ["pc-1"] = new Category { Id = "pc-1", Name = "Category 1" }, + ["c-2"] = new Category { Id = "c-2", Name = "Category 2" } + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/TypesSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/TypesSubgraph.cs new file mode 100644 index 00000000000..efe56874694 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Node/Types/TypesSubgraph.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.Node.Types; + +/// +/// Builds the types Apollo Federation subgraph for the node +/// audit suite. Owns the field-rich projections of Product and +/// Category. +/// +public static class TypesSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "types"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/NullKeys/A/AData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/AData.cs new file mode 100644 index 00000000000..2947e3b762c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/AData.cs @@ -0,0 +1,18 @@ +namespace HotChocolate.Fusion.Suites.NullKeys.A; + +/// +/// Seed data for the a subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/null-keys/data.ts. +/// +internal static class AData +{ + public static readonly IReadOnlyList Books = + [ + new Book { Upc = "b1" }, + new Book { Upc = "b2" }, + new Book { Upc = "b3" } + ]; + + public static readonly IReadOnlyDictionary ByUpc = + Books.ToDictionary(static b => b.Upc, StringComparer.Ordinal); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/ASubgraph.cs new file mode 100644 index 00000000000..b89cd5ecc0b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/ASubgraph.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.NullKeys.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// null-keys audit suite. Owns Query.bookContainers and the +/// minimal Book @key("upc") projection. +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/NullKeys/A/Book.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/Book.cs new file mode 100644 index 00000000000..cbc1f297308 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/Book.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Suites.NullKeys.A; + +/// +/// The Book entity as projected by the a subgraph +/// (@key(fields: "upc")). +/// +public sealed class Book +{ + public string Upc { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookContainer.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookContainer.cs new file mode 100644 index 00000000000..c7c0d533ac1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookContainer.cs @@ -0,0 +1,9 @@ +namespace HotChocolate.Fusion.Suites.NullKeys.A; + +/// +/// The BookContainer wrapper exposed by the a subgraph. +/// +public sealed class BookContainer +{ + public Book? Book { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookContainerType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookContainerType.cs new file mode 100644 index 00000000000..245e275043a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookContainerType.cs @@ -0,0 +1,14 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.NullKeys.A; + +/// +/// Descriptor for the BookContainer wrapper. +/// +public sealed class BookContainerType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(c => c.Book).Type(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookType.cs new file mode 100644 index 00000000000..091e59f66f0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/BookType.cs @@ -0,0 +1,23 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.NullKeys.A; + +/// +/// Apollo Federation descriptor for the Book entity in the +/// a subgraph (@key(fields: "upc")). +/// +public sealed class BookType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("upc") + .ResolveReferenceWith(_ => ResolveByUpc(default!)); + + descriptor.Field(b => b.Upc).Type>(); + } + + private static Book? ResolveByUpc(string upc) + => AData.ByUpc.TryGetValue(upc, out var b) ? b : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/QueryType.cs new file mode 100644 index 00000000000..3796ee01b71 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/A/QueryType.cs @@ -0,0 +1,19 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.NullKeys.A; + +/// +/// Root Query for the a subgraph. Exposes +/// bookContainers: [BookContainer]. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + descriptor + .Field("bookContainers") + .Type>() + .Resolve(_ => AData.Books.Select(b => new BookContainer { Book = b }).ToArray()); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BData.cs new file mode 100644 index 00000000000..c2aaaeca680 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BData.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.NullKeys.B; + +/// +/// Seed data for the b subgraph. +/// +internal static class BData +{ + public static readonly IReadOnlyList Books = + [ + new Book { Id = "1", Upc = "b1" }, + new Book { Id = "2", Upc = "b2" }, + new Book { Id = "3", Upc = "b3" } + ]; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BSubgraph.cs new file mode 100644 index 00000000000..92a7765db26 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BSubgraph.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.NullKeys.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// null-keys audit suite. Provides the upc <-> id +/// bridge for the Book entity. +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/NullKeys/B/Book.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/Book.cs new file mode 100644 index 00000000000..b0fed4e1e0a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/Book.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.NullKeys.B; + +/// +/// The Book entity as projected by the b subgraph +/// (@key(fields: "id") and @key(fields: "upc")). +/// +public sealed class Book +{ + public string Id { get; init; } = default!; + + public string Upc { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BookType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BookType.cs new file mode 100644 index 00000000000..7d9a9f54289 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/BookType.cs @@ -0,0 +1,55 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.NullKeys.B; + +/// +/// Apollo Federation descriptor for the Book entity in the +/// b subgraph (two keys: id and upc). The reference +/// resolver mirrors the audit's null-handling: it returns +/// when the looked-up book has id 3, simulating a partial entity +/// store where one row is unavailable. +/// +public sealed class BookType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor + .Key("upc") + .ResolveReferenceWith(_ => ResolveByUpc(default!)); + + descriptor.Field(b => b.Id).Type>(); + descriptor.Field(b => b.Upc).Type>(); + } + + private static Book? ResolveById(string id) + { + var book = BData.Books.FirstOrDefault( + b => string.Equals(b.Id, id, StringComparison.Ordinal)); + return ApplyNullPolicy(book); + } + + private static Book? ResolveByUpc(string upc) + { + var book = BData.Books.FirstOrDefault( + b => string.Equals(b.Upc, upc, StringComparison.Ordinal)); + return ApplyNullPolicy(book); + } + + private static Book? ApplyNullPolicy(Book? book) + { + if (book is null) + { + return null; + } + + // Mirror the audit fixture: subgraph 'b' deliberately returns null + // for the book with id "3" so the planner has to short-circuit the + // downstream entity call to subgraph 'c'. + return string.Equals(book.Id, "3", StringComparison.Ordinal) ? null : book; + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/QueryType.cs new file mode 100644 index 00000000000..d762ee4daa0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/B/QueryType.cs @@ -0,0 +1,18 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.NullKeys.B; + +/// +/// Root Query placeholder for the b subgraph. The subgraph +/// declares no user-facing query fields. +/// +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/NullKeys/C/Author.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/Author.cs new file mode 100644 index 00000000000..b02f5cac70f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/Author.cs @@ -0,0 +1,11 @@ +namespace HotChocolate.Fusion.Suites.NullKeys.C; + +/// +/// The Author value type owned by the c subgraph. +/// +public sealed class Author +{ + public string Id { get; init; } = default!; + + public string? Name { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/AuthorType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/AuthorType.cs new file mode 100644 index 00000000000..3a65b12196b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/AuthorType.cs @@ -0,0 +1,15 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.NullKeys.C; + +/// +/// Descriptor for the Author value type. +/// +public sealed class AuthorType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(a => a.Id).Type>(); + descriptor.Field(a => a.Name).Type(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/Book.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/Book.cs new file mode 100644 index 00000000000..dfeff2856a3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/Book.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.NullKeys.C; + +/// +/// The Book entity as projected by the c subgraph +/// (@key(fields: "id")). Owns the author link. +/// +public sealed class Book +{ + public string Id { get; init; } = default!; + + public Author? Author { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/BookType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/BookType.cs new file mode 100644 index 00000000000..5c4845812e9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/BookType.cs @@ -0,0 +1,25 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.NullKeys.C; + +/// +/// Apollo Federation descriptor for the Book entity in the +/// c subgraph (@key(fields: "id")). Owns the author +/// link. +/// +public sealed class BookType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(b => b.Id).Type>(); + descriptor.Field(b => b.Author).Type(); + } + + private static Book? ResolveById(string id) + => CData.ById.TryGetValue(id, out var b) ? b : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/CData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/CData.cs new file mode 100644 index 00000000000..cb1f40e1d02 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/CData.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Suites.NullKeys.C; + +/// +/// Seed data for the c subgraph. +/// +internal static class CData +{ + public static readonly IReadOnlyList Books = + [ + new Book { Id = "1", Author = new Author { Id = "a1", Name = "Alice" } }, + new Book { Id = "2", Author = new Author { Id = "a2", Name = "Bob" } }, + new Book { Id = "3", Author = new Author { Id = "a3", Name = "Jack" } } + ]; + + public static readonly IReadOnlyDictionary ById = + Books.ToDictionary(static b => b.Id, StringComparer.Ordinal); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/CSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/CSubgraph.cs new file mode 100644 index 00000000000..2e2bff4c2a9 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/CSubgraph.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.NullKeys.C; + +/// +/// Builds the c Apollo Federation subgraph for the +/// null-keys audit suite. Owns Book.author. +/// +public static class CSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "c"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/NullKeys/C/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/QueryType.cs new file mode 100644 index 00000000000..69cebf97d96 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/C/QueryType.cs @@ -0,0 +1,18 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.NullKeys.C; + +/// +/// Root Query placeholder for the c subgraph. The subgraph +/// declares no user-facing query fields. +/// +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/NullKeys/NullKeysTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/NullKeysTests.cs index 74514cc4bad..7146890e0bc 100644 --- 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 @@ -1,10 +1,53 @@ +using HotChocolate.Fusion.Suites.NullKeys.A; +using HotChocolate.Fusion.Suites.NullKeys.B; +using HotChocolate.Fusion.Suites.NullKeys.C; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the null-keys suite from +/// graphql-hive/federation-gateway-audit. Three Apollo Federation +/// subgraphs share the Book entity. Subgraph a exposes +/// bookContainers and only the upc key; b bridges +/// between upc and id; c owns author via the +/// id key. The third book triggers b's reference resolver +/// to return null, which must propagate as author: null +/// without aborting the parent list. +/// public sealed class NullKeysTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync), + (CSubgraph.Name, CSubgraph.BuildAsync)); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// Walks the three-subgraph chain and verifies that the null entity + /// returned for the third book leaves the parent list intact, with the + /// downstream author field set to null. + /// + [Fact] + public Task BookContainers_Resolves_Null_Author_When_Bridge_Subgraph_Returns_Null() => RunAsync( + query: """ + query { + bookContainers { + book { + upc + author { + name + } + } + } + } + """, + expectedData: """ + { + "bookContainers": [ + { "book": { "upc": "b1", "author": { "name": "Alice" } } }, + { "book": { "upc": "b2", "author": { "name": "Bob" } } }, + { "book": { "upc": "b3", "author": null } } + ] + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/a.graphql new file mode 100644 index 00000000000..9c0f858980a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/a.graphql @@ -0,0 +1,14 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Query { + bookContainers: [BookContainer] +} + +type BookContainer { + book: Book +} + +type Book @key(fields: "upc") { + upc: ID! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/b.graphql new file mode 100644 index 00000000000..184fe5ec6e3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/b.graphql @@ -0,0 +1,7 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Book @key(fields: "id") @key(fields: "upc") { + id: ID! + upc: ID! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/c.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/c.graphql new file mode 100644 index 00000000000..ad3c45d9e20 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/c.graphql @@ -0,0 +1,12 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Book @key(fields: "id") { + id: ID! + author: Author +} + +type Author { + id: ID! + name: String +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/data.json new file mode 100644 index 00000000000..ab583d488a1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/data.json @@ -0,0 +1,7 @@ +{ + "books": [ + { "id": "1", "upc": "b1", "author": { "id": "a1", "name": "Alice" } }, + { "id": "2", "upc": "b2", "author": { "id": "a2", "name": "Bob" } }, + { "id": "3", "upc": "b3", "author": { "id": "a3", "name": "Jack" } } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/tests.json new file mode 100644 index 00000000000..ad59dac02d1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/NullKeys/Reference/tests.json @@ -0,0 +1,14 @@ +[ + { + "query": "query {\n bookContainers {\n book {\n upc\n author { name }\n }\n }\n}\n", + "expected": { + "data": { + "bookContainers": [ + { "book": { "upc": "b1", "author": { "name": "Alice" } } }, + { "book": { "upc": "b2", "author": { "name": "Bob" } } }, + { "book": { "upc": "b3", "author": null } } + ] + } + } + } +] 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 index e233415a9ab..ae455c0daa2 100644 --- 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 @@ -5,6 +5,6 @@ public sealed class ParentEntityCallTests : ComplianceTestBase protected override Task BuildGatewayAsync() => throw new NotImplementedException("Subgraphs not yet wired for this suite."); - [Fact(Skip = "Pending: subgraph harness.")] + [Fact(Skip = "Composer satisfiability validator cycles on Category.id between subgraphs that both declare Category @key(\"id\") without a non-lookup path. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] public Task Pending() => Task.CompletedTask; } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/a.graphql new file mode 100644 index 00000000000..c82b61b4c03 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/a.graphql @@ -0,0 +1,20 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@shareable"] + ) + +type Query { + products: [Product!]! +} + +type Product @key(fields: "id") @key(fields: "id pid") { + id: ID! + pid: ID! + category: Category @shareable +} + +type Category @key(fields: "id") { + id: ID! + name: String! @shareable +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/b.graphql new file mode 100644 index 00000000000..cfd6d8362cb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/b.graphql @@ -0,0 +1,16 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Product @key(fields: "id pid") { + id: ID! + pid: ID! + category: Category @shareable +} + +type Category @key(fields: "id") { + id: ID! + name: String! @shareable +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/c.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/c.graphql new file mode 100644 index 00000000000..2b1410a1dc6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/c.graphql @@ -0,0 +1,19 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Category { + details: CategoryDetails +} + +type Product @key(fields: "id pid") { + id: ID! + pid: ID! + category: Category @shareable +} + +type CategoryDetails { + products: Int +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/data.json new file mode 100644 index 00000000000..fc7df31ef12 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/data.json @@ -0,0 +1,11 @@ +{ + "products": [ + { "id": "p1", "pid": "p1-pid", "categoryId": "c1" }, + { "id": "p2", "pid": "p2-pid", "categoryId": "c2" }, + { "id": "p3", "pid": "p3-pid", "categoryId": "c1" } + ], + "categories": [ + { "id": "c1", "name": "c1-name", "details": { "products": 2 } }, + { "id": "c2", "name": "c2-name", "details": { "products": 1 } } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/tests.json new file mode 100644 index 00000000000..190630c531b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCall/Reference/tests.json @@ -0,0 +1,32 @@ +[ + { + "query": "query {\n products {\n id\n category {\n id\n details {\n products\n }\n }\n }\n}\n", + "expected": { + "data": { + "products": [ + { + "id": "p1", + "category": { + "id": "c1", + "details": { "products": 2 } + } + }, + { + "id": "p2", + "category": { + "id": "c2", + "details": { "products": 1 } + } + }, + { + "id": "p3", + "category": { + "id": "c1", + "details": { "products": 2 } + } + } + ] + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/ASubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/ASubgraph.cs new file mode 100644 index 00000000000..0b67b9889c3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/ASubgraph.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.A; + +/// +/// Builds the a Apollo Federation subgraph for the +/// parent-entity-call-complex audit suite. Owns the shareable +/// Product.category field and produces the Category.details +/// payload from the __resolveReference entity call. +/// +public static class ASubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "a"; + + /// + /// 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/ParentEntityCallComplex/A/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/Category.cs new file mode 100644 index 00000000000..81642352d54 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/Category.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.A; + +/// +/// The Category value type as projected by the a subgraph +/// (type Category { details: String }). Not an entity in this +/// subgraph: it has no @key and is only ever produced inline by +/// the parent Product.category field. +/// +public sealed class Category +{ + public string? Details { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/CategoryType.cs new file mode 100644 index 00000000000..327d9a5a937 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/CategoryType.cs @@ -0,0 +1,17 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.A; + +/// +/// Apollo Federation descriptor for the Category value type owned by +/// the a subgraph. Mirrors the audit Schema Definition Language (SDL): +/// type Category { details: String }. No @key; the type only +/// flows out via the parent Product.category field. +/// +public sealed class CategoryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(c => c.Details).Type(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/Product.cs new file mode 100644 index 00000000000..18a7492b5f6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/Product.cs @@ -0,0 +1,16 @@ +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.A; + +/// +/// The Product entity as projected by the a subgraph +/// (type Product @key(fields: "id") { id: ID @external, category: Category @shareable }). +/// Subgraph a only owns the shareable category field; +/// id is external. The reference resolver builds the category +/// payload using the product id, so the value flows out of the +/// __resolveReference entity call. +/// +public sealed class Product +{ + public string Id { get; init; } = default!; + + public Category? Category { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/ProductType.cs new file mode 100644 index 00000000000..50002714107 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/ProductType.cs @@ -0,0 +1,31 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.A; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// a subgraph. Mirrors the audit Schema Definition Language (SDL): +/// type Product @key(fields: "id") { id: ID @external, category: Category @shareable }. +/// The __resolveReference path produces a +/// inline whose details string includes the requesting product id. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).External().Type(); + descriptor.Field(p => p.Category).Shareable().Type(); + } + + private static Product ResolveById(string id) + => new() + { + Id = id, + Category = new Category { Details = $"Details for Product#{id}" } + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/QueryType.cs new file mode 100644 index 00000000000..00533028c86 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/A/QueryType.cs @@ -0,0 +1,21 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.A; + +/// +/// Root Query placeholder for the a 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/ParentEntityCallComplex/B/BSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/BSubgraph.cs new file mode 100644 index 00000000000..debd1efd17b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/BSubgraph.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.B; + +/// +/// Builds the b Apollo Federation subgraph for the +/// parent-entity-call-complex audit suite. Owns the shareable +/// Product.category field and produces a Category with a +/// shareable id that downstream subgraphs use as the entity key. +/// +public static class BSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "b"; + + /// + /// 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/ParentEntityCallComplex/B/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/Category.cs new file mode 100644 index 00000000000..eecee95bcd1 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/Category.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.B; + +/// +/// The Category value type as projected by the b subgraph +/// (type Category { id: ID @shareable }). Not an entity in this +/// subgraph: it has no @key here and is only ever produced inline +/// by the parent Product.category field. The shareable id +/// flows through the supergraph and acts as the entity key for the +/// c subgraph's Category @key("id") lookup. +/// +public sealed class Category +{ + public string? Id { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/CategoryType.cs new file mode 100644 index 00000000000..d0c37fec482 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/CategoryType.cs @@ -0,0 +1,19 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.B; + +/// +/// Apollo Federation descriptor for the Category value type owned by +/// the b subgraph. Mirrors the audit Schema Definition Language (SDL): +/// type Category { id: ID @shareable }. No @key; the type +/// only flows out via the parent Product.category field, but +/// id is shareable so other subgraphs can produce the same value. +/// +public sealed class CategoryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(c => c.Id).Shareable().Type(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/Product.cs new file mode 100644 index 00000000000..00cb25a7062 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/Product.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.B; + +/// +/// The Product entity as projected by the b subgraph +/// (type Product @key(fields: "id") { id: ID @external, category: Category @shareable }). +/// Subgraph b only owns the shareable category field; +/// id is external. The reference resolver always pins the inline +/// Category.id to "3" (matching the audit fixture), which +/// then becomes the @key the gateway uses to fetch +/// Category.name from subgraph c. +/// +public sealed class Product +{ + public string Id { get; init; } = default!; + + public Category? Category { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/ProductType.cs new file mode 100644 index 00000000000..c81da6a2b5f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/ProductType.cs @@ -0,0 +1,33 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.B; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// b subgraph. Mirrors the audit Schema Definition Language (SDL): +/// type Product @key(fields: "id") { id: ID @external, category: Category @shareable }. +/// The __resolveReference path returns a +/// inline whose id is hard-pinned to "3" (matching the +/// audit fixture). Downstream subgraphs that own Category resolve +/// further fields from that key. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).External().Type(); + descriptor.Field(p => p.Category).Shareable().Type(); + } + + private static Product ResolveById(string id) + => new() + { + Id = id, + Category = new Category { Id = "3" } + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/QueryType.cs new file mode 100644 index 00000000000..34b3871e19c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/B/QueryType.cs @@ -0,0 +1,21 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.B; + +/// +/// Root Query placeholder for the b 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/ParentEntityCallComplex/C/CSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/CSubgraph.cs new file mode 100644 index 00000000000..ebe3fa9a076 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/CSubgraph.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.C; + +/// +/// Builds the c Apollo Federation subgraph for the +/// parent-entity-call-complex audit suite. Owns the +/// Category @key("id") entity and projects Category.name +/// from any incoming key via the __resolveReference entity call. +/// +public static class CSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "c"; + + /// + /// 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/ParentEntityCallComplex/C/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/Category.cs new file mode 100644 index 00000000000..c3b3709b7ce --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/Category.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.C; + +/// +/// The Category entity as projected by the c subgraph +/// (type Category @key(fields: "id") { id: ID, name: String }). +/// Owns the name field; the __resolveReference resolver +/// builds Category#{id} for any requested id. +/// +public sealed class Category +{ + public string Id { get; init; } = default!; + + public string? Name { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/CategoryType.cs new file mode 100644 index 00000000000..a71be91f90a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/CategoryType.cs @@ -0,0 +1,31 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.C; + +/// +/// Apollo Federation descriptor for the Category entity owned by the +/// c subgraph. Mirrors the audit Schema Definition Language (SDL): +/// type Category @key(fields: "id") { id: ID, name: String }. +/// The reference resolver synthesizes Category#{id} for any +/// requested 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.Name).Type(); + } + + private static Category ResolveById(string id) + => new() + { + Id = id, + Name = $"Category#{id}" + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/QueryType.cs new file mode 100644 index 00000000000..fbd4d66779b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/C/QueryType.cs @@ -0,0 +1,21 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.C; + +/// +/// Root Query placeholder for the c 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/ParentEntityCallComplex/D/DSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/DSubgraph.cs new file mode 100644 index 00000000000..84f026f29ce --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/DSubgraph.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.D; + +/// +/// Builds the d Apollo Federation subgraph for the +/// parent-entity-call-complex audit suite. Exposes the +/// Query.productFromD(id: ID!) root field and owns the +/// Product @key("id") entity (the name field). +/// +public static class DSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "d"; + + /// + /// 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/ParentEntityCallComplex/D/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/Product.cs new file mode 100644 index 00000000000..57140f05b4f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/Product.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.D; + +/// +/// The Product entity as projected by the d subgraph +/// (type Product @key(fields: "id") { id: ID, name: String }). +/// Owns the name field; the __resolveReference resolver +/// builds Product#{id} for any requested id. +/// +public sealed class Product +{ + public string Id { get; init; } = default!; + + public string? Name { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/ProductType.cs new file mode 100644 index 00000000000..a72fce08954 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/ProductType.cs @@ -0,0 +1,31 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.D; + +/// +/// Apollo Federation descriptor for the Product entity owned by the +/// d subgraph. Mirrors the audit Schema Definition Language (SDL): +/// type Product @key(fields: "id") { id: ID, name: String }. +/// The reference resolver synthesizes Product#{id} for any +/// requested id. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(p => p.Id).Type(); + descriptor.Field(p => p.Name).Type(); + } + + private static Product ResolveById(string id) + => new() + { + Id = id, + Name = $"Product#{id}" + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/QueryType.cs new file mode 100644 index 00000000000..88f1584dc1d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/D/QueryType.cs @@ -0,0 +1,26 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.ParentEntityCallComplex.D; + +/// +/// Root Query type for the d subgraph. Exposes +/// productFromD(id: ID!): Product; the resolver synthesizes a +/// for the supplied id. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("productFromD") + .Argument("id", a => a.Type>()) + .Type() + .Resolve(ctx => + { + var id = ctx.ArgumentValue("id"); + return new Product { Id = id, Name = $"Product#{id}" }; + }); + } +} 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 index c6ae438f4fa..46332cb6830 100644 --- 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 @@ -1,10 +1,65 @@ +using HotChocolate.Fusion.Suites.ParentEntityCallComplex.A; +using HotChocolate.Fusion.Suites.ParentEntityCallComplex.B; +using HotChocolate.Fusion.Suites.ParentEntityCallComplex.C; +using HotChocolate.Fusion.Suites.ParentEntityCallComplex.D; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the parent-entity-call-complex suite from +/// graphql-hive/federation-gateway-audit. Four Apollo Federation +/// subgraphs cooperate on a single Product request: subgraph +/// d owns the root productFromD(id) and Product.name; +/// subgraphs a and b both contribute the shareable +/// Product.category field by inlining a Category value type +/// (a sets details, b sets the shareable id); +/// subgraph c owns the Category @key("id") entity and +/// projects Category.name via the __resolveReference entity +/// call. The supergraph composes category { id name details } by +/// merging the inline contributions from a and b with the +/// downstream entity call into c. +/// public sealed class ParentEntityCallComplexTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (ASubgraph.Name, ASubgraph.BuildAsync), + (BSubgraph.Name, BSubgraph.BuildAsync), + (CSubgraph.Name, CSubgraph.BuildAsync), + (DSubgraph.Name, DSubgraph.BuildAsync)); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// The single audit case: requests Product.category { id name details } + /// from the d root. id comes from b, name from + /// the c entity lookup keyed on that id, and details from + /// the parent Product entity call into a. + /// + [Fact] + public Task ProductFromD_Composes_Category_Fields_Across_Four_Subgraphs() => RunAsync( + query: """ + query { + productFromD(id: "1") { + id + name + category { + id + name + details + } + } + } + """, + expectedData: """ + { + "productFromD": { + "id": "1", + "name": "Product#1", + "category": { + "id": "3", + "name": "Category#3", + "details": "Details for Product#1" + } + } + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/a.graphql new file mode 100644 index 00000000000..383885ce065 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/a.graphql @@ -0,0 +1,13 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@shareable"] + ) + +type Product @key(fields: "id") { + id: ID @external + category: Category @shareable +} +type Category { + details: String +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/b.graphql new file mode 100644 index 00000000000..f3c8093d687 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/b.graphql @@ -0,0 +1,14 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@shareable"] + ) + +type Product @key(fields: "id") { + id: ID @external + category: Category @shareable +} + +type Category { + id: ID @shareable +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/c.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/c.graphql new file mode 100644 index 00000000000..bd3a84338dc --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/c.graphql @@ -0,0 +1,6 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) +type Category @key(fields: "id") { + id: ID + name: String +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/d.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/d.graphql new file mode 100644 index 00000000000..685b4c3a5da --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/subgraphs/d.graphql @@ -0,0 +1,11 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Query { + productFromD(id: ID!): Product +} + +type Product @key(fields: "id") { + id: ID + name: String +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/tests.json new file mode 100644 index 00000000000..5bc7dcf1dfd --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/ParentEntityCallComplex/Reference/tests.json @@ -0,0 +1,18 @@ +[ + { + "query": "query { productFromD(id: \"1\") { id name category { id name details } } }", + "expected": { + "data": { + "productFromD": { + "id": "1", + "name": "Product#1", + "category": { + "id": "3", + "name": "Category#3", + "details": "Details for Product#1" + } + } + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/Category.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/Category.cs new file mode 100644 index 00000000000..828cc18e0c7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/Category.cs @@ -0,0 +1,11 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Category; + +/// +/// The Category value type owned by the category subgraph. +/// +public sealed class Category +{ + public string Id { get; init; } = default!; + + public string Name { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/CategoryData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/CategoryData.cs new file mode 100644 index 00000000000..490d80ae8b7 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/CategoryData.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Category; + +/// +/// Seed data for the category subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/shared-root/data.ts. +/// +internal static class CategoryData +{ + public static readonly Product Product = new() + { + Id = "1", + Category = new Category { Id = "1", Name = "Category 1" } + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/CategorySubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/CategorySubgraph.cs new file mode 100644 index 00000000000..2156a52c10d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/CategorySubgraph.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Category; + +/// +/// Builds the category Apollo Federation subgraph for the +/// shared-root audit suite. Owns Category and contributes the +/// Product.category field via the shareable shared-root pattern. +/// +public static class CategorySubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "category"; + + /// + /// 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/SharedRoot/Category/CategoryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/CategoryType.cs new file mode 100644 index 00000000000..b46ff5fa0e0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/CategoryType.cs @@ -0,0 +1,16 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Category; + +/// +/// Descriptor for the Category value type owned by the category +/// subgraph. +/// +public sealed class CategoryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(c => c.Id).Type>(); + descriptor.Field(c => c.Name).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/Product.cs new file mode 100644 index 00000000000..e8b528fb60d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/Product.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Category; + +/// +/// The Product projection owned by the category subgraph. +/// Carries the shared id and the category link only. +/// +public sealed class Product +{ + public string Id { get; init; } = default!; + + public Category Category { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/ProductType.cs new file mode 100644 index 00000000000..32d955eb80b --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/ProductType.cs @@ -0,0 +1,20 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Category; + +/// +/// Descriptor for the Product contribution from the category +/// subgraph. The audit SDL declares no @key; the type is shared across +/// subgraphs through the shareable root Query.product / +/// Query.products only. Field id is shareable so any subgraph +/// can produce it. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(p => p.Id).Shareable().Type>(); + descriptor.Field(p => p.Category).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/QueryType.cs new file mode 100644 index 00000000000..a6943c687d0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Category/QueryType.cs @@ -0,0 +1,29 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Category; + +/// +/// Root Query for the category subgraph. Exposes +/// product: Product! and products: [Product!]!, both +/// shareable, returning the seeded category-only projection. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("product") + .Type>() + .Shareable() + .Resolve(_ => CategoryData.Product); + + descriptor + .Field("products") + .Type>>>() + .Shareable() + .Resolve(_ => new[] { CategoryData.Product }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/Name.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/Name.cs new file mode 100644 index 00000000000..7b0b32ba7cb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/Name.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Name; + +/// +/// The Name value type owned by the name subgraph. +/// +public sealed class Name +{ + public string Id { get; init; } = default!; + + public string Brand { get; init; } = default!; + + public string Model { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/NameData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/NameData.cs new file mode 100644 index 00000000000..f850bdbd179 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/NameData.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Name; + +/// +/// Seed data for the name subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/shared-root/data.ts. +/// +internal static class NameData +{ + public static readonly Product Product = new() + { + Id = "1", + Name = new Name { Id = "1", Brand = "Brand 1", Model = "Model 1" } + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/NameSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/NameSubgraph.cs new file mode 100644 index 00000000000..a7533981c6c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/NameSubgraph.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Name; + +/// +/// Builds the name Apollo Federation subgraph for the +/// shared-root audit suite. Owns Name and contributes the +/// Product.name field via the shareable shared-root pattern. +/// +public static class NameSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "name"; + + /// + /// 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/SharedRoot/Name/NameType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/NameType.cs new file mode 100644 index 00000000000..0ddd2d1a49f --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/NameType.cs @@ -0,0 +1,17 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Name; + +/// +/// Descriptor for the Name value type owned by the name +/// subgraph. +/// +public sealed class NameType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(n => n.Id).Type>(); + descriptor.Field(n => n.Brand).Type>(); + descriptor.Field(n => n.Model).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/Product.cs new file mode 100644 index 00000000000..97c005b7141 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/Product.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Name; + +/// +/// The Product projection owned by the name subgraph. +/// Carries the shared id and the name link only. +/// +public sealed class Product +{ + public string Id { get; init; } = default!; + + public Name Name { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/ProductType.cs new file mode 100644 index 00000000000..07663af9d06 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/ProductType.cs @@ -0,0 +1,17 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Name; + +/// +/// Descriptor for the Product contribution from the name +/// subgraph. Field id is shareable so any subgraph can produce it. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(p => p.Id).Shareable().Type>(); + descriptor.Field(p => p.Name).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/QueryType.cs new file mode 100644 index 00000000000..0fe2ad7ff8e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Name/QueryType.cs @@ -0,0 +1,29 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Name; + +/// +/// Root Query for the name subgraph. Exposes +/// product: Product! and products: [Product!]!, both +/// shareable, returning the seeded name-only projection. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("product") + .Type>() + .Shareable() + .Resolve(_ => NameData.Product); + + descriptor + .Field("products") + .Type>>>() + .Shareable() + .Resolve(_ => new[] { NameData.Product }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/Price.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/Price.cs new file mode 100644 index 00000000000..2eba6aa594a --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/Price.cs @@ -0,0 +1,13 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Price; + +/// +/// The Price value type owned by the price subgraph. +/// +public sealed class Price +{ + public string Id { get; init; } = default!; + + public int Amount { get; init; } + + public string Currency { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/PriceData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/PriceData.cs new file mode 100644 index 00000000000..ebb3fafa7c4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/PriceData.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Price; + +/// +/// Seed data for the price subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/shared-root/data.ts. +/// +internal static class PriceData +{ + public static readonly Product Product = new() + { + Id = "1", + Price = new Price { Id = "1", Amount = 1000, Currency = "USD" } + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/PriceSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/PriceSubgraph.cs new file mode 100644 index 00000000000..623f1e74ced --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/PriceSubgraph.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Price; + +/// +/// Builds the price Apollo Federation subgraph for the +/// shared-root audit suite. Owns Price and contributes the +/// Product.price field via the shareable shared-root pattern. +/// +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(); + + 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/SharedRoot/Price/PriceType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/PriceType.cs new file mode 100644 index 00000000000..0beb9108cf4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/PriceType.cs @@ -0,0 +1,17 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Price; + +/// +/// Descriptor for the Price value type owned by the price +/// subgraph. +/// +public sealed class PriceType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(p => p.Id).Type>(); + descriptor.Field(p => p.Amount).Type>(); + descriptor.Field(p => p.Currency).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/Product.cs new file mode 100644 index 00000000000..a0af515526c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/Product.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.SharedRoot.Price; + +/// +/// The Product projection owned by the price subgraph. +/// Carries the shared id and the price link only. +/// +public sealed class Product +{ + public string Id { get; init; } = default!; + + public Price Price { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/ProductType.cs new file mode 100644 index 00000000000..d5112522911 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/ProductType.cs @@ -0,0 +1,17 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Price; + +/// +/// Descriptor for the Product contribution from the price +/// subgraph. Field id is shareable so any subgraph can produce it. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(p => p.Id).Shareable().Type>(); + descriptor.Field(p => p.Price).Type>(); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/QueryType.cs new file mode 100644 index 00000000000..13321cf2a3d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Price/QueryType.cs @@ -0,0 +1,29 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SharedRoot.Price; + +/// +/// Root Query for the price subgraph. Exposes +/// product: Product! and products: [Product!]!, both +/// shareable, returning the seeded price-only projection. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("product") + .Type>() + .Shareable() + .Resolve(_ => PriceData.Product); + + descriptor + .Field("products") + .Type>>>() + .Shareable() + .Resolve(_ => new[] { PriceData.Product }); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/category.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/category.graphql new file mode 100644 index 00000000000..e3c5ee43dc3 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/category.graphql @@ -0,0 +1,20 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Query { + product: Product! @shareable + products: [Product!]! @shareable +} + +type Product { + id: ID! @shareable + category: Category! +} + +type Category { + id: ID! + name: String! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/data.json new file mode 100644 index 00000000000..5c2ea679849 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/data.json @@ -0,0 +1,8 @@ +{ + "product": { + "id": "1", + "name": { "id": "1", "brand": "Brand 1", "model": "Model 1" }, + "category": { "id": "1", "name": "Category 1" }, + "price": { "id": "1", "amount": 1000, "currency": "USD" } + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/name.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/name.graphql new file mode 100644 index 00000000000..aaf25f908a6 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/name.graphql @@ -0,0 +1,21 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Query { + product: Product! @shareable + products: [Product!]! @shareable +} + +type Product { + id: ID! @shareable + name: Name! +} + +type Name { + id: ID! + brand: String! + model: String! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/price.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/price.graphql new file mode 100644 index 00000000000..149e2ea95cb --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/price.graphql @@ -0,0 +1,21 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Query { + product: Product! @shareable + products: [Product!]! @shareable +} + +type Product { + id: ID! @shareable + price: Price! +} + +type Price { + id: ID! + amount: Int! + currency: String! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/tests.json new file mode 100644 index 00000000000..49d34a8751d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SharedRoot/Reference/tests.json @@ -0,0 +1,30 @@ +[ + { + "query": "query {\n product {\n id\n name { id brand model }\n category { id name }\n price { id amount currency }\n }\n}\n", + "expected": { + "data": { + "product": { + "id": "1", + "name": { "id": "1", "brand": "Brand 1", "model": "Model 1" }, + "price": { "id": "1", "amount": 1000, "currency": "USD" }, + "category": { "id": "1", "name": "Category 1" } + } + } + } + }, + { + "query": "query {\n products {\n id\n name { id brand model }\n category { id name }\n price { id amount currency }\n }\n}\n", + "expected": { + "data": { + "products": [ + { + "id": "1", + "name": { "id": "1", "brand": "Brand 1", "model": "Model 1" }, + "price": { "id": "1", "amount": 1000, "currency": "USD" }, + "category": { "id": "1", "name": "Category 1" } + } + ] + } + } + } +] 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 index 3a0f78f109a..3badd04ca5f 100644 --- 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 @@ -1,10 +1,79 @@ +using HotChocolate.Fusion.Suites.SharedRoot.Category; +using HotChocolate.Fusion.Suites.SharedRoot.Name; +using HotChocolate.Fusion.Suites.SharedRoot.Price; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the shared-root suite from +/// graphql-hive/federation-gateway-audit. Three Apollo Federation +/// subgraphs (category, name, price) all expose the +/// same shareable Query.product and Query.products root fields +/// returning a non-keyed Product. Each subgraph contributes a +/// different field on Product; the planner must split a single query +/// across all three subgraphs. +/// public sealed class SharedRootTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (CategorySubgraph.Name, CategorySubgraph.BuildAsync), + (NameSubgraph.Name, NameSubgraph.BuildAsync), + (PriceSubgraph.Name, PriceSubgraph.BuildAsync)); + + /// + /// Single-product query; all three subgraphs contribute fields via the + /// shared root. + /// + [Fact] + public Task Product_Composes_Fields_From_Three_Subgraphs() => RunAsync( + query: """ + query { + product { + id + name { id brand model } + category { id name } + price { id amount currency } + } + } + """, + expectedData: """ + { + "product": { + "id": "1", + "name": { "id": "1", "brand": "Brand 1", "model": "Model 1" }, + "price": { "id": "1", "amount": 1000, "currency": "USD" }, + "category": { "id": "1", "name": "Category 1" } + } + } + """); - [Fact(Skip = "Pending: subgraph harness.")] - public Task Pending() => Task.CompletedTask; + /// + /// List form of the same composition; verifies the planner stitches + /// list elements across subgraphs without an entity lookup. + /// + [Fact(Skip = "Planner does not zip parallel shareable list root queries across subgraphs without an entity lookup. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Products_Composes_Fields_From_Three_Subgraphs() => RunAsync( + query: """ + query { + products { + id + name { id brand model } + category { id name } + price { id amount currency } + } + } + """, + expectedData: """ + { + "products": [ + { + "id": "1", + "name": { "id": "1", "brand": "Brand 1", "model": "Model 1" }, + "price": { "id": "1", "amount": 1000, "currency": "USD" }, + "category": { "id": "1", "name": "Category 1" } + } + ] + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/AccountsData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/AccountsData.cs new file mode 100644 index 00000000000..4a06df96d16 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/AccountsData.cs @@ -0,0 +1,24 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Accounts; + +/// +/// Seed data for the accounts subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/simple-requires-provides/data.ts. +/// +internal static class AccountsData +{ + /// + /// The seeded entities, ordered by id. + /// + public static readonly IReadOnlyList Users = + [ + new User { Id = "u1", Name = "u-name-1", Username = "u-username-1" }, + new User { Id = "u2", Name = "u-name-2", Username = "u-username-2" } + ]; + + /// + /// 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/SimpleRequiresProvides/Accounts/AccountsSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/AccountsSubgraph.cs new file mode 100644 index 00000000000..92ea6ff4fbe --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/AccountsSubgraph.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Accounts; + +/// +/// Builds the accounts Apollo Federation subgraph for the +/// simple-requires-provides audit suite. Owns the User +/// entity through Query.me. +/// +public static class AccountsSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "accounts"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/SimpleRequiresProvides/Accounts/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/QueryType.cs new file mode 100644 index 00000000000..7af6d26d145 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/QueryType.cs @@ -0,0 +1,20 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Accounts; + +/// +/// Root Query for the accounts subgraph. Exposes the +/// me: 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("me") + .Type() + .Resolve(_ => AccountsData.Users[0]); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/User.cs new file mode 100644 index 00000000000..a2aaaa84da2 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/User.cs @@ -0,0 +1,15 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Accounts; + +/// +/// The User entity as projected by the accounts subgraph +/// (@key(fields: "id")). Owns name and username +/// (the latter is shareable so other subgraphs may also expose it). +/// +public sealed class User +{ + public string Id { get; init; } = default!; + + public string? Name { get; init; } + + public string? Username { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/UserType.cs new file mode 100644 index 00000000000..f2dc0075da4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Accounts/UserType.cs @@ -0,0 +1,27 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Accounts; + +/// +/// Apollo Federation descriptor for the User entity owned by the +/// accounts subgraph. Mirrors the audit Schema Definition Language +/// (SDL): type User @key(fields: "id") { id: ID!, name: String, +/// username: String @shareable }. +/// +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.Name).Type(); + descriptor.Field(u => u.Username).Shareable().Type(); + } + + private static User? ResolveById(string id) + => AccountsData.ById.TryGetValue(id, out var user) ? user : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/InventoryData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/InventoryData.cs new file mode 100644 index 00000000000..c7cc319f7d4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/InventoryData.cs @@ -0,0 +1,23 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Inventory; + +/// +/// Seed data for the inventory subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/simple-requires-provides/data.ts. +/// Only p1 is in stock; the seed list of product Universal Product +/// Codes (UPCs) is intentionally narrow. +/// +internal static class InventoryData +{ + /// + /// The set of product Universal Product Codes (UPCs) that are in stock. + /// + public static readonly IReadOnlySet InStock = + new HashSet(StringComparer.Ordinal) { "p1" }; + + /// + /// The known product Universal Product Codes (UPCs) the inventory + /// subgraph recognizes when resolving entity references. + /// + public static readonly IReadOnlySet KnownUpcs = + new HashSet(StringComparer.Ordinal) { "p1", "p2" }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/InventorySubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/InventorySubgraph.cs new file mode 100644 index 00000000000..543b9431189 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/InventorySubgraph.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Inventory; + +/// +/// Builds the inventory Apollo Federation subgraph for the +/// simple-requires-provides audit suite. Owns Product.inStock +/// plus the @requires(price weight) fields +/// shippingEstimate and shippingEstimateTag. +/// +public static class InventorySubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "inventory"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/SimpleRequiresProvides/Inventory/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/Product.cs new file mode 100644 index 00000000000..8bdec8f6690 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/Product.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Inventory; + +/// +/// The Product entity in the inventory subgraph +/// (@key(fields: "upc")). Owns inStock, shippingEstimate, +/// and shippingEstimateTag; weight and price are +/// external and supplied by the federation external setter when the +/// gateway attaches the requires dependencies to the entity representation. +/// +public sealed class Product +{ + public string Upc { get; set; } = default!; + + public int? Weight { get; set; } + + public int? Price { get; set; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/ProductType.cs new file mode 100644 index 00000000000..b0fbf5929e4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/ProductType.cs @@ -0,0 +1,71 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Inventory; + +/// +/// Apollo Federation descriptor for the Product entity in the +/// inventory subgraph. Mirrors the audit Schema Definition Language +/// (SDL): owns inStock, shippingEstimate, and +/// shippingEstimateTag. The latter two require the external +/// price and weight fields, which the federation external +/// setter copies from the inbound entity representation onto the parent +/// before the resolver runs. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("upc") + .ResolveReferenceWith(_ => ResolveByUpc(default!)); + + descriptor.Field(p => p.Upc).Type>(); + descriptor.Field(p => p.Weight).External().Type(); + descriptor.Field(p => p.Price).External().Type(); + + descriptor + .Field("inStock") + .Type() + .Resolve(ctx => + { + var product = ctx.Parent(); + return InventoryData.InStock.Contains(product.Upc); + }); + + descriptor + .Field("shippingEstimate") + .Type() + .Requires("price weight") + .Resolve(ctx => + { + var product = ctx.Parent(); + if (product.Price is not int price || product.Weight is not int weight) + { + throw new InvalidOperationException( + "shippingEstimate requires price and weight on the parent entity."); + } + return price * weight * 10; + }); + + descriptor + .Field("shippingEstimateTag") + .Type() + .Requires("price weight") + .Resolve(ctx => + { + var product = ctx.Parent(); + if (product.Price is not int price || product.Weight is not int weight) + { + throw new InvalidOperationException( + "shippingEstimateTag requires price and weight on the parent entity."); + } + return $"#{product.Upc}#{price * weight * 10}#"; + }); + } + + private static Product? ResolveByUpc(string upc) + => InventoryData.KnownUpcs.Contains(upc) + ? new Product { Upc = upc } + : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/QueryType.cs new file mode 100644 index 00000000000..b14ea156520 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Inventory/QueryType.cs @@ -0,0 +1,20 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Inventory; + +/// +/// Root Query placeholder for the inventory subgraph. The +/// audit Schema Definition Language (SDL) declares no user-facing query +/// fields, so the type extends the federated Query 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/SimpleRequiresProvides/Products/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/Product.cs new file mode 100644 index 00000000000..8d97e7a7785 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/Product.cs @@ -0,0 +1,17 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Products; + +/// +/// The Product entity as projected by the products subgraph +/// (@key(fields: "upc")). Owns name, price, and +/// weight. +/// +public sealed class Product +{ + public string Upc { get; init; } = default!; + + public string? Name { get; init; } + + public int? Price { get; init; } + + public int? Weight { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductType.cs new file mode 100644 index 00000000000..5538263eb11 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductType.cs @@ -0,0 +1,28 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Products; + +/// +/// Apollo Federation descriptor for the Product entity owned by the +/// products subgraph. Mirrors the audit Schema Definition Language +/// (SDL): type Product @key(fields: "upc") { upc: String!, name: String, +/// price: Int, weight: Int }. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("upc") + .ResolveReferenceWith(_ => ResolveByUpc(default!)); + + descriptor.Field(p => p.Upc).Type>(); + descriptor.Field(p => p.Name).Type(); + descriptor.Field(p => p.Price).Type(); + descriptor.Field(p => p.Weight).Type(); + } + + private static Product? ResolveByUpc(string upc) + => ProductsData.ByUpc.TryGetValue(upc, out var product) ? product : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductsData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductsData.cs new file mode 100644 index 00000000000..92499211010 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductsData.cs @@ -0,0 +1,24 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Products; + +/// +/// Seed data for the products subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/simple-requires-provides/data.ts. +/// +internal static class ProductsData +{ + /// + /// The seeded entities, ordered by upc. + /// + public static readonly IReadOnlyList Products = + [ + new Product { Upc = "p1", Name = "p-name-1", Price = 11, Weight = 1 }, + new Product { Upc = "p2", Name = "p-name-2", Price = 22, Weight = 2 } + ]; + + /// + /// The seeded entities indexed by their upc + /// field. + /// + public static readonly IReadOnlyDictionary ByUpc = + Products.ToDictionary(static p => p.Upc, StringComparer.Ordinal); +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductsSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductsSubgraph.cs new file mode 100644 index 00000000000..3de7c899f84 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/ProductsSubgraph.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Products; + +/// +/// Builds the products Apollo Federation subgraph for the +/// simple-requires-provides audit suite. Owns the Product +/// entity through Query.products. +/// +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 + /// . + /// + 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/SimpleRequiresProvides/Products/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/QueryType.cs new file mode 100644 index 00000000000..55ebe76bb66 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Products/QueryType.cs @@ -0,0 +1,20 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Products; + +/// +/// Root Query for the products subgraph. Exposes the +/// products: [Product] list field. +/// +public sealed class QueryType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor + .Field("products") + .Type>() + .Resolve(_ => ProductsData.Products); + } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/accounts.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/accounts.graphql new file mode 100644 index 00000000000..a337c037295 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/accounts.graphql @@ -0,0 +1,15 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@shareable"] + ) + +type Query { + me: User +} + +type User @key(fields: "id") { + id: ID! + name: String + username: String @shareable +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/data.json new file mode 100644 index 00000000000..34bc71f65ab --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/data.json @@ -0,0 +1,43 @@ +{ + "users": [ + { + "id": "u1", + "username": "u-username-1", + "name": "u-name-1" + }, + { + "id": "u2", + "username": "u-username-2", + "name": "u-name-2" + } + ], + "products": [ + { + "upc": "p1", + "name": "p-name-1", + "price": 11, + "weight": 1 + }, + { + "upc": "p2", + "name": "p-name-2", + "price": 22, + "weight": 2 + } + ], + "inStock": ["p1"], + "reviews": [ + { + "id": "r1", + "body": "r-body-1", + "authorId": "u1", + "productUpc": "p1" + }, + { + "id": "r2", + "body": "r-body-2", + "authorId": "u1", + "productUpc": "p2" + } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/inventory.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/inventory.graphql new file mode 100644 index 00000000000..aa8a592c65c --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/inventory.graphql @@ -0,0 +1,14 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@requires"] + ) + +type Product @key(fields: "upc") { + upc: String! + weight: Int @external + price: Int @external + inStock: Boolean + shippingEstimate: Int @requires(fields: "price weight") + shippingEstimateTag: String @requires(fields: "price weight") +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/products.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/products.graphql new file mode 100644 index 00000000000..ac4e30ca9a0 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/products.graphql @@ -0,0 +1,13 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Query { + products: [Product] +} + +type Product @key(fields: "upc") { + upc: String! + name: String + price: Int + weight: Int +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/reviews.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/reviews.graphql new file mode 100644 index 00000000000..1c272df9639 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/reviews.graphql @@ -0,0 +1,23 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@external", "@provides"] + ) + +type Review @key(fields: "id") { + id: ID! + body: String + author: User @provides(fields: "username") + product: Product +} + +type User @key(fields: "id") { + id: ID! + username: String @external + reviews: [Review] +} + +type Product @key(fields: "upc") { + upc: String! + reviews: [Review] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/tests.json new file mode 100644 index 00000000000..b691814134e --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reference/tests.json @@ -0,0 +1,180 @@ +[ + { + "query": "query {\n me {\n id\n }\n}\n", + "expected": { + "data": { + "me": { + "id": "u1" + } + } + } + }, + { + "query": "query {\n me {\n id\n reviews {\n id\n }\n }\n}\n", + "expected": { + "data": { + "me": { + "id": "u1", + "reviews": [ + { "id": "r1" }, + { "id": "r2" } + ] + } + } + } + }, + { + "query": "query {\n me {\n reviews {\n id\n author {\n id\n username\n }\n product {\n inStock\n }\n }\n }\n}\n", + "expected": { + "data": { + "me": { + "reviews": [ + { + "id": "r1", + "author": { + "id": "u1", + "username": "u-username-1" + }, + "product": { + "inStock": true + } + }, + { + "id": "r2", + "author": { + "id": "u1", + "username": "u-username-1" + }, + "product": { + "inStock": false + } + } + ] + } + } + } + }, + { + "query": "query {\n products {\n name\n }\n}\n", + "expected": { + "data": { + "products": [ + { "name": "p-name-1" }, + { "name": "p-name-2" } + ] + } + } + }, + { + "query": "query {\n products {\n price\n }\n}\n", + "expected": { + "data": { + "products": [ + { "price": 11 }, + { "price": 22 } + ] + } + } + }, + { + "query": "query {\n products {\n shippingEstimate\n }\n}\n", + "expected": { + "data": { + "products": [ + { "shippingEstimate": 110 }, + { "shippingEstimate": 440 } + ] + } + } + }, + { + "query": "query {\n products {\n shippingEstimate\n weight\n price\n }\n}\n", + "expected": { + "data": { + "products": [ + { "shippingEstimate": 110, "weight": 1, "price": 11 }, + { "shippingEstimate": 440, "weight": 2, "price": 22 } + ] + } + } + }, + { + "query": "{\n products {\n reviews {\n id\n author {\n username\n }\n product {\n name\n shippingEstimate\n }\n }\n }\n}\n", + "expected": { + "data": { + "products": [ + { + "reviews": [ + { + "id": "r1", + "author": { "username": "u-username-1" }, + "product": { "name": "p-name-1", "shippingEstimate": 110 } + } + ] + }, + { + "reviews": [ + { + "id": "r2", + "author": { "username": "u-username-1" }, + "product": { "name": "p-name-2", "shippingEstimate": 440 } + } + ] + } + ] + } + } + }, + { + "query": "{\n me {\n reviews {\n product {\n reviews {\n id\n }\n }\n }\n }\n}\n", + "expected": { + "data": { + "me": { + "reviews": [ + { "product": { "reviews": [{ "id": "r1" }] } }, + { "product": { "reviews": [{ "id": "r2" }] } } + ] + } + } + } + }, + { + "query": "query {\n me {\n reviews {\n product {\n inStock\n }\n }\n }\n}\n", + "expected": { + "data": { + "me": { + "reviews": [ + { "product": { "inStock": true } }, + { "product": { "inStock": false } } + ] + } + } + } + }, + { + "query": "query {\n me {\n reviews {\n product {\n shippingEstimate\n }\n }\n }\n}\n", + "expected": { + "data": { + "me": { + "reviews": [ + { "product": { "shippingEstimate": 110 } }, + { "product": { "shippingEstimate": 440 } } + ] + } + } + } + }, + { + "query": "query {\n me {\n reviews {\n product {\n shippingEstimate\n shippingEstimateTag\n }\n }\n }\n}\n", + "expected": { + "data": { + "me": { + "reviews": [ + { "product": { "shippingEstimate": 110, "shippingEstimateTag": "#p1#110#" } }, + { "product": { "shippingEstimate": 440, "shippingEstimateTag": "#p2#440#" } } + ] + } + } + } + } +] diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/Product.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/Product.cs new file mode 100644 index 00000000000..28f77d85c77 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/Product.cs @@ -0,0 +1,12 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// The Product entity as projected by the reviews subgraph +/// (@key(fields: "upc")). The reviews subgraph contributes the +/// reviews field; name, price, and weight are +/// owned elsewhere. +/// +public sealed class Product +{ + public string Upc { get; init; } = default!; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ProductType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ProductType.cs new file mode 100644 index 00000000000..33c2fd2cec5 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ProductType.cs @@ -0,0 +1,35 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// Apollo Federation descriptor for the Product entity as extended +/// by the reviews subgraph (@key(fields: "upc")). Owns +/// the reviews field; name, price, and weight +/// are owned elsewhere. +/// +public sealed class ProductType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("upc") + .ResolveReferenceWith(_ => ResolveByUpc(default!)); + + descriptor.Field(p => p.Upc).Type>(); + + descriptor + .Field("reviews") + .Type>() + .Resolve(ctx => + { + var product = ctx.Parent(); + return ReviewsData.Reviews + .Where(r => string.Equals(r.ProductUpc, product.Upc, StringComparison.Ordinal)) + .ToArray(); + }); + } + + private static Product ResolveByUpc(string upc) => new() { Upc = upc }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/QueryType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/QueryType.cs new file mode 100644 index 00000000000..f74af199d75 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/QueryType.cs @@ -0,0 +1,20 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// Root Query placeholder for the reviews subgraph. The +/// audit Schema Definition Language (SDL) declares no user-facing query +/// fields, so the type extends the federated Query 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/SimpleRequiresProvides/Reviews/Review.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/Review.cs new file mode 100644 index 00000000000..945d0ed8c38 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/Review.cs @@ -0,0 +1,19 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// The Review entity in the reviews subgraph +/// (@key(fields: "id")). Carries the foreign keys +/// and so the resolvers for +/// can project the related User +/// and Product entities. +/// +public sealed class Review +{ + public string Id { get; init; } = default!; + + public string? Body { get; init; } + + public string? AuthorId { get; init; } + + public string? ProductUpc { get; init; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewType.cs new file mode 100644 index 00000000000..489a8cf7341 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewType.cs @@ -0,0 +1,60 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// Apollo Federation descriptor for the Review entity owned by the +/// reviews subgraph. Mirrors the audit Schema Definition Language +/// (SDL): type Review @key(fields: "id") { id: ID!, body: String, +/// author: User @provides(fields: "username"), product: Product }. +/// The author resolver inlines username alongside the +/// returned so the gateway can satisfy the provides +/// selection without dispatching a fresh entity call to the +/// accounts subgraph. +/// +public sealed class ReviewType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor + .Key("id") + .ResolveReferenceWith(_ => ResolveById(default!)); + + descriptor.Field(r => r.Id).Type>(); + descriptor.Field(r => r.Body).Type(); + + descriptor + .Field("author") + .Type() + .Provides("username") + .Resolve(ctx => + { + var review = ctx.Parent(); + if (review.AuthorId is not { Length: > 0 } authorId) + { + return null; + } + + var username = ReviewsData.UsernameById.TryGetValue(authorId, out var u) ? u : null; + return new User { Id = authorId, Username = username }; + }); + + descriptor + .Field("product") + .Type() + .Resolve(ctx => + { + var review = ctx.Parent(); + if (review.ProductUpc is not { Length: > 0 } upc) + { + return null; + } + + return new Product { Upc = upc }; + }); + } + + private static Review? ResolveById(string id) + => ReviewsData.ById.TryGetValue(id, out var review) ? review : null; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewsData.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewsData.cs new file mode 100644 index 00000000000..d9570e2b69d --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewsData.cs @@ -0,0 +1,39 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// Seed data for the reviews subgraph, transcribed from +/// graphql-hive/federation-gateway-audit/src/test-suites/simple-requires-provides/data.ts. +/// The reviews subgraph also remembers the inline username for users so the +/// @provides(fields: "username") path on Review.author can ship +/// the value alongside the entity reference. +/// +internal static class ReviewsData +{ + /// + /// The seeded entities, ordered by id. + /// + public static readonly IReadOnlyList Reviews = + [ + new Review { Id = "r1", Body = "r-body-1", AuthorId = "u1", ProductUpc = "p1" }, + new Review { Id = "r2", Body = "r-body-2", AuthorId = "u1", ProductUpc = "p2" } + ]; + + /// + /// The seeded entities indexed by their id + /// field. + /// + public static readonly IReadOnlyDictionary ById = + Reviews.ToDictionary(static r => r.Id, StringComparer.Ordinal); + + /// + /// Inline mirror of the accounts subgraph user table so the + /// reviews subgraph can populate the external username field + /// when serving the @provides(fields: "username") path. + /// + public static readonly IReadOnlyDictionary UsernameById = + new Dictionary(StringComparer.Ordinal) + { + ["u1"] = "u-username-1", + ["u2"] = "u-username-2" + }; +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewsSubgraph.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewsSubgraph.cs new file mode 100644 index 00000000000..44ff77c35ee --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/ReviewsSubgraph.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// Builds the reviews Apollo Federation subgraph for the +/// simple-requires-provides audit suite. Owns the Review +/// entity and contributes reviews to both User and +/// Product. +/// +public static class ReviewsSubgraph +{ + /// + /// The source-schema name by which the gateway addresses this subgraph. + /// + public const string Name = "reviews"; + + /// + /// Starts the subgraph's and returns a + /// . + /// + 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/SimpleRequiresProvides/Reviews/User.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/User.cs new file mode 100644 index 00000000000..dc5857912ac --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/User.cs @@ -0,0 +1,14 @@ +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// The User entity as projected by the reviews subgraph +/// (@key(fields: "id")). username is external and may be +/// populated when the gateway dispatches an entity reference that already +/// carries the field through @provides(fields: "username"). +/// +public sealed class User +{ + public string Id { get; set; } = default!; + + public string? Username { get; set; } +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/UserType.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/UserType.cs new file mode 100644 index 00000000000..018593466df --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/SimpleRequiresProvides/Reviews/UserType.cs @@ -0,0 +1,38 @@ +using HotChocolate.ApolloFederation.Types; +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + +/// +/// Apollo Federation descriptor for the User entity as extended by +/// the reviews subgraph. Owns reviews; username is +/// external. The reference resolver returns just the key, the optional +/// username is supplied by the federation external setter when the +/// gateway dispatches the entity reference along the +/// @provides(fields: "username") path. +/// +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.Username).External().Type(); + + descriptor + .Field("reviews") + .Type>() + .Resolve(ctx => + { + var user = ctx.Parent(); + return ReviewsData.Reviews + .Where(r => string.Equals(r.AuthorId, user.Id, StringComparison.Ordinal)) + .ToArray(); + }); + } + + private static User ResolveById(string id) => new() { Id = id }; +} 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 index 81090c15a0f..f5ddb77f038 100644 --- 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 @@ -1,10 +1,378 @@ +using HotChocolate.Fusion.Suites.SimpleRequiresProvides.Accounts; +using HotChocolate.Fusion.Suites.SimpleRequiresProvides.Inventory; +using HotChocolate.Fusion.Suites.SimpleRequiresProvides.Products; +using HotChocolate.Fusion.Suites.SimpleRequiresProvides.Reviews; + namespace HotChocolate.Fusion.Suites; +/// +/// Port of the simple-requires-provides suite from +/// graphql-hive/federation-gateway-audit. Four Apollo Federation +/// subgraphs (accounts, products, inventory, reviews) +/// share the User and Product entities. The audit verifies +/// that @requires dependencies on shippingEstimate and +/// shippingEstimateTag route through the entity lookup, and that +/// @provides(fields: "username") on Review.author short-circuits +/// the entity call to accounts. +/// public sealed class SimpleRequiresProvidesTests : ComplianceTestBase { protected override Task BuildGatewayAsync() - => throw new NotImplementedException("Subgraphs not yet wired for this suite."); + => FusionGatewayBuilder.ComposeAsync( + (AccountsSubgraph.Name, AccountsSubgraph.BuildAsync), + (ProductsSubgraph.Name, ProductsSubgraph.BuildAsync), + (InventorySubgraph.Name, InventorySubgraph.BuildAsync), + (ReviewsSubgraph.Name, ReviewsSubgraph.BuildAsync)); + + /// + /// Single-subgraph baseline: accounts serves me { id } + /// directly. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Me_Returns_Id_From_Accounts() => RunAsync( + query: """ + query { + me { + id + } + } + """, + expectedData: """ + { + "me": { "id": "u1" } + } + """); + + /// + /// accounts resolves me { id }; the planner enriches + /// the user with reviews from the reviews subgraph via + /// the entity lookup on id. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Me_Reviews_Composes_Across_Accounts_And_Reviews() => RunAsync( + query: """ + query { + me { + id + reviews { + id + } + } + } + """, + expectedData: """ + { + "me": { + "id": "u1", + "reviews": [ + { "id": "r1" }, + { "id": "r2" } + ] + } + } + """); + + /// + /// Exercises @provides(fields: "username") on Review.author + /// (the author { id username } selection should be served inline + /// from reviews) plus an entity hop into inventory for + /// product { inStock }. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Me_Reviews_Author_Provides_Username_And_Product_InStock() => RunAsync( + query: """ + query { + me { + reviews { + id + author { + id + username + } + product { + inStock + } + } + } + } + """, + expectedData: """ + { + "me": { + "reviews": [ + { + "id": "r1", + "author": { "id": "u1", "username": "u-username-1" }, + "product": { "inStock": true } + }, + { + "id": "r2", + "author": { "id": "u1", "username": "u-username-1" }, + "product": { "inStock": false } + } + ] + } + } + """); + + /// + /// Single-subgraph baseline: products serves products { name } + /// directly. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Products_Returns_Names_From_Products() => RunAsync( + query: """ + query { + products { + name + } + } + """, + expectedData: """ + { + "products": [ + { "name": "p-name-1" }, + { "name": "p-name-2" } + ] + } + """); + + /// + /// Single-subgraph baseline: products serves products { price } + /// directly. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Products_Returns_Prices_From_Products() => RunAsync( + query: """ + query { + products { + price + } + } + """, + expectedData: """ + { + "products": [ + { "price": 11 }, + { "price": 22 } + ] + } + """); + + /// + /// products resolves the list and key; inventory contributes + /// shippingEstimate via @requires(price weight). The planner + /// must fetch price and weight from products and + /// attach them to the entity representation passed to inventory. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Products_ShippingEstimate_Routes_Requires_Through_Inventory() => RunAsync( + query: """ + query { + products { + shippingEstimate + } + } + """, + expectedData: """ + { + "products": [ + { "shippingEstimate": 110 }, + { "shippingEstimate": 440 } + ] + } + """); + + /// + /// Same @requires path as + /// but the client also asks for the dependency fields directly. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Products_ShippingEstimate_With_Weight_And_Price_Selected() => RunAsync( + query: """ + query { + products { + shippingEstimate + weight + price + } + } + """, + expectedData: """ + { + "products": [ + { "shippingEstimate": 110, "weight": 1, "price": 11 }, + { "shippingEstimate": 440, "weight": 2, "price": 22 } + ] + } + """); + + /// + /// Deeply nested merge: products -> reviews -> author uses + /// @provides(fields: "username"); products -> reviews -> product + /// loops back into products for name and into inventory + /// for shippingEstimate (which itself uses @requires). + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Products_Reviews_Author_Provides_And_Product_Requires() => RunAsync( + query: """ + { + products { + reviews { + id + author { + username + } + product { + name + shippingEstimate + } + } + } + } + """, + expectedData: """ + { + "products": [ + { + "reviews": [ + { + "id": "r1", + "author": { "username": "u-username-1" }, + "product": { "name": "p-name-1", "shippingEstimate": 110 } + } + ] + }, + { + "reviews": [ + { + "id": "r2", + "author": { "username": "u-username-1" }, + "product": { "name": "p-name-2", "shippingEstimate": 440 } + } + ] + } + ] + } + """); + + /// + /// me -> reviews -> product -> reviews double-hops the + /// reviews subgraph for the inner reviews list using + /// the Product entity key. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Me_Reviews_Product_Reviews_DoubleHops_Reviews() => RunAsync( + query: """ + { + me { + reviews { + product { + reviews { + id + } + } + } + } + } + """, + expectedData: """ + { + "me": { + "reviews": [ + { "product": { "reviews": [{ "id": "r1" }] } }, + { "product": { "reviews": [{ "id": "r2" }] } } + ] + } + } + """); + + /// + /// me -> reviews -> product -> inStock hops accounts, + /// reviews, and inventory. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Me_Reviews_Product_InStock_Across_Three_Subgraphs() => RunAsync( + query: """ + query { + me { + reviews { + product { + inStock + } + } + } + } + """, + expectedData: """ + { + "me": { + "reviews": [ + { "product": { "inStock": true } }, + { "product": { "inStock": false } } + ] + } + } + """); + + /// + /// me -> reviews -> product -> shippingEstimate chains a + /// four-subgraph requires path: accounts for the user, + /// reviews for the reviews list, products to project the + /// price and weight dependencies, and inventory to + /// run the @requires(price weight) resolver. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Me_Reviews_Product_ShippingEstimate_Routes_Requires() => RunAsync( + query: """ + query { + me { + reviews { + product { + shippingEstimate + } + } + } + } + """, + expectedData: """ + { + "me": { + "reviews": [ + { "product": { "shippingEstimate": 110 } }, + { "product": { "shippingEstimate": 440 } } + ] + } + } + """); - [Fact(Skip = "Pending: @requires/@provides coverage.")] - public Task Pending() => Task.CompletedTask; + /// + /// me -> reviews -> product reads two @requires(price weight) + /// fields side by side. The planner should fetch the dependencies once + /// and serve both downstream resolvers from the same representation. + /// + [Fact(Skip = "Federation transformer's RemoveExternalFields strips the @external 'username' field that the @provides(fields: \"username\") directive on Review.author references, so source-schema validation rejects the composition before any test runs. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] + public Task Me_Reviews_Product_ShippingEstimate_And_Tag() => RunAsync( + query: """ + query { + me { + reviews { + product { + shippingEstimate + shippingEstimateTag + } + } + } + } + """, + expectedData: """ + { + "me": { + "reviews": [ + { "product": { "shippingEstimate": 110, "shippingEstimateTag": "#p1#110#" } }, + { "product": { "shippingEstimate": 440, "shippingEstimateTag": "#p2#440#" } } + ] + } + } + """); } diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/a.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/a.graphql new file mode 100644 index 00000000000..8f9b109bbae --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/a.graphql @@ -0,0 +1,30 @@ +extend schema + @link(url: "https://specs.apollo.dev/federation/v2.3", import: ["@key"]) + +type Query { + union: Product + interface: Node +} + +union Product = Oven | Toaster + +interface Node { + id: ID! +} + +type Oven implements Node { + id: ID! +} + +type Toaster implements Node { + id: ID! +} + +interface User @key(fields: "id") { + id: ID! +} + +type Admin implements User @key(fields: "id") { + id: ID! + isMain: Boolean! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/b.graphql b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/b.graphql new file mode 100644 index 00000000000..ce67b0fd6ef --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/b.graphql @@ -0,0 +1,14 @@ +extend schema + @link( + url: "https://specs.apollo.dev/federation/v2.3" + import: ["@key", "@interfaceObject"] + ) + +type Query { + users: [User] +} + +type User @key(fields: "id") @interfaceObject { + id: ID! + name: String! +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/data.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/data.json new file mode 100644 index 00000000000..161c7a14fe8 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/data.json @@ -0,0 +1,6 @@ +{ + "users": [ + { "__typename": "Admin", "id": "u1", "name": "Alice", "isMain": false }, + { "__typename": "Admin", "id": "u2", "name": "Bob", "isMain": true } + ] +} diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/tests.json b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/tests.json new file mode 100644 index 00000000000..a335187acc4 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Compliance.Tests/Suites/Typename/Reference/tests.json @@ -0,0 +1,50 @@ +[ + { + "query": "query {\n union {\n __typename\n typename: __typename\n }\n}\n", + "expected": { + "data": { + "union": { "__typename": "Oven", "typename": "Oven" } + } + } + }, + { + "query": "query {\n interface {\n id\n __typename\n typename: __typename\n t: __typename\n }\n}\n", + "expected": { + "data": { + "interface": { "id": "2", "__typename": "Toaster", "typename": "Toaster", "t": "Toaster" } + } + } + }, + { + "query": "query {\n union {\n __typename\n ... on Oven { typename: __typename }\n ... on Toaster { typename: __typename }\n }\n}\n", + "expected": { + "data": { + "union": { "__typename": "Oven", "typename": "Oven" } + } + } + }, + { + "query": "query {\n interface {\n __typename\n ... on Oven { typename: __typename }\n ... on Toaster { typename: __typename }\n }\n}\n", + "expected": { + "data": { + "interface": { "__typename": "Toaster", "typename": "Toaster" } + } + } + }, + { + "query": "query {\n users { id }\n}\n", + "expected": { + "data": { + "users": [ { "id": "u1" }, { "id": "u2" } ] + } + } + }, + { + "query": "query {\n users { __typename }\n}\n", + "expected": { + "data": { + "users": [ { "__typename": "Admin" }, { "__typename": "Admin" } ] + } + } + } +] 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 index 98b0c990b49..153d465e056 100644 --- 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 @@ -5,6 +5,6 @@ public sealed class TypenameTests : ComplianceTestBase protected override Task BuildGatewayAsync() => throw new NotImplementedException("Subgraphs not yet wired for this suite."); - [Fact(Skip = "Pending: subgraph harness.")] + [Fact(Skip = "Audit subgraph 'b' uses @interfaceObject which is not yet supported by the Apollo Federation adapter. See APOLLO_FEDERATION_COMPLIANCE_FOLLOWUP.md follow-up.")] 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 index 6c5df169a5a..42ccdc85283 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/ApolloFederationConnectorTests.cs @@ -1,4 +1,7 @@ using System.Collections.Immutable; +using System.Text.Json; +using HotChocolate.Buffers; +using HotChocolate.Fusion.Execution; using HotChocolate.Fusion.Execution.Clients; using HotChocolate.Language; @@ -6,6 +9,9 @@ namespace HotChocolate.Fusion; public class ApolloFederationConnectorTests { + private static readonly IReadOnlyDictionary s_noRequires + = new Dictionary(StringComparer.Ordinal); + [Fact] public void Configuration_Should_StoreProperties() { @@ -19,7 +25,8 @@ public void Configuration_Should_StoreProperties() "products-http", baseAddress, lookups, - SupportedOperationType.Query); + new Dictionary(), + supportedOperations: SupportedOperationType.Query); // assert Assert.Equal("products", config.Name); @@ -37,7 +44,8 @@ public void Configuration_Should_DefaultToQueryAndMutation() "products", "products-http", new Uri("http://localhost:5000/graphql"), - new Dictionary()); + new Dictionary(), + new Dictionary()); // assert Assert.Equal( @@ -57,7 +65,7 @@ public void Rewrite_SimpleLookup_Should_ProduceEntitiesQuery() ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query GetProduct($__fusion_1_id: ID!) { @@ -99,7 +107,7 @@ public void Rewrite_CompositeKeyLookup_Should_MapMultipleArguments() } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query Op($__fusion_1_sku: String!, $__fusion_1_package: String!) { @@ -126,7 +134,7 @@ public void Rewrite_NonLookupField_Should_BePassthrough() { // arrange var lookupFields = new Dictionary(); - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query { @@ -159,7 +167,7 @@ public void GetOrRewrite_SameHash_Should_ReturnCachedResult() ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query Op($__fusion_1_id: ID!) { @@ -187,7 +195,7 @@ public void Rewrite_SimpleLookup_Should_ProduceCorrectVariableDefinition() ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query GetProduct($__fusion_1_id: ID!) { @@ -218,7 +226,7 @@ public void Rewrite_DifferentHashes_Should_ReturnDistinctResults() ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query Op($__fusion_1_id: ID!) { @@ -248,7 +256,7 @@ public void Rewrite_EntityLookup_Should_IncludeInlineFragment() ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query GetProduct($__fusion_1_id: ID!) { @@ -282,7 +290,7 @@ public void Rewrite_Passthrough_Should_HaveNullInlineFragment() { // arrange var lookupFields = new Dictionary(); - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query { @@ -319,8 +327,8 @@ public void BuildCombinedEntityQuery_Should_ProduceAliasedEntitiesQuery() } }; - var productRewriter = new FederationQueryRewriter(productLookup); - var userRewriter = new FederationQueryRewriter(userLookup); + var productRewriter = new FederationQueryRewriter(productLookup, s_noRequires); + var userRewriter = new FederationQueryRewriter(userLookup, s_noRequires); var productOp = productRewriter.GetOrRewrite( """ @@ -394,7 +402,7 @@ public void Rewrite_NestedObjectLookup_Should_MapArgumentToKeyPath() ArgumentToKeyFieldMap = new Dictionary { ["key"] = string.Empty } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query GetArticle($__fusion_1_key: ArticleByMetadataInput!) { @@ -431,7 +439,7 @@ public void Rewrite_NestedListLookup_Should_MapArgumentToKeyPath() ArgumentToKeyFieldMap = new Dictionary { ["key"] = string.Empty } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query GetList($__fusion_1_key: ProductListByProductsAndIdInput!) { @@ -466,7 +474,7 @@ public void Rewrite_DeeplyNestedListLookup_Should_MapArgumentToKeyPath() new Dictionary { ["key"] = string.Empty } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query($__fusion_1_key: ProductListByProductsAndIdAndPidAndCategoryAndIdAndTagAndSelectedAndIdInput!) { @@ -503,7 +511,7 @@ public void BuildCombinedEntityQuery_Should_ProduceEntitiesAliasForNestedLookup( ArgumentToKeyFieldMap = new Dictionary { ["key"] = string.Empty } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); const string sourceText = """ query($__fusion_1_key: ProductListByProductsAndIdInput!) { @@ -540,4 +548,174 @@ public void BuildCombinedEntityQuery_Should_ProduceEntitiesAliasForNestedLookup( Assert.Contains("... on ProductList", queryText); Assert.Contains("\"__typename\":\"ProductList\"", variablesJson); } + + [Fact] + public void Rewrite_Should_Strip_RequireArgument_And_Record_RepresentationMapping() + { + // arrange: the composer translates '@requires(fields: "price")' on + // 'Product.isExpensive' into a synthetic 'price' argument on the + // composite-schema field. The planner emits the argument as a + // variable reference, and the rewriter must strip it from the + // outgoing '_entities' selection while recording the bound variable + // against the representation field 'price'. + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var entityRequires = new Dictionary + { + ["Product"] = new EntityRequiresInfo + { + Fields = new Dictionary> + { + ["isExpensive"] = new Dictionary { ["price"] = "price" } + } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields, entityRequires); + + const string sourceText = """ + query($__fusion_1_id: ID!, $__fusion_2_price: Float!) { + productById(id: $__fusion_1_id) { + isExpensive(price: $__fusion_2_price) + isAvailable + } + } + """; + + // act + var result = rewriter.GetOrRewrite(sourceText, 201UL); + + // assert + Assert.True(result.IsEntityLookup); + Assert.Equal("Product", result.EntityTypeName); + Assert.Equal("id", result.VariableToKeyFieldMap["__fusion_1_id"]); + Assert.Equal("price", result.VariableToKeyFieldMap["__fusion_2_price"]); + Assert.DoesNotContain("price:", result.OperationText); + Assert.Contains("isExpensive", result.OperationText); + Assert.Contains("isAvailable", result.OperationText); + } + + [Fact] + public void Rewrite_Should_Leave_NonRequireArguments_Untouched() + { + // arrange: an argument on a selection field that is not tagged as a + // require must pass through to the outgoing operation verbatim. + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var entityRequires = new Dictionary + { + ["Product"] = new EntityRequiresInfo + { + Fields = new Dictionary> + { + ["isExpensive"] = new Dictionary { ["price"] = "price" } + } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields, entityRequires); + + const string sourceText = """ + query($__fusion_1_id: ID!, $__fusion_2_limit: Int!) { + productById(id: $__fusion_1_id) { + relatedBy(limit: $__fusion_2_limit) + } + } + """; + + // act + var result = rewriter.GetOrRewrite(sourceText, 202UL); + + // assert + Assert.True(result.IsEntityLookup); + Assert.Contains("limit: $__fusion_2_limit", result.OperationText); + Assert.False(result.VariableToKeyFieldMap.ContainsKey("__fusion_2_limit")); + } + + [Fact] + public void BuildCombinedEntityQuery_Should_Write_RequireField_Into_Representation() + { + // arrange: pair the rewritten '@require' variable mapping with a + // variable payload that carries the bound values. The resulting + // representations array must contain both the entity key field and + // the require field projected as top-level entries. + var lookupFields = new Dictionary + { + ["productById"] = new LookupFieldInfo + { + EntityTypeName = "Product", + ArgumentToKeyFieldMap = new Dictionary { ["id"] = "id" } + } + }; + var entityRequires = new Dictionary + { + ["Product"] = new EntityRequiresInfo + { + Fields = new Dictionary> + { + ["isExpensive"] = new Dictionary { ["price"] = "price" } + } + } + }; + var rewriter = new FederationQueryRewriter(lookupFields, entityRequires); + + const string sourceText = """ + query($__fusion_1_id: ID!, $__fusion_2_price: Float!) { + productById(id: $__fusion_1_id) { + isExpensive(price: $__fusion_2_price) + } + } + """; + + var rewritten = rewriter.GetOrRewrite(sourceText, 203UL); + + var variableValues = CreateVariableValues( + """{"__fusion_1_id":"p1","__fusion_2_price":9.99}"""); + + var requests = ImmutableArray.Create( + new SourceSchemaClientRequest + { + Node = null!, + SchemaName = "products", + OperationType = OperationType.Query, + OperationSourceText = rewritten.OperationText, + OperationHash = 203UL, + Variables = [variableValues] + }); + + var rewrittenOps = new[] { rewritten }; + + // act + var (_, variablesJson) = + ApolloFederationSourceSchemaClient.BuildCombinedEntityQuery(requests, rewrittenOps); + + // assert + Assert.Contains("\"__typename\":\"Product\"", variablesJson); + Assert.Contains("\"id\":\"p1\"", variablesJson); + Assert.Contains("\"price\":9.99", variablesJson); + } + + private static VariableValues CreateVariableValues(string json) + { + var writer = new ChunkedArrayWriter(); + var startPosition = writer.Position; + using (var jsonWriter = new Utf8JsonWriter(writer)) + { + using var document = JsonDocument.Parse(json); + document.RootElement.WriteTo(jsonWriter); + jsonWriter.Flush(); + } + var length = writer.Position - startPosition; + return new VariableValues(default, JsonSegment.Create(writer, startPosition, length)); + } } 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 index 35bcf93bc74..0b2cdfa8968 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/Configuration/ParsersTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/Configuration/ParsersTests.cs @@ -235,6 +235,93 @@ public void TryParse_Should_Accept_ObjectForm_WithPathSegment() Assert.Equal("id", lookup.ArgumentToKeyFieldMap["id"]); } + [Fact] + public void TryParse_Should_Accept_EntityRequires_Block() + { + // arrange: the composer emits per-entity-type field require metadata + // under 'extensions.apolloFederation.entityTypes'. For each field + // with synthetic '@require' arguments, the block carries the + // argument name to representation field path mapping that the + // rewriter uses to strip the argument and project the variable + // value onto the '_entities' representation. + const string settingsJson = + """ + { + "inventory": { + "transports": { "http": { "url": "http://inventory/graphql" } }, + "extensions": { + "apolloFederation": { + "lookups": { + "productById": { + "entityType": "Product", + "arguments": { "id": "id" } + } + }, + "entityTypes": { + "Product": { + "fields": { + "shippingEstimate": { + "requires": { "price": "price", "weight": "weight" } + } + } + } + } + } + } + } + } + """; + + 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.True(federationConfig.EntityRequires.TryGetValue("Product", out var productRequires)); + Assert.True(productRequires.Fields.TryGetValue("shippingEstimate", out var shippingArgs)); + Assert.Equal("price", shippingArgs["price"]); + Assert.Equal("weight", shippingArgs["weight"]); + } + + [Fact] + public void TryParse_Should_Treat_Missing_EntityRequires_As_Empty() + { + // 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.Empty(federationConfig.EntityRequires); + } + private static (JsonProperty SourceSchema, JsonProperty Transport) ReadSettings(string settingsJson) { var document = JsonDocument.Parse(settingsJson); diff --git a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs index fb1a7a363be..ac89c171ca2 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Connectors.ApolloFederation.Tests/SchemaTransformationIntegrationTests.cs @@ -9,6 +9,9 @@ namespace HotChocolate.Fusion; public class SchemaTransformationIntegrationTests { + private static readonly IReadOnlyDictionary s_noRequires + = new Dictionary(StringComparer.Ordinal); + [Fact] public async Task Transform_FederationSubgraph_Should_ProduceValidCompositeSchema() { @@ -79,7 +82,7 @@ public async Task Rewriter_Should_RewriteLookupToEntities_FromTransformedSchema( } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); // Simulate what the Fusion planner would generate const string plannerQuery = """ @@ -183,7 +186,7 @@ public async Task FullRoundtrip_Transform_Rewrite_Execute() ArgumentToKeyFieldMap = new Dictionary { ["email"] = "email" } } }; - var rewriter = new FederationQueryRewriter(lookupFields); + var rewriter = new FederationQueryRewriter(lookupFields, s_noRequires); // 4. Simulate planner query and rewrite const string plannerQuery = """ 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 0924eb4afaa..0519917dc96 100644 --- a/src/HotChocolate/Utilities/src/Utilities.Buffers/HotChocolate.Utilities.Buffers.csproj +++ b/src/HotChocolate/Utilities/src/Utilities.Buffers/HotChocolate.Utilities.Buffers.csproj @@ -26,6 +26,7 @@ +