diff --git a/composition/src/errors/errors.ts b/composition/src/errors/errors.ts index cdbc701cf1..51a73d1961 100644 --- a/composition/src/errors/errors.ts +++ b/composition/src/errors/errors.ts @@ -56,6 +56,43 @@ import { type TypeName, } from '../types/types'; +// @composeDirective(name: "myDirective") — missing @ prefix +export function composeDirectiveNameMissingAtPrefixError(name: string): Error { + return new Error( + `The name argument of "@composeDirective" must start with "@", but received "${name}".` + ); +} + +// @composeDirective(name: "@unknownDirective") — directive not defined +export function undefinedComposeDirectiveNameError(name: string): Error { + return new Error( + `The directive "@${name}" declared in "@composeDirective" is not defined in this subgraph.` + ); +} + +// @composeDirective(name: "@key") — built-in federation directive +export function composeDirectiveBuiltInError(name: string): Error { + return new Error( + `The directive "@${name}" is a built-in federation directive and cannot be used with "@composeDirective".` + ); +} + +// @composeDirective — two subgraphs define the directive with disjoint location sets +export function composeDirectiveNoMutualLocationsError(name: string, subgraphNames: Set): Error { + return new Error( + `The composed directive "@${name}" has no mutually supported locations across subgraphs: [${[...subgraphNames].join(', ')}].` + + ` All subgraphs that define a composed directive must share at least one location.`, + ); +} + +// @composeDirective with conflicting repeatable declarations across subgraphs +export function composeDirectiveRepeatableConflictError(name: string, subgraphNames: Set): Error { + return new Error( + `The composed directive "@${name}" has conflicting "repeatable" declarations across subgraphs: [${[...subgraphNames].join(', ')}].` + + ` All subgraphs that define a composed directive must agree on whether it is repeatable.`, + ); +} + export const minimumSubgraphRequirementError = new Error('At least one subgraph is required for federation.'); export function multipleNamedTypeDefinitionError( diff --git a/composition/src/normalization/types.ts b/composition/src/normalization/types.ts index 3e598440ba..b1717a7265 100644 --- a/composition/src/normalization/types.ts +++ b/composition/src/normalization/types.ts @@ -28,6 +28,7 @@ export type NormalizationFailure = { export type NormalizationSuccess = { authorizationDataByParentTypeName: Map; + composedDirectiveDefinitionDataByDirectiveName: Map; concreteTypeNamesByAbstractTypeName: Map>; conditionalFieldDataByCoordinates: Map; configurationDataByTypeName: Map; diff --git a/composition/src/schema-building/types.ts b/composition/src/schema-building/types.ts index 7861f0e540..2fde38dc70 100644 --- a/composition/src/schema-building/types.ts +++ b/composition/src/schema-building/types.ts @@ -193,6 +193,7 @@ export type ObjectDefinitionData = { export type PersistedDirectiveDefinitionData = { argumentDataByName: Map; executableLocations: Set; + locations?: Set; name: DirectiveName; repeatable: boolean; subgraphNames: Set; diff --git a/composition/src/schema-building/utils.ts b/composition/src/schema-building/utils.ts index 82ed3be440..64e23b43dd 100644 --- a/composition/src/schema-building/utils.ts +++ b/composition/src/schema-building/utils.ts @@ -37,7 +37,12 @@ import { type PersistedDirectivesData, type SchemaData, } from './types'; -import { type MutableDefinitionNode, type MutableFieldNode, type MutableInputValueNode } from './ast'; +import { + type MutableDefinitionNode, + type MutableDirectiveDefinitionNode, + type MutableFieldNode, + type MutableInputValueNode, +} from './ast'; import { type ObjectTypeNode, setToNameNodeArray, stringToNameNode } from '../ast/utils'; import { incompatibleInputValueDefaultValuesError, @@ -439,7 +444,10 @@ function getRouterPersistedDirectiveNodes( return persistedDirectiveNodes; } -export function getClientPersistedDirectiveNodes(nodeData: T): ConstDirectiveNode[] { +export function getClientPersistedDirectiveNodes( + nodeData: T, + composedDirectiveNames?: ReadonlySet, +): ConstDirectiveNode[] { const persistedDirectiveNodes: Array = []; if (nodeData.persistedDirectivesData.isDeprecated) { persistedDirectiveNodes.push(generateDeprecatedDirective(nodeData.persistedDirectivesData.deprecatedReason)); @@ -451,6 +459,11 @@ export function getClientPersistedDirectiveNodes(nodeData: T ); continue; } + if (composedDirectiveNames?.has(directiveName)) { + // Composed directives may be repeatable; all validated nodes are safe to emit. + persistedDirectiveNodes.push(...directiveNodes); + continue; + } // Only include @deprecated, @oneOf, and @semanticNonNull in the client schema. if (!PERSISTED_CLIENT_DIRECTIVES.has(directiveName)) { continue; @@ -463,8 +476,11 @@ export function getClientPersistedDirectiveNodes(nodeData: T return persistedDirectiveNodes; } -export function getClientSchemaFieldNodeByFieldData(fieldData: FieldData): MutableFieldNode { - const directives = getClientPersistedDirectiveNodes(fieldData); +export function getClientSchemaFieldNodeByFieldData( + fieldData: FieldData, + composedDirectiveNames?: ReadonlySet, +): MutableFieldNode { + const directives = getClientPersistedDirectiveNodes(fieldData, composedDirectiveNames); const argumentNodes: MutableInputValueNode[] = []; for (const inputValueData of fieldData.argumentDataByName.values()) { if (isNodeDataInaccessible(inputValueData)) { @@ -472,7 +488,7 @@ export function getClientSchemaFieldNodeByFieldData(fieldData: FieldData): Mutab } argumentNodes.push({ ...inputValueData.node, - directives: getClientPersistedDirectiveNodes(inputValueData), + directives: getClientPersistedDirectiveNodes(inputValueData, composedDirectiveNames), }); } return { @@ -540,24 +556,35 @@ function addValidatedArgumentNodes( return true; } -export function addValidPersistedDirectiveDefinitionNodeByData( - definitions: (MutableDefinitionNode | DefinitionNode)[], +export function buildValidPersistedDirectiveDefinitionNode( data: PersistedDirectiveDefinitionData, persistedDirectiveDefinitionByDirectiveName: Map, errors: Error[], -) { +): MutableDirectiveDefinitionNode | undefined { const argumentNodes: MutableInputValueNode[] = []; if (!addValidatedArgumentNodes(argumentNodes, data, persistedDirectiveDefinitionByDirectiveName, errors)) { - return; + return undefined; } - definitions.push({ + return { arguments: argumentNodes, kind: Kind.DIRECTIVE_DEFINITION, - locations: setToNameNodeArray(data.executableLocations), + locations: setToNameNodeArray(data.locations ?? data.executableLocations), name: stringToNameNode(data.name), repeatable: data.repeatable, description: data.description, - }); + }; +} + +export function addValidPersistedDirectiveDefinitionNodeByData( + definitions: (MutableDefinitionNode | DefinitionNode)[], + data: PersistedDirectiveDefinitionData, + persistedDirectiveDefinitionByDirectiveName: Map, + errors: Error[], +) { + const node = buildValidPersistedDirectiveDefinitionNode(data, persistedDirectiveDefinitionByDirectiveName, errors); + if (node) { + definitions.push(node); + } } type InvalidFieldNames = { diff --git a/composition/src/subgraph/types.ts b/composition/src/subgraph/types.ts index 768916ab41..060d9382a3 100644 --- a/composition/src/subgraph/types.ts +++ b/composition/src/subgraph/types.ts @@ -34,6 +34,7 @@ export type SubgraphConfig = { }; export type InternalSubgraph = { + composedDirectiveDefinitionDataByDirectiveName: Map; conditionalFieldDataByCoordinates: Map; configurationDataByTypeName: Map; definitions: DocumentNode; diff --git a/composition/src/v1/constants/directive-definitions.ts b/composition/src/v1/constants/directive-definitions.ts index 56429b38e6..eb195fb866 100644 --- a/composition/src/v1/constants/directive-definitions.ts +++ b/composition/src/v1/constants/directive-definitions.ts @@ -101,8 +101,7 @@ export const AUTHENTICATED_DEFINITION: DirectiveDefinitionNode = { repeatable: false, }; -// @composeDirective is currently unimplemented -/* directive @composeDirective(name: String!) repeatable on SCHEMA */ +// directive @composeDirective(name: String!) repeatable on SCHEMA export const COMPOSE_DIRECTIVE_DEFINITION: DirectiveDefinitionNode = { arguments: [ { diff --git a/composition/src/v1/federation/federation-factory.ts b/composition/src/v1/federation/federation-factory.ts index 6ddb524dc7..e580300288 100644 --- a/composition/src/v1/federation/federation-factory.ts +++ b/composition/src/v1/federation/federation-factory.ts @@ -71,6 +71,8 @@ import { undefinedTypeError, unexpectedNonCompositeOutputTypeError, unknownFieldDataError, + composeDirectiveNoMutualLocationsError, + composeDirectiveRepeatableConflictError, unknownFieldSubgraphNameError, unknownNamedTypeError, } from '../../errors/errors'; @@ -138,6 +140,7 @@ import { } from '../../schema-building/types'; import { addValidPersistedDirectiveDefinitionNodeByData, + buildValidPersistedDirectiveDefinitionNode, areKindsEqual, compareAndValidateInputValueDefaultValues, generateDeprecatedDirective, @@ -298,6 +301,7 @@ export class FederationFactory { [SEMANTIC_NON_NULL, SEMANTIC_NON_NULL_DEFINITION], [TAG, TAG_DEFINITION], ]); + composedDirectiveDefinitionDataByDirectiveName = new Map(); potentialPersistedDirectiveDefinitionDataByDirectiveName = new Map(); referencedPersistedDirectiveNames = new Set(); routerDefinitions: Array = []; @@ -1622,6 +1626,70 @@ export class FederationFactory { * This method is always necessary, regardless of whether federating a source graph or contract graph. * */ federateInternalSubgraphData() { + // Pre-pass: register composed directives before any type/field upserts so that + // extractPersistedDirectives can find them when walking type/field nodes. + const rejectedComposedDirectiveNames = new Set(); + for (const internalSubgraph of this.internalSubgraphBySubgraphName.values()) { + for (const [directiveName, data] of internalSubgraph.composedDirectiveDefinitionDataByDirectiveName) { + if (rejectedComposedDirectiveNames.has(directiveName)) { + continue; + } + if (!this.persistedDirectiveDefinitionByDirectiveName.has(directiveName)) { + const node = internalSubgraph.directiveDefinitionByName.get(directiveName); + if (node) { + this.persistedDirectiveDefinitionByDirectiveName.set(directiveName, node); + } + } + const existing = this.composedDirectiveDefinitionDataByDirectiveName.get(directiveName); + if (!existing) { + const argumentDataByName = new Map(); + for (const inputValueData of data.argumentDataByName.values()) { + this.namedInputValueTypeNames.add(getTypeNodeNamedTypeName(inputValueData.type)); + this.upsertInputValueData(argumentDataByName, inputValueData, `@${directiveName}`, false); + } + this.composedDirectiveDefinitionDataByDirectiveName.set(directiveName, { + argumentDataByName, + executableLocations: new Set(data.executableLocations), + locations: data.locations ? new Set(data.locations) : undefined, + name: data.name, + repeatable: data.repeatable, + subgraphNames: new Set(data.subgraphNames), + description: data.description, + }); + } else { + // Intersect locations so only mutually supported locations are emitted + if (existing.locations && data.locations) { + for (const loc of existing.locations) { + if (!data.locations.has(loc)) { + existing.locations.delete(loc); + } + } + } + setMutualExecutableLocations(existing, data.executableLocations); + // Reject if no locations remain after intersection + const effectiveLocationsEmpty = + existing.locations !== undefined + ? existing.locations.size === 0 + : existing.executableLocations.size === 0; + if (effectiveLocationsEmpty) { + addIterableToSet({ source: data.subgraphNames, target: existing.subgraphNames }); + this.errors.push(composeDirectiveNoMutualLocationsError(directiveName, existing.subgraphNames)); + this.composedDirectiveDefinitionDataByDirectiveName.delete(directiveName); + rejectedComposedDirectiveNames.add(directiveName); + continue; + } + for (const inputValueData of data.argumentDataByName.values()) { + this.namedInputValueTypeNames.add(getTypeNodeNamedTypeName(inputValueData.type)); + this.upsertInputValueData(existing.argumentDataByName, inputValueData, `@${directiveName}`, false); + } + setLongestDescription(existing, data); + addIterableToSet({ source: data.subgraphNames, target: existing.subgraphNames }); + if (existing.repeatable !== data.repeatable) { + this.errors.push(composeDirectiveRepeatableConflictError(directiveName, existing.subgraphNames)); + } + } + } + } let subgraphNumber = 0; let shouldSkipPersistedExecutableDirectives = false; for (const internalSubgraph of this.internalSubgraphBySubgraphName.values()) { @@ -1993,6 +2061,7 @@ export class FederationFactory { } pushParentDefinitionDataToDocumentDefinitions(interfaceImplementations: InterfaceImplementationData[]) { + const composedDirectiveNames = new Set(this.composedDirectiveDefinitionDataByDirectiveName.keys()); for (const [parentTypeName, parentDefinitionData] of this.parentDefinitionDataByTypeName) { if (parentDefinitionData.extensionType !== ExtensionType.NONE) { this.errors.push(noBaseDefinitionForExtensionError(kindToNodeType(parentDefinitionData.kind), parentTypeName)); @@ -2011,7 +2080,7 @@ export class FederationFactory { const isValueInaccessible = isNodeDataInaccessible(enumValueData); const clientEnumValueNode: MutableEnumValueNode = { ...enumValueData.node, - directives: getClientPersistedDirectiveNodes(enumValueData), + directives: getClientPersistedDirectiveNodes(enumValueData, composedDirectiveNames), }; switch (mergeMethod) { case MergeMethod.CONSISTENT: @@ -2058,7 +2127,7 @@ export class FederationFactory { } this.clientDefinitions.push({ ...parentDefinitionData.node, - directives: getClientPersistedDirectiveNodes(parentDefinitionData), + directives: getClientPersistedDirectiveNodes(parentDefinitionData, composedDirectiveNames), values: clientEnumValueNodes, }); break; @@ -2082,7 +2151,7 @@ export class FederationFactory { } clientInputValueNodes.push({ ...inputValueData.node, - directives: getClientPersistedDirectiveNodes(inputValueData), + directives: getClientPersistedDirectiveNodes(inputValueData, composedDirectiveNames), }); } else if (isTypeRequired(inputValueData.type)) { invalidRequiredInputs.push({ @@ -2128,7 +2197,7 @@ export class FederationFactory { } this.clientDefinitions.push({ ...parentDefinitionData.node, - directives: getClientPersistedDirectiveNodes(parentDefinitionData), + directives: getClientPersistedDirectiveNodes(parentDefinitionData, composedDirectiveNames), fields: clientInputValueNodes, }); break; @@ -2154,7 +2223,7 @@ export class FederationFactory { if (isNodeDataInaccessible(fieldData)) { continue; } - clientSchemaFieldNodes.push(getClientSchemaFieldNodeByFieldData(fieldData)); + clientSchemaFieldNodes.push(getClientSchemaFieldNodeByFieldData(fieldData, composedDirectiveNames)); graphFieldDataByFieldName.set(fieldName, this.fieldDataToGraphFieldData(fieldData)); } if (isObject) { @@ -2198,7 +2267,7 @@ export class FederationFactory { } this.clientDefinitions.push({ ...parentDefinitionData.node, - directives: getClientPersistedDirectiveNodes(parentDefinitionData), + directives: getClientPersistedDirectiveNodes(parentDefinitionData, composedDirectiveNames), fields: clientSchemaFieldNodes, }); break; @@ -2216,7 +2285,7 @@ export class FederationFactory { } this.clientDefinitions.push({ ...parentDefinitionData.node, - directives: getClientPersistedDirectiveNodes(parentDefinitionData), + directives: getClientPersistedDirectiveNodes(parentDefinitionData, composedDirectiveNames), }); break; } @@ -2235,7 +2304,7 @@ export class FederationFactory { } this.clientDefinitions.push({ ...parentDefinitionData.node, - directives: getClientPersistedDirectiveNodes(parentDefinitionData), + directives: getClientPersistedDirectiveNodes(parentDefinitionData, composedDirectiveNames), types: clientMembers, }); break; @@ -2303,6 +2372,7 @@ export class FederationFactory { validateInterfaceImplementationsAndPushToDocumentDefinitions( interfaceImplementations: InterfaceImplementationData[], ) { + const composedDirectiveNames = new Set(this.composedDirectiveDefinitionDataByDirectiveName.keys()); for (const { data, clientSchemaFieldNodes } of interfaceImplementations) { data.node.interfaces = this.getValidImplementedInterfaces(data); this.routerDefinitions.push(this.getNodeForRouterSchemaByData(data)); @@ -2323,7 +2393,7 @@ export class FederationFactory { * */ this.clientDefinitions.push({ ...data.node, - directives: getClientPersistedDirectiveNodes(data), + directives: getClientPersistedDirectiveNodes(data, composedDirectiveNames), fields: clientSchemaFieldNodes, interfaces: clientInterfaces, }); @@ -2859,6 +2929,17 @@ export class FederationFactory { this.errors, ); } + for (const data of this.composedDirectiveDefinitionDataByDirectiveName.values()) { + const node = buildValidPersistedDirectiveDefinitionNode( + data, + this.persistedDirectiveDefinitionByDirectiveName, + this.errors, + ); + if (node) { + this.routerDefinitions.push(node); + this.clientDefinitions.push(node); + } + } const definitionsWithInterfaces: InterfaceImplementationData[] = []; this.pushParentDefinitionDataToDocumentDefinitions(definitionsWithInterfaces); this.validateInterfaceImplementationsAndPushToDocumentDefinitions(definitionsWithInterfaces); @@ -3182,6 +3263,17 @@ export class FederationFactory { this.errors, ); } + for (const data of this.composedDirectiveDefinitionDataByDirectiveName.values()) { + const node = buildValidPersistedDirectiveDefinitionNode( + data, + this.persistedDirectiveDefinitionByDirectiveName, + this.errors, + ); + if (node) { + this.routerDefinitions.push(node); + this.clientDefinitions.push(node); + } + } const interfaceImplementations: InterfaceImplementationData[] = []; this.pushParentDefinitionDataToDocumentDefinitions(interfaceImplementations); this.validateInterfaceImplementationsAndPushToDocumentDefinitions(interfaceImplementations); diff --git a/composition/src/v1/normalization/normalization-factory.ts b/composition/src/v1/normalization/normalization-factory.ts index f4cca59232..5ccaca14d0 100644 --- a/composition/src/v1/normalization/normalization-factory.ts +++ b/composition/src/v1/normalization/normalization-factory.ts @@ -75,6 +75,8 @@ import { upsertEntityData, } from '../utils/utils'; import { + composeDirectiveBuiltInError, + composeDirectiveNameMissingAtPrefixError, configureDescriptionNoDescriptionError, costOnInterfaceFieldErrorMessage, duplicateArgumentsError, @@ -164,6 +166,7 @@ import { subgraphValidationError, subgraphValidationFailureError, typeNameAlreadyProvidedErrorMessage, + undefinedComposeDirectiveNameError, undefinedCompositeOutputTypeError, undefinedDirectiveError, undefinedFieldInFieldSetErrorMessage, @@ -276,6 +279,7 @@ import { BOOLEAN_SCALAR, CHANNEL, CHANNELS, + COMPOSE_DIRECTIVE, CONFIGURE_DESCRIPTION, CONSUMER_INACTIVE_THRESHOLD, CONSUMER_NAME, @@ -312,6 +316,7 @@ import { LINK_PURPOSE, LIST_SIZE, MUTATION, + NAME, NON_NULLABLE_BOOLEAN, NON_NULLABLE_EDFS_PUBLISH_EVENT_RESULT, NON_NULLABLE_INT, @@ -3862,6 +3867,60 @@ export class NormalizationFactory { definitions.push(...dependencies); } + extractComposedDirectiveDefinitionData(): Map { + const composedDirectiveDefinitionDataByDirectiveName = new Map(); + const composeDirectiveNodes = this.schemaData.directivesByName.get(COMPOSE_DIRECTIVE); + if (!composeDirectiveNodes || composeDirectiveNodes.length === 0) { + return composedDirectiveDefinitionDataByDirectiveName; + } + // NOTE: No v1 check needed here — @composeDirective is in V2_DIRECTIVE_DEFINITION_BY_DIRECTIVE_NAME, + // so using it automatically sets isSubgraphVersionTwo = true during directive definition processing. + for (const node of composeDirectiveNodes) { + const nameArg = node.arguments?.find(a => a.name.value === NAME); + if (!nameArg || nameArg.value.kind !== Kind.STRING) { + continue; // already caught by validateDirectives + } + const rawName = nameArg.value.value; // e.g. "@myDirective" + if (!rawName.startsWith('@')) { + this.errors.push(composeDirectiveNameMissingAtPrefixError(rawName)); + continue; + } + const directiveName = rawName.slice(1); + // Check built-ins first so the error message is maximally helpful. + if (V2_DIRECTIVE_DEFINITION_BY_DIRECTIVE_NAME.has(directiveName) || DIRECTIVE_DEFINITION_BY_NAME.has(directiveName)) { + this.errors.push(composeDirectiveBuiltInError(directiveName)); + continue; + } + const directiveDefinitionNode = this.directiveDefinitionByName.get(directiveName); + if (!directiveDefinitionNode) { + this.errors.push(undefinedComposeDirectiveNameError(directiveName)); + continue; + } + if (composedDirectiveDefinitionDataByDirectiveName.has(directiveName)) { + continue; // repeated @composeDirective for the same directive + } + // Separate executable locations from the full location set. + // executableLocations is used for argument validation; locations carries all locations + // (including type-system ones like OBJECT, FIELD_DEFINITION) for the emitted definition. + const executableLocations = extractExecutableDirectiveLocations( + directiveDefinitionNode.locations, + new Set(), + ); + const allLocations = new Set(); + for (const locationNode of directiveDefinitionNode.locations) { + allLocations.add(locationNode.value); + } + this.addPersistedDirectiveDefinitionDataByNode( + composedDirectiveDefinitionDataByDirectiveName, + directiveDefinitionNode, + executableLocations, + ); + const data = composedDirectiveDefinitionDataByDirectiveName.get(directiveName)!; + data.locations = allLocations; + } + return composedDirectiveDefinitionDataByDirectiveName; + } + normalize(document: DocumentNode): NormalizationResult { // Collect any renamed root types upsertDirectiveSchemaAndEntityDefinitions(this, document); @@ -3869,6 +3928,7 @@ export class NormalizationFactory { const definitions: Array = []; this.#addDirectiveDefinitionsToDocument(definitions); this.validateDirectives(this.schemaData, SCHEMA); + const composedDirectiveDefinitionDataByDirectiveName = this.extractComposedDirectiveDefinitionData(); const schemaNode = this.getSchemaNodeByData(this.schemaData); /* Schema extension orphans are not supported on old routers. * Consequently, it is a breaking change that requires a new composition version, and that composition version @@ -4144,6 +4204,7 @@ export class NormalizationFactory { }; return { authorizationDataByParentTypeName: this.authorizationDataByParentTypeName, + composedDirectiveDefinitionDataByDirectiveName, // configurationDataMap is map of ConfigurationData per type name. // It is an Intermediate configuration object that will be converted to an engine configuration in the router concreteTypeNamesByAbstractTypeName: this.concreteTypeNamesByAbstractTypeName, @@ -4261,6 +4322,7 @@ export function batchNormalize({ options, subgraphs }: BatchNormalizeParams): Ba internalSubgraphBySubgraphName.set(subgraphName, { conditionalFieldDataByCoordinates: normalizationResult.conditionalFieldDataByCoordinates, configurationDataByTypeName: normalizationResult.configurationDataByTypeName, + composedDirectiveDefinitionDataByDirectiveName: normalizationResult.composedDirectiveDefinitionDataByDirectiveName, costs: normalizationResult.costs, definitions: normalizationResult.subgraphAST, directiveDefinitionByName: normalizationResult.directiveDefinitionByName, diff --git a/composition/tests/v1/directives/compose-directive.test.ts b/composition/tests/v1/directives/compose-directive.test.ts new file mode 100644 index 0000000000..2d574c1987 --- /dev/null +++ b/composition/tests/v1/directives/compose-directive.test.ts @@ -0,0 +1,440 @@ +import { + composeDirectiveBuiltInError, + composeDirectiveNameMissingAtPrefixError, + composeDirectiveNoMutualLocationsError, + composeDirectiveRepeatableConflictError, + parse, + ROUTER_COMPATIBILITY_VERSION_ONE, + type Subgraph, + undefinedComposeDirectiveNameError, +} from '../../../src'; +import { describe, expect, test } from 'vitest'; +import { SCHEMA_QUERY_DEFINITION } from '../utils/utils'; +import { + federateSubgraphsSuccess, + federateSubgraphsFailure, + normalizeString, + normalizeSubgraphFailure, + schemaToSortedNormalizedString, +} from '../../utils/utils'; + +describe('@composeDirective tests', () => { + test('that a composed directive definition and its usages appear in the router schema', () => { + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithComposedDirective], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + ` + directive @myDirective(reason: String!) on FIELD_DEFINITION | OBJECT + + type Product { + id: ID! + name: String! @myDirective(reason: "field-cached") + } + + type Query { + product: Product + } + `, + ), + ); + }); + + test('that a composed directive definition and its usages appear in the client schema', () => { + const { federatedGraphClientSchema } = federateSubgraphsSuccess( + [subgraphWithComposedDirective], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(schemaToSortedNormalizedString(federatedGraphClientSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + ` + directive @myDirective(reason: String!) on FIELD_DEFINITION | OBJECT + + type Product { + id: ID! + name: String! @myDirective(reason: "field-cached") + } + + type Query { + product: Product + } + `, + ), + ); + }); + + test('that object-level composed directive usages appear in both schemas', () => { + const { federatedGraphSchema, federatedGraphClientSchema } = federateSubgraphsSuccess( + [subgraphWithObjectLevelDirective], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + const expected = normalizeString( + SCHEMA_QUERY_DEFINITION + + ` + directive @myDirective(reason: String!) on FIELD_DEFINITION | OBJECT + + type Product @myDirective(reason: "cached") { + id: ID! + } + + type Query { + product: Product + } + `, + ); + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toBe(expected); + expect(schemaToSortedNormalizedString(federatedGraphClientSchema)).toBe( + normalizeString( + SCHEMA_QUERY_DEFINITION + + ` + directive @myDirective(reason: String!) on FIELD_DEFINITION | OBJECT + + type Product @myDirective(reason: "cached") { + id: ID! + } + + type Query { + product: Product + } + `, + ), + ); + }); + + test('that a composed directive from only one of two subgraphs still appears in the supergraph', () => { + const { federatedGraphSchema, federatedGraphClientSchema } = federateSubgraphsSuccess( + [subgraphWithComposedDirective, subgraphWithoutComposedDirective], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + const expectedDirective = `directive @myDirective(reason: String!) on FIELD_DEFINITION | OBJECT`; + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toContain(expectedDirective); + expect(schemaToSortedNormalizedString(federatedGraphClientSchema)).toContain(expectedDirective); + }); + + test('that a composed directive used in two subgraphs is merged into a single definition', () => { + const { federatedGraphSchema, federatedGraphClientSchema } = federateSubgraphsSuccess( + [subgraphAWithSharedDirective, subgraphBWithSharedDirective], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + // definition should appear exactly once + const routerSDL = schemaToSortedNormalizedString(federatedGraphSchema); + const clientSDL = schemaToSortedNormalizedString(federatedGraphClientSchema); + const occurrencesInRouter = (routerSDL.match(/directive @sharedDirective/g) ?? []).length; + const occurrencesInClient = (clientSDL.match(/directive @sharedDirective/g) ?? []).length; + expect(occurrencesInRouter).toBe(1); + expect(occurrencesInClient).toBe(1); + }); + + test('that a repeatable composed directive is correctly emitted', () => { + const { federatedGraphSchema, federatedGraphClientSchema } = federateSubgraphsSuccess( + [subgraphWithRepeatableDirective], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + const expected = `directive @tag2(name: String!) repeatable on FIELD_DEFINITION | OBJECT`; + expect(schemaToSortedNormalizedString(federatedGraphSchema)).toContain(expected); + expect(schemaToSortedNormalizedString(federatedGraphClientSchema)).toContain(expected); + }); + + test('that @composeDirective(name: "missingAt") returns an error during normalization', () => { + const { errors } = normalizeSubgraphFailure(subgraphMissingAtPrefix, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual(composeDirectiveNameMissingAtPrefixError('myDirective')); + }); + + test('that @composeDirective(name: "@notDefined") returns an error during normalization', () => { + const { errors } = normalizeSubgraphFailure(subgraphUndefinedDirective, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual(undefinedComposeDirectiveNameError('notDefined')); + }); + + test('that @composeDirective(name: "@key") returns an error during normalization', () => { + const { errors } = normalizeSubgraphFailure(subgraphBuiltInDirective, ROUTER_COMPATIBILITY_VERSION_ONE); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual(composeDirectiveBuiltInError('key')); + }); + + test('that @composeDirective with a repeated name is deduplicated', () => { + // Two @composeDirective(name: "@myDirective") on the same schema should not cause an error + // and the definition should appear exactly once. + const { federatedGraphSchema } = federateSubgraphsSuccess( + [subgraphWithRepeatedComposeDirective], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + const sdl = schemaToSortedNormalizedString(federatedGraphSchema); + const occurrences = (sdl.match(/directive @myDirective/g) ?? []).length; + expect(occurrences).toBe(1); + }); + + test('that conflicting repeatable declarations for a composed directive return a composition error', () => { + const { errors } = federateSubgraphsFailure( + [subgraphRepeatableA, subgraphRepeatableB], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + composeDirectiveRepeatableConflictError('myDirective', new Set(['subgraph-a', 'subgraph-b'])), + ); + }); + + test('that a composed directive with disjoint locations across subgraphs returns a composition error', () => { + const { errors } = federateSubgraphsFailure( + [subgraphDirectiveOnFieldDefinition, subgraphDirectiveOnObject], + ROUTER_COMPATIBILITY_VERSION_ONE, + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toStrictEqual( + composeDirectiveNoMutualLocationsError('myDirective', new Set(['subgraph-a', 'subgraph-b'])), + ); + }); +}); + +// ── Subgraph fixtures ────────────────────────────────────────────────────────── + +const subgraphWithComposedDirective: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@myDirective") + + directive @myDirective(reason: String!) on OBJECT | FIELD_DEFINITION + + type Query { + product: Product + } + + type Product { + id: ID! + name: String! @myDirective(reason: "field-cached") + } + `), +}; + +const subgraphWithObjectLevelDirective: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@myDirective") + + directive @myDirective(reason: String!) on OBJECT | FIELD_DEFINITION + + type Query { + product: Product + } + + type Product @myDirective(reason: "cached") { + id: ID! + } + `), +}; + +const subgraphWithoutComposedDirective: Subgraph = { + name: 'subgraph-b', + url: '', + definitions: parse(` + type Query { + user: User + } + + type User { + id: ID! + } + `), +}; + +const subgraphAWithSharedDirective: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@sharedDirective") + + directive @sharedDirective on FIELD_DEFINITION + + type Query { + product: Product + } + + type Product @shareable { + id: ID! @sharedDirective + } + `), +}; + +const subgraphBWithSharedDirective: Subgraph = { + name: 'subgraph-b', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@sharedDirective") + + directive @sharedDirective on FIELD_DEFINITION + + type Query { + user: User + } + + type Product @shareable { + id: ID! + } + + type User { + id: ID! @sharedDirective + } + `), +}; + +const subgraphWithRepeatableDirective: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@tag2") + + directive @tag2(name: String!) repeatable on OBJECT | FIELD_DEFINITION + + type Query { + product: Product + } + + type Product { + id: ID! @tag2(name: "one") @tag2(name: "two") + } + `), +}; + +const subgraphMissingAtPrefix: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "myDirective") + + directive @myDirective on FIELD_DEFINITION + + type Query { + dummy: String + } + `), +}; + +const subgraphUndefinedDirective: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@notDefined") + + type Query { + dummy: String + } + `), +}; + +const subgraphBuiltInDirective: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@key") + + type Query { + dummy: String + } + `), +}; + +const subgraphWithRepeatedComposeDirective: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema + @composeDirective(name: "@myDirective") + @composeDirective(name: "@myDirective") + + directive @myDirective(reason: String!) on FIELD_DEFINITION + + type Query { + dummy: String @myDirective(reason: "test") + } + `), +}; + +// subgraph-a declares @myDirective as repeatable; subgraph-b does not +const subgraphRepeatableA: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@myDirective") + + directive @myDirective(reason: String!) repeatable on FIELD_DEFINITION + + type Query { + product: Product + } + + type Product @shareable { + id: ID! @myDirective(reason: "a") @myDirective(reason: "b") + } + `), +}; + +const subgraphRepeatableB: Subgraph = { + name: 'subgraph-b', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@myDirective") + + directive @myDirective(reason: String!) on FIELD_DEFINITION + + type Query { + user: User + } + + type Product @shareable { + id: ID! + } + + type User { + id: ID! + } + `), +}; + +// subgraph-a declares @myDirective on FIELD_DEFINITION; subgraph-b declares it on OBJECT only — disjoint +const subgraphDirectiveOnFieldDefinition: Subgraph = { + name: 'subgraph-a', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@myDirective") + + directive @myDirective(reason: String!) on FIELD_DEFINITION + + type Query { + product: Product + } + + type Product @shareable { + id: ID! @myDirective(reason: "a") + } + `), +}; + +const subgraphDirectiveOnObject: Subgraph = { + name: 'subgraph-b', + url: '', + definitions: parse(` + extend schema @composeDirective(name: "@myDirective") + + directive @myDirective(reason: String!) on OBJECT + + type Query { + user: User + } + + type Product @shareable { + id: ID! + } + + type User { + id: ID! + } + `), +};