From 74581cf399472428a4d0f15c0892d6377db29d47 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 12 Jul 2021 20:58:05 +0300 Subject: [PATCH] feat: support Gatsby-style directives in extensions (#3185) * feat: support Gatsby-style directives in extensions BREAKING CHANGE: getDirectives now always return an array of DirectiveAnnotation objects New function getDirective returns an array of args records for each use of the directive. Note: this is true even when the directive is non-repeatable. This is because one use of this function is to throw an error if more than one directive annotation is used for a non repeatable directive! * add changeset --- .changeset/quick-hotels-beam.md | 16 ++ .../computedDirectiveTransformer.ts | 6 +- .../stitch/tests/mergeDefinitions.test.ts | 38 +-- .../tests/typeMergingWithExtensions.test.ts | 17 +- .../src/stitchingDirectivesTransformer.ts | 148 +++++++---- .../src/stitchingDirectivesValidator.ts | 40 +-- packages/utils/src/get-directives.ts | 117 ++++++++- packages/utils/tests/get-directives.spec.ts | 62 +++-- packages/utils/tests/schemaTransforms.test.ts | 125 ++++----- .../RemoveObjectFieldsWithDirective.ts | 11 +- website/docs/schema-directives.md | 245 +++++++++--------- 11 files changed, 482 insertions(+), 343 deletions(-) create mode 100644 .changeset/quick-hotels-beam.md diff --git a/.changeset/quick-hotels-beam.md b/.changeset/quick-hotels-beam.md new file mode 100644 index 00000000000..f37aa1f8814 --- /dev/null +++ b/.changeset/quick-hotels-beam.md @@ -0,0 +1,16 @@ +--- +'@graphql-tools/stitch': major +'@graphql-tools/stitching-directives': major +'@graphql-tools/utils': major +'@graphql-tools/wrap': major +--- + +fix(getDirectives): preserve order around repeatable directives + +BREAKING CHANGE: getDirectives now always return an array of individual DirectiveAnnotation objects consisting of `name` and `args` properties. + +New useful function `getDirective` returns an array of objects representing any args for each use of a single directive (returning the empty object `{}` when a directive is used without arguments). + +Note: The `getDirective` function returns an array even when the specified directive is non-repeatable. This is because one use of this function is to throw an error if more than one directive annotation is used for a non repeatable directive! + +When specifying directives in extensions, one can use either the old or new format. diff --git a/packages/stitch/src/subschemaConfigTransforms/computedDirectiveTransformer.ts b/packages/stitch/src/subschemaConfigTransforms/computedDirectiveTransformer.ts index cb84a17ddf9..d39e097cc5a 100644 --- a/packages/stitch/src/subschemaConfigTransforms/computedDirectiveTransformer.ts +++ b/packages/stitch/src/subschemaConfigTransforms/computedDirectiveTransformer.ts @@ -1,4 +1,4 @@ -import { getDirectives, MapperKind, mapSchema } from '@graphql-tools/utils'; +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'; import { cloneSubschemaConfig, SubschemaConfig } from '@graphql-tools/delegate'; import { SubschemaConfigTransform } from '../types'; @@ -15,13 +15,13 @@ export function computedDirectiveTransformer(computedDirectiveName: string): Sub return undefined; } - const computed = getDirectives(schema, fieldConfig)[computedDirectiveName]; + const computed = getDirective(schema, fieldConfig, computedDirectiveName)?.[0]; if (computed == null) { return undefined; } - const selectionSet = computed.fields != null ? `{ ${computed.fields} }` : computed.selectionSet; + const selectionSet = computed['fields'] != null ? `{ ${computed['fields']} }` : computed['selectionSet']; if (selectionSet == null) { return undefined; diff --git a/packages/stitch/tests/mergeDefinitions.test.ts b/packages/stitch/tests/mergeDefinitions.test.ts index 17af8555698..655d91916b5 100644 --- a/packages/stitch/tests/mergeDefinitions.test.ts +++ b/packages/stitch/tests/mergeDefinitions.test.ts @@ -1,6 +1,6 @@ import { makeExecutableSchema } from '@graphql-tools/schema'; import { stitchSchemas } from '@graphql-tools/stitch'; -import { getDirectives } from '@graphql-tools/utils'; +import { getDirective } from '@graphql-tools/utils'; import { stitchingDirectives } from '@graphql-tools/stitching-directives'; import { GraphQLObjectType, @@ -282,22 +282,22 @@ describe('merge canonical types', () => { const scalarType = gatewaySchema.getType('ProductScalar'); assertGraphQLScalerType(scalarType) - expect(getDirectives(firstSchema, queryType.toConfig())['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, objectType.toConfig())['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, interfaceType.toConfig())['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, inputType.toConfig())['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, enumType.toConfig())['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, unionType.toConfig())['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, scalarType.toConfig())['mydir'].value).toEqual('first'); - - expect(getDirectives(firstSchema, queryType.getFields()['field1'])['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, queryType.getFields()['field2'])['mydir'].value).toEqual('second'); - expect(getDirectives(firstSchema, objectType.getFields()['id'])['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, objectType.getFields()['url'])['mydir'].value).toEqual('second'); - expect(getDirectives(firstSchema, interfaceType.getFields()['id'])['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, interfaceType.getFields()['url'])['mydir'].value).toEqual('second'); - expect(getDirectives(firstSchema, inputType.getFields()['id'])['mydir'].value).toEqual('first'); - expect(getDirectives(firstSchema, inputType.getFields()['url'])['mydir'].value).toEqual('second'); + expect(getDirective(firstSchema, queryType.toConfig(), 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, objectType.toConfig(), 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, interfaceType.toConfig(), 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, inputType.toConfig(), 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, enumType.toConfig(), 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, unionType.toConfig(), 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, scalarType.toConfig(), 'mydir')?.[0]['value']).toEqual('first'); + + expect(getDirective(firstSchema, queryType.getFields()['field1'], 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, queryType.getFields()['field2'], 'mydir')?.[0]['value']).toEqual('second'); + expect(getDirective(firstSchema, objectType.getFields()['id'], 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, objectType.getFields()['url'], 'mydir')?.[0]['value']).toEqual('second'); + expect(getDirective(firstSchema, interfaceType.getFields()['id'], 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, interfaceType.getFields()['url'], 'mydir')?.[0]['value']).toEqual('second'); + expect(getDirective(firstSchema, inputType.getFields()['id'], 'mydir')?.[0]['value']).toEqual('first'); + expect(getDirective(firstSchema, inputType.getFields()['url'], 'mydir')?.[0]['value']).toEqual('second'); expect(enumType.toConfig().astNode?.values?.map(v => v.description?.value)).toEqual(['first', 'first', 'second']); expect(enumType.toConfig().values['YES'].astNode?.description?.value).toEqual('first'); @@ -309,8 +309,8 @@ describe('merge canonical types', () => { const objectType = gatewaySchema.getType('Product') as GraphQLObjectType; expect(objectType.getFields()['id'].deprecationReason).toEqual('first'); expect(objectType.getFields()['url'].deprecationReason).toEqual('second'); - expect(getDirectives(firstSchema, objectType.getFields()['id'])['deprecated'].reason).toEqual('first'); - expect(getDirectives(firstSchema, objectType.getFields()['url'])['deprecated'].reason).toEqual('second'); + expect(getDirective(firstSchema, objectType.getFields()['id'], 'deprecated')?.[0]['reason']).toEqual('first'); + expect(getDirective(firstSchema, objectType.getFields()['url'], 'deprecated')?.[0]['reason']).toEqual('second'); }); it('promotes canonical root field definitions', async () => { diff --git a/packages/stitch/tests/typeMergingWithExtensions.test.ts b/packages/stitch/tests/typeMergingWithExtensions.test.ts index a84c33d130d..d11153cf6e6 100644 --- a/packages/stitch/tests/typeMergingWithExtensions.test.ts +++ b/packages/stitch/tests/typeMergingWithExtensions.test.ts @@ -52,9 +52,9 @@ describe('merging using type merging', () => { }, resolve: (_root, { keys }) => keys.map((key: Record) => users.find(u => u.id === key['id'])), extensions: { - directives: { - merge: {}, - }, + directives: [{ + name: 'merge', + }], }, } }), @@ -68,12 +68,13 @@ describe('merging using type merging', () => { username: { type: GraphQLString } }), extensions: { - directives: { - key: { + directives: [{ + name: 'key', + args: { selectionSet: '{ id }', - } - } - } + }, + }], + }, }); const accountsSchema = stitchingDirectivesValidator(new GraphQLSchema({ diff --git a/packages/stitching-directives/src/stitchingDirectivesTransformer.ts b/packages/stitching-directives/src/stitchingDirectivesTransformer.ts index 87094987316..c0cef3553ae 100644 --- a/packages/stitching-directives/src/stitchingDirectivesTransformer.ts +++ b/packages/stitching-directives/src/stitchingDirectivesTransformer.ts @@ -17,7 +17,7 @@ import { import { cloneSubschemaConfig, SubschemaConfig, MergedTypeConfig, MergedFieldConfig } from '@graphql-tools/delegate'; import { - getDirectives, + getDirective, getImplementingTypes, MapperKind, mapSchema, @@ -73,45 +73,48 @@ export function stitchingDirectivesTransformer( mapSchema(schema, { [MapperKind.OBJECT_TYPE]: type => { - const directives = getDirectives(schema, type, pathToDirectivesInExtensions); - - if (keyDirectiveName != null && directives[keyDirectiveName] != null) { - const keyDirective = directives[keyDirectiveName]; - const selectionSet = parseSelectionSet(keyDirective.selectionSet, { noLocation: true }); + const keyDirective = getDirective(schema, type, keyDirectiveName, pathToDirectivesInExtensions)?.[0]; + if (keyDirective != null) { + const selectionSet = parseSelectionSet(keyDirective['selectionSet'], { noLocation: true }); selectionSetsByType[type.name] = selectionSet; } - if (canonicalDirectiveName != null && directives[canonicalDirectiveName]) { + const canonicalDirective = getDirective( + schema, + type, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + if (canonicalDirective != null) { setCanonicalDefinition(type.name); } - return undefined; }, [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => { - const directives = getDirectives(schema, fieldConfig, pathToDirectivesInExtensions); - - if (computedDirectiveName != null && directives[computedDirectiveName] != null) { - const computedDirective = directives[computedDirectiveName]; - const selectionSet = parseSelectionSet(computedDirective.selectionSet, { noLocation: true }); + const computedDirective = getDirective( + schema, + fieldConfig, + computedDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + if (computedDirective != null) { + const selectionSet = parseSelectionSet(computedDirective['selectionSet'], { noLocation: true }); if (!computedFieldSelectionSets[typeName]) { computedFieldSelectionSets[typeName] = Object.create(null); } computedFieldSelectionSets[typeName][fieldName] = selectionSet; } - if ( - mergeDirectiveName != null && - directives[mergeDirectiveName] != null && - directives[mergeDirectiveName].keyField - ) { - const mergeDirectiveKeyField = directives[mergeDirectiveName].keyField; + const mergeDirective = getDirective(schema, fieldConfig, mergeDirectiveName, pathToDirectivesInExtensions)?.[0]; + if (mergeDirective?.['keyField'] != null) { + const mergeDirectiveKeyField = mergeDirective['keyField']; const selectionSet = parseSelectionSet(`{ ${mergeDirectiveKeyField}}`, { noLocation: true }); - const typeNames: Array = directives[mergeDirectiveName].types; + const typeNames: Array = mergeDirective['types']; const returnType = getNamedType(fieldConfig.type); - forEachConcreteType(schema, returnType, directives[mergeDirectiveName]?.types, typeName => { + forEachConcreteType(schema, returnType, typeNames, typeName => { if (typeNames == null || typeNames.includes(typeName)) { const existingSelectionSet = selectionSetsByType[typeName]; selectionSetsByType[typeName] = existingSelectionSet @@ -121,70 +124,111 @@ export function stitchingDirectivesTransformer( }); } - if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) { + const canonicalDirective = getDirective( + schema, + fieldConfig, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + if (canonicalDirective != null) { setCanonicalDefinition(typeName, fieldName); } return undefined; }, [MapperKind.INTERFACE_TYPE]: type => { - const directives = getDirectives(schema, type, pathToDirectivesInExtensions); - - if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) { + const canonicalDirective = getDirective( + schema, + type, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + + if (canonicalDirective) { setCanonicalDefinition(type.name); } return undefined; }, [MapperKind.INTERFACE_FIELD]: (fieldConfig, fieldName, typeName) => { - const directives = getDirectives(schema, fieldConfig, pathToDirectivesInExtensions); - - if (canonicalDirectiveName != null && directives[canonicalDirectiveName]) { + const canonicalDirective = getDirective( + schema, + fieldConfig, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + + if (canonicalDirective) { setCanonicalDefinition(typeName, fieldName); } return undefined; }, [MapperKind.INPUT_OBJECT_TYPE]: type => { - const directives = getDirectives(schema, type, pathToDirectivesInExtensions); - - if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) { + const canonicalDirective = getDirective( + schema, + type, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + + if (canonicalDirective) { setCanonicalDefinition(type.name); } return undefined; }, [MapperKind.INPUT_OBJECT_FIELD]: (inputFieldConfig, fieldName, typeName) => { - const directives = getDirectives(schema, inputFieldConfig, pathToDirectivesInExtensions); - - if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) { + const canonicalDirective = getDirective( + schema, + inputFieldConfig, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + + if (canonicalDirective != null) { setCanonicalDefinition(typeName, fieldName); } return undefined; }, [MapperKind.UNION_TYPE]: type => { - const directives = getDirectives(schema, type, pathToDirectivesInExtensions); - - if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) { + const canonicalDirective = getDirective( + schema, + type, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + + if (canonicalDirective != null) { setCanonicalDefinition(type.name); } return undefined; }, [MapperKind.ENUM_TYPE]: type => { - const directives = getDirectives(schema, type, pathToDirectivesInExtensions); - - if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) { + const canonicalDirective = getDirective( + schema, + type, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + + if (canonicalDirective != null) { setCanonicalDefinition(type.name); } return undefined; }, [MapperKind.SCALAR_TYPE]: type => { - const directives = getDirectives(schema, type, pathToDirectivesInExtensions); - - if (canonicalDirectiveName != null && directives[canonicalDirectiveName] != null) { + const canonicalDirective = getDirective( + schema, + type, + canonicalDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + + if (canonicalDirective != null) { setCanonicalDefinition(type.name); } @@ -248,23 +292,21 @@ export function stitchingDirectivesTransformer( mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName) => { - const directives = getDirectives(schema, fieldConfig, pathToDirectivesInExtensions); - - if (mergeDirectiveName != null && directives[mergeDirectiveName] != null) { - const directiveArgumentMap = directives[mergeDirectiveName]; + const mergeDirective = getDirective(schema, fieldConfig, mergeDirectiveName, pathToDirectivesInExtensions)?.[0]; + if (mergeDirective != null) { const returnType = getNullableType(fieldConfig.type); const returnsList = isListType(returnType); const namedType = getNamedType(returnType); - let mergeArgsExpr: string = directiveArgumentMap.argsExpr; + let mergeArgsExpr: string = mergeDirective['argsExpr']; if (mergeArgsExpr == null) { - const key: Array = directiveArgumentMap.key; - const keyField: string = directiveArgumentMap.keyField; + const key: Array = mergeDirective['key']; + const keyField: string = mergeDirective['keyField']; const keyExpr = key != null ? buildKeyExpr(key) : keyField != null ? `$key.${keyField}` : '$key'; - const keyArg: string = directiveArgumentMap.keyArg; + const keyArg: string = mergeDirective['keyArg']; const argNames = keyArg == null ? [Object.keys(fieldConfig.args ?? {})[0]] : keyArg.split('.'); const lastArgName = argNames.pop(); @@ -275,7 +317,7 @@ export function stitchingDirectivesTransformer( } } - const typeNames: Array = directiveArgumentMap.types; + const typeNames: Array = mergeDirective['types']; forEachConcreteTypeName(namedType, schema, typeNames, typeName => { const parsedMergeArgsExpr = parseMergeArgsExpr( @@ -285,7 +327,7 @@ export function stitchingDirectivesTransformer( : mergeSelectionSets(...allSelectionSetsByType[typeName]) ); - const additionalArgs = directiveArgumentMap.additionalArgs; + const additionalArgs = mergeDirective['additionalArgs']; if (additionalArgs != null) { parsedMergeArgsExpr.args = mergeDeep( parsedMergeArgsExpr.args, diff --git a/packages/stitching-directives/src/stitchingDirectivesValidator.ts b/packages/stitching-directives/src/stitchingDirectivesValidator.ts index 92ab3ddd9b5..daa3ea482cf 100644 --- a/packages/stitching-directives/src/stitchingDirectivesValidator.ts +++ b/packages/stitching-directives/src/stitchingDirectivesValidator.ts @@ -11,7 +11,7 @@ import { } from 'graphql'; import { - getDirectives, + getDirective, getImplementingTypes, isSome, MapperKind, @@ -39,26 +39,28 @@ export function stitchingDirectivesValidator( mapSchema(schema, { [MapperKind.OBJECT_TYPE]: type => { - const directives = getDirectives(schema, type, pathToDirectivesInExtensions); + const keyDirective = getDirective(schema, type, keyDirectiveName, pathToDirectivesInExtensions)?.[0]; - if (keyDirectiveName != null && directives[keyDirectiveName]) { - const directiveArgumentMap = directives[keyDirectiveName]; - parseSelectionSet(directiveArgumentMap.selectionSet); + if (keyDirective != null) { + parseSelectionSet(keyDirective['selectionSet']); } return undefined; }, [MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => { - const directives = getDirectives(schema, fieldConfig, pathToDirectivesInExtensions); - - if (computedDirectiveName != null && directives[computedDirectiveName]) { - const directiveArgumentMap = directives[computedDirectiveName]; - parseSelectionSet(directiveArgumentMap.selectionSet); + const computedDirective = getDirective( + schema, + fieldConfig, + computedDirectiveName, + pathToDirectivesInExtensions + )?.[0]; + + if (computedDirective != null) { + parseSelectionSet(computedDirective['selectionSet']); } - if (mergeDirectiveName != null && directives[mergeDirectiveName]) { - const directiveArgumentMap = directives[mergeDirectiveName]; - + const mergeDirective = getDirective(schema, fieldConfig, mergeDirectiveName, pathToDirectivesInExtensions)?.[0]; + if (mergeDirective != null) { if (typeName !== queryTypeName) { throw new Error('@merge directive may be used only for root fields of the root Query type.'); } @@ -73,14 +75,14 @@ export function stitchingDirectivesValidator( throw new Error('@merge directive must be used on a field that returns an object or a list of objects.'); } - const mergeArgsExpr = directiveArgumentMap.argsExpr; + const mergeArgsExpr = mergeDirective['argsExpr']; if (mergeArgsExpr != null) { parseMergeArgsExpr(mergeArgsExpr); } const args = Object.keys(fieldConfig.args ?? {}); - const keyArg = directiveArgumentMap.keyArg; + const keyArg = mergeDirective['keyArg']; if (keyArg == null) { if (!mergeArgsExpr && args.length !== 1) { throw new Error( @@ -94,7 +96,7 @@ export function stitchingDirectivesValidator( // TODO: ideally we should check that the arg exists for the resolver } - const keyField = directiveArgumentMap.keyArg; + const keyField = mergeDirective['keyField']; if (keyField != null && !keyField.match(dottedNameRegEx)) { throw new Error( '`keyField` argument for @merge directive must be a set of valid GraphQL SDL names separated by periods.' @@ -102,7 +104,7 @@ export function stitchingDirectivesValidator( // TODO: ideally we should check that it is part of the key } - const key: Array = directiveArgumentMap.key; + const key: Array = mergeDirective['key']; if (key != null) { if (keyField != null) { throw new Error('Cannot use @merge directive with both `keyField` and `key` arguments.'); @@ -132,7 +134,7 @@ export function stitchingDirectivesValidator( } } - const additionalArgs = directiveArgumentMap.additionalArgs; + const additionalArgs = mergeDirective['additionalArgs']; if (additionalArgs != null) { parseValue(`{ ${additionalArgs} }`, { noLocation: true }); } @@ -147,7 +149,7 @@ export function stitchingDirectivesValidator( ); } - const typeNames: Array = directiveArgumentMap.types; + const typeNames: Array = mergeDirective['types']; if (typeNames != null) { if (!isAbstractType(returnType)) { throw new Error('Types argument can only be used with a field that returns an abstract type.'); diff --git a/packages/utils/src/get-directives.ts b/packages/utils/src/get-directives.ts index d0afe8fb287..c3a4bdf6d4b 100644 --- a/packages/utils/src/get-directives.ts +++ b/packages/utils/src/get-directives.ts @@ -23,11 +23,13 @@ import { GraphQLEnumValueConfig, EnumValueDefinitionNode, } from 'graphql'; -import { Maybe } from '@graphql-tools/utils'; import { getArgumentValues } from './getArgumentValues'; -export type DirectiveUseMap = { [key: string]: any }; +export interface DirectiveAnnotation { + name: string; + args?: Record; +} type SchemaOrTypeNode = | SchemaDefinitionNode @@ -58,23 +60,70 @@ type DirectableGraphQLObject = export function getDirectivesInExtensions( node: DirectableGraphQLObject, pathToDirectivesInExtensions = ['directives'] -): Maybe { - const directivesInExtensions = pathToDirectivesInExtensions.reduce( +): Array { + return pathToDirectivesInExtensions.reduce( (acc, pathSegment) => (acc == null ? acc : acc[pathSegment]), node?.extensions + ) as Array; +} + +function _getDirectiveInExtensions( + directivesInExtensions: Array, + directiveName: string +): Array> | undefined { + const directiveInExtensions = directivesInExtensions.filter( + directiveAnnotation => directiveAnnotation.name === directiveName ); + if (!directiveInExtensions.length) { + return undefined; + } + + return directiveInExtensions.map(directive => directive.args ?? {}); +} + +export function getDirectiveInExtensions( + node: DirectableGraphQLObject, + directiveName: string, + pathToDirectivesInExtensions = ['directives'] +): Array> | undefined { + const directivesInExtensions = pathToDirectivesInExtensions.reduce( + (acc, pathSegment) => (acc == null ? acc : acc[pathSegment]), + node?.extensions + ) as Record | Array>> | Array | undefined; - return directivesInExtensions; + if (directivesInExtensions === undefined) { + return undefined; + } + + if (Array.isArray(directivesInExtensions)) { + return _getDirectiveInExtensions(directivesInExtensions, directiveName); + } + + // Support condensed format by converting to longer format + // The condensed format does not preserve ordering of directives when repeatable directives are used. + // See https://github.com/ardatan/graphql-tools/issues/2534 + const reformattedDirectivesInExtensions: Array = []; + for (const [name, argsOrArrayOfArgs] of Object.entries(directivesInExtensions)) { + if (Array.isArray(argsOrArrayOfArgs)) { + for (const args of argsOrArrayOfArgs) { + reformattedDirectivesInExtensions.push({ name, args }); + } + } else { + reformattedDirectivesInExtensions.push({ name, args: argsOrArrayOfArgs }); + } + } + + return _getDirectiveInExtensions(reformattedDirectivesInExtensions, directiveName); } export function getDirectives( schema: GraphQLSchema, node: DirectableGraphQLObject, pathToDirectivesInExtensions = ['directives'] -): DirectiveUseMap { +): Array { const directivesInExtensions = getDirectivesInExtensions(node, pathToDirectivesInExtensions); - if (directivesInExtensions != null) { + if (directivesInExtensions != null && directivesInExtensions.length > 0) { return directivesInExtensions; } @@ -94,23 +143,63 @@ export function getDirectives( astNodes = [...astNodes, ...node.extensionASTNodes]; } - const result: DirectiveUseMap = {}; + const result: Array = []; for (const astNode of astNodes) { if (astNode.directives) { for (const directiveNode of astNode.directives) { const schemaDirective = schemaDirectiveMap[directiveNode.name.value]; if (schemaDirective) { - if (schemaDirective.isRepeatable) { - result[schemaDirective.name] = result[schemaDirective.name] ?? []; - result[schemaDirective.name].push(getArgumentValues(schemaDirective, directiveNode)); - } else { - result[schemaDirective.name] = getArgumentValues(schemaDirective, directiveNode); - } + result.push({ name: directiveNode.name.value, args: getArgumentValues(schemaDirective, directiveNode) }); + } + } + } + } + + return result; +} + +export function getDirective( + schema: GraphQLSchema, + node: DirectableGraphQLObject, + directiveName: string, + pathToDirectivesInExtensions = ['directives'] +): Array> | undefined { + const directiveInExtensions = getDirectiveInExtensions(node, directiveName, pathToDirectivesInExtensions); + + if (directiveInExtensions != null) { + return directiveInExtensions; + } + + const schemaDirective = schema && schema.getDirective ? schema.getDirective(directiveName) : undefined; + + if (schemaDirective == null) { + return undefined; + } + + let astNodes: Array = []; + if (node.astNode) { + astNodes.push(node.astNode); + } + if ('extensionASTNodes' in node && node.extensionASTNodes) { + astNodes = [...astNodes, ...node.extensionASTNodes]; + } + + const result: Array> = []; + + for (const astNode of astNodes) { + if (astNode.directives) { + for (const directiveNode of astNode.directives) { + if (directiveNode.name.value === directiveName) { + result.push(getArgumentValues(schemaDirective, directiveNode)); } } } } + if (!result.length) { + return undefined; + } + return result; } diff --git a/packages/utils/tests/get-directives.spec.ts b/packages/utils/tests/get-directives.spec.ts index 19da8413343..4058d9b4eed 100644 --- a/packages/utils/tests/get-directives.spec.ts +++ b/packages/utils/tests/get-directives.spec.ts @@ -4,19 +4,19 @@ import { assertGraphQLObjectType } from '../../testing/assertion'; import { GraphQLSchema } from 'graphql'; describe('getDirectives', () => { - it('should return the correct directives map when no directives specified', () => { + it('should return the correct directives when no directives specified', () => { const typeDefs = ` type Query { test: String } `; const schema = makeExecutableSchema({ typeDefs, resolvers: {} }) as GraphQLSchema; - const directivesMap = getDirectives(schema, schema.getQueryType()!); + const directives = getDirectives(schema, schema.getQueryType()!); - expect(directivesMap).toEqual({}); + expect(directives).toEqual([]); }); - it('should return the correct directives map when built-in directive specified over FIELD_DEFINITION', () => { + it('should return the correct directives built-in directive specified over FIELD_DEFINITION', () => { const typeDefs = ` type Query { test: String @deprecated @@ -24,15 +24,16 @@ describe('getDirectives', () => { `; const schema = makeExecutableSchema({ typeDefs, resolvers: {} }) as GraphQLSchema; - const directivesMap = getDirectives(schema, schema.getQueryType()!.getFields()['test']); - expect(directivesMap).toEqual({ - deprecated: { + const directives = getDirectives(schema, schema.getQueryType()!.getFields()['test']); + expect(directives).toEqual([{ + name: 'deprecated', + args: { reason: 'No longer supported', }, - }); + }]); }); - it('should return the correct directives map when using custom directive without arguments', () => { + it('should return the correct directives when using custom directive without arguments', () => { const typeDefs = ` type Query { test: String @mydir @@ -42,13 +43,14 @@ describe('getDirectives', () => { `; const schema = makeExecutableSchema({ typeDefs, resolvers: {} }) as GraphQLSchema; - const directivesMap = getDirectives(schema, schema.getQueryType()!.getFields()['test']); - expect(directivesMap).toEqual({ - mydir: {}, - }); + const directives = getDirectives(schema, schema.getQueryType()!.getFields()['test']); + expect(directives).toEqual([{ + name: 'mydir', + args: {}, + }]); }); - it('should return the correct directives map when using custom directive with optional argument', () => { + it('should return the correct directives when using custom directive with optional argument', () => { const typeDefs = ` type Query { test: String @mydir(f1: "test") @@ -58,15 +60,16 @@ describe('getDirectives', () => { `; const schema = makeExecutableSchema({ typeDefs, resolvers: {} }) as GraphQLSchema; - const directivesMap = getDirectives(schema, schema.getQueryType()!.getFields()['test']); - expect(directivesMap).toEqual({ - mydir: { + const directives = getDirectives(schema, schema.getQueryType()!.getFields()['test']); + expect(directives).toEqual([{ + name: 'mydir', + args: { f1: 'test', }, - }); + }]); }); - it('should return the correct directives map when using custom directive with optional argument an no value', () => { + it('should return the correct directives when using custom directive with optional argument an no value', () => { const typeDefs = ` type Query { test: String @mydir @@ -76,10 +79,11 @@ describe('getDirectives', () => { `; const schema = makeExecutableSchema({ typeDefs, resolvers: {} }) as GraphQLSchema; - const directivesMap = getDirectives(schema, schema.getQueryType()!.getFields()['test']); - expect(directivesMap).toEqual({ - mydir: {}, - }); + const directives = getDirectives(schema, schema.getQueryType()!.getFields()['test']); + expect(directives).toEqual([{ + name: 'mydir', + args: {}, + }]); }); it('provides the extension definition', () => { @@ -96,7 +100,7 @@ describe('getDirectives', () => { }); const QueryType = schema.getQueryType() assertGraphQLObjectType(QueryType) - expect(getDirectives(schema,QueryType)).toEqual({ mydir: { arg: 'ext1' } }); + expect(getDirectives(schema,QueryType)).toEqual([{ name: 'mydir', args: { arg: 'ext1' } }]); }); it('builds proper repeatable directives listing', () => { @@ -110,8 +114,12 @@ describe('getDirectives', () => { }); const QueryType = schema.getQueryType() assertGraphQLObjectType(QueryType) - expect(getDirectives(schema, QueryType)).toEqual({ - mydir: [{ arg: "first" }, { arg: "second" }] - }); + expect(getDirectives(schema, QueryType)).toEqual([{ + name: 'mydir', + args: { arg: 'first' }, + }, { + name: 'mydir', + args: { arg: 'second' }, + }]); }); }); diff --git a/packages/utils/tests/schemaTransforms.test.ts b/packages/utils/tests/schemaTransforms.test.ts index 451314b4a82..12643ddafeb 100644 --- a/packages/utils/tests/schemaTransforms.test.ts +++ b/packages/utils/tests/schemaTransforms.test.ts @@ -31,6 +31,7 @@ import { MapperKind, getDirectives, ExecutionResult, + getDirective, } from '@graphql-tools/utils'; import { addMocksToSchema } from '@graphql-tools/mock'; @@ -122,8 +123,8 @@ describe('@directives', () => { return schema => mapSchema(schema, { [MapperKind.OBJECT_TYPE]: type => { const directives = getDirectives(schema, type); - for (const directiveName in directives) { - if (directiveNames.includes(directiveName)) { + for (const directive of directives) { + if (directiveNames.includes(directive.name)) { expect(type.name).toBe(schema.getQueryType()?.name); visited.add(type); } @@ -149,8 +150,8 @@ describe('@directives', () => { function recordSchemaDirectiveUses(directiveNames: Array): (schema: GraphQLSchema) => GraphQLSchema { return schema => { const directives = getDirectives(schema, schema); - for (const directiveName in directives) { - if (directiveNames.includes(directiveName)) { + for (const directive of directives) { + if (directiveNames.includes(directive.name)) { visited.push(schema); } } @@ -179,8 +180,8 @@ describe('@directives', () => { upperDirectiveTypeDefs: `directive @${directiveName} on FIELD_DEFINITION`, upperDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - if (directives[directiveName]) { + const upperDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (upperDirective) { const { resolve = defaultFieldResolver } = fieldConfig; fieldConfig.resolve = async function (source, args, context, info) { const result = await resolve(source, args, context, info); @@ -234,18 +235,16 @@ describe('@directives', () => { deprecatedDirectiveTypeDefs: `directive @${directiveName}(reason: String) on FIELD_DEFINITION | ENUM_VALUE`, deprecatedDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - fieldConfig.deprecationReason = directiveArgumentMap.reason; + const deprecatedDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (deprecatedDirective) { + fieldConfig.deprecationReason = deprecatedDirective['reason']; return fieldConfig; } }, [MapperKind.ENUM_VALUE]: (enumValueConfig) => { - const directives = getDirectives(schema, enumValueConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - enumValueConfig.deprecationReason = directiveArgumentMap.reason; + const deprecatedDirective = getDirective(schema, enumValueConfig, directiveName)?.[0]; + if (deprecatedDirective) { + enumValueConfig.deprecationReason = deprecatedDirective['reason']; return enumValueConfig; } } @@ -278,16 +277,14 @@ describe('@directives', () => { dateDirectiveTypeDefs: `directive @${directiveName}(format: String) on FIELD_DEFINITION`, dateDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { + const dateDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (dateDirective) { const { resolve = defaultFieldResolver } = fieldConfig; - const { format } = directiveArgumentMap; - fieldConfig.resolve = async function (source, args, context, info) { + const { format } = dateDirective; + fieldConfig.resolve = async (source, args, context, info) => { const date = await resolve(source, args, context, info); return formatDate(date, format, true); - - } + }; return fieldConfig; } } @@ -338,11 +335,10 @@ describe('@directives', () => { `, formattableDateDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { + const dateDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (dateDirective) { const { resolve = defaultFieldResolver } = fieldConfig; - const { defaultFormat } = directiveArgumentMap; + const { defaultFormat } = dateDirective; if (!fieldConfig.args) { throw new Error("Unexpected Error. args should be defined.") @@ -353,12 +349,12 @@ describe('@directives', () => { }; fieldConfig.type = GraphQLString; - fieldConfig.resolve = async function ( + fieldConfig.resolve = async ( source, { format, ...args }, context, info, - ) { + ) => { const newFormat = format || defaultFormat; const date = await resolve(source, args, context, info); return formatDate(date, newFormat, true); @@ -430,15 +426,16 @@ describe('@directives', () => { }`, authDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.TYPE]: (type) => { - const typeDirectives = getDirectives(schema, type); - typeDirectiveArgumentMaps[type.name] = typeDirectives[directiveName]; + const authDirective = getDirective(schema, type, directiveName)?.[0]; + if (authDirective) { + typeDirectiveArgumentMaps[type.name] = authDirective; + } return undefined; }, [MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => { - const fieldDirectives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = fieldDirectives[directiveName] ?? typeDirectiveArgumentMaps[typeName]; - if (directiveArgumentMap) { - const { requires } = directiveArgumentMap; + const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0] ?? typeDirectiveArgumentMaps[typeName]; + if (authDirective) { + const { requires } = authDirective; if (requires) { const { resolve = defaultFieldResolver } = fieldConfig; fieldConfig.resolve = function (source, args, context, info) { @@ -626,10 +623,9 @@ describe('@directives', () => { lengthDirectiveTypeDefs: `directive @${directiveName}(max: Int) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION`, lengthDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - wrapType(fieldConfig, directiveArgumentMap); + const lengthDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (lengthDirective) { + wrapType(fieldConfig, lengthDirective); return fieldConfig; } } @@ -717,10 +713,9 @@ describe('@directives', () => { uniqueIDDirectiveTypeDefs: `directive @${directiveName}(name: String, from: [String]) on OBJECT`, uniqueIDDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.OBJECT_TYPE]: (type) => { - const directives = getDirectives(schema, type); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - const { name, from } = directiveArgumentMap; + const uniqueIDDirective = getDirective(schema, type, directiveName)?.[0]; + if (uniqueIDDirective) { + const { name, from } = uniqueIDDirective; const config = type.toConfig(); config.fields[name] = { type: GraphQLID, @@ -827,9 +822,8 @@ describe('@directives', () => { function renameObjectTypeToHumanDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { return schema => mapSchema(schema, { [MapperKind.OBJECT_TYPE]: (type) => { - const directives = getDirectives(schema, type); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { + const directive = getDirective(schema, type, directiveName)?.[0]; + if (directive) { const config = type.toConfig(); config.name = 'Human'; return new GraphQLObjectType(config); @@ -875,9 +869,8 @@ describe('@directives', () => { function removeEnumValueDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { return schema => mapSchema(schema, { [MapperKind.ENUM_VALUE]: (enumValueConfig) => { - const directives = getDirectives(schema, enumValueConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap && directiveArgumentMap.if) { + const directive = getDirective(schema, enumValueConfig, directiveName)?.[0]; + if (directive?.['if']) { return null; } } @@ -912,10 +905,9 @@ describe('@directives', () => { function modifyExternalEnumValueDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { return schema => mapSchema(schema, { [MapperKind.ENUM_VALUE]: (enumValueConfig) => { - const directives = getDirectives(schema, enumValueConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - return [directiveArgumentMap.new, enumValueConfig]; + const directive = getDirective(schema, enumValueConfig, directiveName)?.[0]; + if (directive) { + return [directive['new'], enumValueConfig]; } } }); @@ -950,10 +942,9 @@ describe('@directives', () => { function modifyInternalEnumValueDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { return schema => mapSchema(schema, { [MapperKind.ENUM_VALUE]: (enumValueConfig) => { - const directives = getDirectives(schema, enumValueConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - enumValueConfig.value = directiveArgumentMap.new; + const directive = getDirective(schema, enumValueConfig, directiveName)?.[0]; + if (directive) { + enumValueConfig.value = directive['new']; return enumValueConfig; } } @@ -989,11 +980,10 @@ describe('@directives', () => { function renameObjectTypeDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { return schema => mapSchema(schema, { [MapperKind.OBJECT_TYPE]: (type) => { - const directives = getDirectives(schema, type); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { + const directive = getDirective(schema, type, directiveName)?.[0]; + if (directive) { const config = type.toConfig(); - config.name = directiveArgumentMap.to; + config.name = directive['to']; return new GraphQLObjectType(config); } } @@ -1042,9 +1032,8 @@ describe('@directives', () => { function addObjectTypeToSetDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { return schema => mapSchema(schema, { [MapperKind.OBJECT_TYPE]: type => { - const directives = getDirectives(schema, type); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { + const directive = getDirective(schema, type, directiveName)?.[0]; + if (directive) { expect(type.name).toBe(schema.getQueryType()?.name); visited.add(type); } @@ -1072,8 +1061,8 @@ describe('@directives', () => { function upperDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { return schema => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - if (directives[directiveName]) { + const upperDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (upperDirective) { const { resolve = defaultFieldResolver } = fieldConfig; fieldConfig.resolve = async function (source, args, context, info) { const result = await resolve(source, args, context, info); @@ -1091,8 +1080,8 @@ describe('@directives', () => { function reverseDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { return schema => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - if (directives[directiveName]) { + const reverseDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (reverseDirective) { const { resolve = defaultFieldResolver } = fieldConfig; fieldConfig.resolve = async function (source, args, context, info) { const result = await resolve(source, args, context, info); @@ -1145,10 +1134,10 @@ describe('@directives', () => { const listWrapperTypes = new Map(); return mapSchema(schema, { [MapperKind.COMPOSITE_FIELD]: (fieldConfig, fieldName) => { - const hasDirectiveAnnotation = !!getDirectives(schema, fieldConfig)['addListWrapper']; + const directive = getDirective(schema, fieldConfig, 'addListWrapper')?.[0]; // Leave the field untouched if it does not have the directive annotation - if (!hasDirectiveAnnotation) { + if (!directive) { return undefined; } diff --git a/packages/wrap/src/transforms/RemoveObjectFieldsWithDirective.ts b/packages/wrap/src/transforms/RemoveObjectFieldsWithDirective.ts index 2633df6e282..4b6dc019741 100644 --- a/packages/wrap/src/transforms/RemoveObjectFieldsWithDirective.ts +++ b/packages/wrap/src/transforms/RemoveObjectFieldsWithDirective.ts @@ -21,13 +21,10 @@ export default class RemoveObjectFieldsWithDirective implements Transform { transformedSchema?: GraphQLSchema ): GraphQLSchema { const transformer = new FilterObjectFields((_typeName, _fieldName, fieldConfig) => { - const valueMap = getDirectives(originalWrappingSchema, fieldConfig); - return !Object.keys(valueMap).some( - directiveName => - valueMatchesCriteria(directiveName, this.directiveName) && - ((Array.isArray(valueMap[directiveName]) && - valueMap[directiveName].some((value: any) => valueMatchesCriteria(value, this.args))) || - valueMatchesCriteria(valueMap[directiveName], this.args)) + const directives = getDirectives(originalWrappingSchema, fieldConfig); + return !directives.some( + directive => + valueMatchesCriteria(directive.name, this.directiveName) && valueMatchesCriteria(directive.args, this.args) ); }); diff --git a/website/docs/schema-directives.md b/website/docs/schema-directives.md index 124bbfaaf07..5b1709bc54c 100644 --- a/website/docs/schema-directives.md +++ b/website/docs/schema-directives.md @@ -61,28 +61,25 @@ Here is one possible implementation of the `@deprecated` directive we saw above: import { mapSchema, getDirectives } from '@graphql-tools/utils'; import { GraphQLSchema } from 'graphql'; -export function deprecatedDirective(directiveName: string) { +function deprecatedDirective(directiveName: string) { return { deprecatedDirectiveTypeDefs: `directive @${directiveName}(reason: String) on FIELD_DEFINITION | ENUM_VALUE`, - deprecatedDirectiveTransformer: (schema: GraphQLSchema) => - mapSchema(schema, { - [MapperKind.OBJECT_FIELD]: fieldConfig => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - fieldConfig.deprecationReason = directiveArgumentMap.reason; - return fieldConfig; - } - }, - [MapperKind.ENUM_VALUE]: enumValueConfig => { - const directives = getDirectives(schema, enumValueConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - enumValueConfig.deprecationReason = directiveArgumentMap.reason; - return enumValueConfig; - } - }, - }), + deprecatedDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: (fieldConfig) => { + const deprecatedDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (deprecatedDirective) { + fieldConfig.deprecationReason = deprecatedDirective['reason']; + return fieldConfig; + } + }, + [MapperKind.ENUM_VALUE]: (enumValueConfig) => { + const deprecatedDirective = getDirective(schema, enumValueConfig, directiveName)?.[0]; + if (deprecatedDirective) { + enumValueConfig.deprecationReason = deprecatedDirective['reason']; + return enumValueConfig; + } + } + }), }; } ``` @@ -131,26 +128,23 @@ To appreciate the range of possibilities enabled by `mapSchema`, let's examine a Suppose you want to ensure a string-valued field is converted to uppercase. Though this use case is simple, it's a good example of a directive implementation that works by wrapping a field's `resolve` function: ```js -function upperDirective(directiveName: string) { - return { - upperDirectiveTypeDefs: `directive @${directiveName} on FIELD_DEFINITION`, - upperDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { - [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - if (directives[directiveName]) { - const { resolve = defaultFieldResolver } = fieldConfig; - fieldConfig.resolve = async function (source, args, context, info) { - const result = await resolve(source, args, context, info); - if (typeof result === 'string') { - return result.toUpperCase(); - } - return result; +function upperDirective(directiveName: string): (schema: GraphQLSchema) => GraphQLSchema { + return schema => mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: (fieldConfig) => { + const upperDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (upperDirective) { + const { resolve = defaultFieldResolver } = fieldConfig; + fieldConfig.resolve = async function (source, args, context, info) { + const result = await resolve(source, args, context, info); + if (typeof result === 'string') { + return result.toUpperCase(); } - return fieldConfig; + return result; } + return fieldConfig; } - }) - }; + } + }); } const { upperDirectiveTypeDefs, upperDirectiveTransformer } = upperDirective('upper'); @@ -190,10 +184,9 @@ function restDirective(directiveName: string) { restDirectiveTypeDefs: `directive @${directiveName}(url: String) on FIELD_DEFINITION`; restDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - const { url } = directiveArgumentMap; + const restDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (restDirective) { + const { url } = restDirective; fieldConfig.resolve = () => fetch(url); return fieldConfig; } @@ -224,22 +217,20 @@ Suppose your resolver returns a `Date` object but you want to return a formatted function dateDirective(directiveName: string) { return { dateDirectiveTypeDefs: `directive @${directiveName}(format: String) on FIELD_DEFINITION`, - dateDirectiveTransformer: (schema: GraphQLSchema) => - mapSchema(schema, { - [MapperKind.OBJECT_FIELD]: fieldConfig => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - const { resolve = defaultFieldResolver } = fieldConfig; - const { format } = directiveArgumentMap; - fieldConfig.resolve = async function (source, args, context, info) { - const date = await resolve(source, args, context, info); - return formatDate(date, format, true); - }; - return fieldConfig; - } - }, - }), + dateDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: (fieldConfig) => { + const dateDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (dateDirective) { + const { resolve = defaultFieldResolver } = fieldConfig; + const { format } = dateDirective; + fieldConfig.resolve = async (source, args, context, info) => { + const date = await resolve(source, args, context, info); + return formatDate(date, format, true); + }; + return fieldConfig; + } + } + }), }; } @@ -278,29 +269,36 @@ function formattableDateDirective(directiveName: string) { defaultFormat: String = "mmmm d, yyyy" ) on FIELD_DEFINITION `, - formattableDateDirectiveTransformer: (schema: GraphQLSchema) => - mapSchema(schema, { - [MapperKind.OBJECT_FIELD]: fieldConfig => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - const { resolve = defaultFieldResolver } = fieldConfig; - const { defaultFormat } = directiveArgumentMap; - - fieldConfig.args['format'] = { - type: GraphQLString, - }; - - fieldConfig.type = GraphQLString; - fieldConfig.resolve = async function (source, { format, ...args }, context, info) { - const newFormat = format || defaultFormat; - const date = await resolve(source, args, context, info); - return formatDate(date, newFormat, true); - }; - return fieldConfig; + formattableDateDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: (fieldConfig) => { + const dateDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (dateDirective) { + const { resolve = defaultFieldResolver } = fieldConfig; + const { defaultFormat } = dateDirective; + + if (!fieldConfig.args) { + throw new Error("Unexpected Error. args should be defined.") } - }, - }), + + fieldConfig.args['format'] = { + type: GraphQLString, + }; + + fieldConfig.type = GraphQLString; + fieldConfig.resolve = async ( + source, + { format, ...args }, + context, + info, + ) => { + const newFormat = format || defaultFormat; + const date = await resolve(source, args, context, info); + return formatDate(date, newFormat, true); + }; + return fieldConfig; + } + } + }), }; } @@ -395,15 +393,16 @@ function authDirective(directiveName: string, getUserFn: (token: string) => { ha }`, authDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.TYPE]: (type) => { - const typeDirectives = getDirectives(schema, type); - typeDirectiveArgumentMaps[type.name] = typeDirectives[directiveName]; + const authDirective = getDirective(schema, type, directiveName)?.[0]; + if (authDirective) { + typeDirectiveArgumentMaps[type.name] = authDirective; + } return undefined; }, [MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => { - const fieldDirectives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = fieldDirectives[directiveName] ?? typeDirectiveArgumentMaps[typeName]; - if (directiveArgumentMap) { - const { requires } = directiveArgumentMap; + const authDirective = getDirective(schema, fieldConfig, directiveName)?.[0] ?? typeDirectiveArgumentMaps[typeName]; + if (authDirective) { + const { requires } = authDirective; if (requires) { const { resolve = defaultFieldResolver } = fieldConfig; fieldConfig.resolve = function (source, args, context, info) { @@ -419,7 +418,7 @@ function authDirective(directiveName: string, getUserFn: (token: string) => { ha } }) }; -}; +} function getUser(token: string) { const roles = ['UNKNOWN', 'USER', 'REVIEWER', 'ADMIN']; @@ -494,7 +493,7 @@ function lengthDirective(directiveName: string) { return type.parseValue(value); }, - parseLiteral(ast: StringValueNode) { + parseLiteral(ast) { return type.parseLiteral(ast, {}); }, }); @@ -524,9 +523,9 @@ function lengthDirective(directiveName: string) { function wrapType | GraphQLInputFieldConfig>(fieldConfig: F, directiveArgumentMap: Record): void { if (isNonNullType(fieldConfig.type) && isScalarType(fieldConfig.type.ofType)) { - fieldConfig.type = getLimitedLengthType(fieldConfig.type.ofType, directiveArgumentMap.max); + fieldConfig.type = getLimitedLengthType(fieldConfig.type.ofType, directiveArgumentMap['max']); } else if (isScalarType(fieldConfig.type)) { - fieldConfig.type = getLimitedLengthType(fieldConfig.type, directiveArgumentMap.max); + fieldConfig.type = getLimitedLengthType(fieldConfig.type, directiveArgumentMap['max']); } else { throw new Error(`Not a scalar type: ${fieldConfig.type.toString()}`); } @@ -536,16 +535,15 @@ function lengthDirective(directiveName: string) { lengthDirectiveTypeDefs: `directive @${directiveName}(max: Int) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION`, lengthDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { [MapperKind.FIELD]: (fieldConfig) => { - const directives = getDirectives(schema, fieldConfig); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - wrapType(fieldConfig, directiveArgumentMap); + const lengthDirective = getDirective(schema, fieldConfig, directiveName)?.[0]; + if (lengthDirective) { + wrapType(fieldConfig, lengthDirective); return fieldConfig; } } }), }; -}; +} const { lengthDirectiveTypeDefs, lengthDirectiveTransformer } = lengthDirective('length'); @@ -600,31 +598,29 @@ import { createHash } from 'crypto'; function uniqueIDDirective(directiveName: string) { return { uniqueIDDirectiveTypeDefs: `directive @${directiveName}(name: String, from: [String]) on OBJECT`, - uniqueIDDirectiveTransformer: (schema: GraphQLSchema) => - mapSchema(schema, { - [MapperKind.OBJECT_TYPE]: type => { - const directives = getDirectives(schema, type); - const directiveArgumentMap = directives[directiveName]; - if (directiveArgumentMap) { - const { name, from } = directiveArgumentMap; - const config = type.toConfig(); - config.fields[name] = { - type: GraphQLID, - description: 'Unique ID', - args: {}, - resolve(object: any) { - const hash = createHash('sha1'); - hash.update(type.name); - for (const fieldName of from) { - hash.update(String(object[fieldName])); - } - return hash.digest('hex'); - }, - }; - return new GraphQLObjectType(config); - } - }, - }), + uniqueIDDirectiveTransformer: (schema: GraphQLSchema) => mapSchema(schema, { + [MapperKind.OBJECT_TYPE]: (type) => { + const uniqueIDDirective = getDirective(schema, type, directiveName)?.[0]; + if (uniqueIDDirective) { + const { name, from } = uniqueIDDirective; + const config = type.toConfig(); + config.fields[name] = { + type: GraphQLID, + description: 'Unique ID', + args: {}, + resolve(object: any) { + const hash = createHash('sha1'); + hash.update(type.name); + for (const fieldName of from ){ + hash.update(String(object[fieldName])); + } + return hash.digest('hex'); + }, + }; + return new GraphQLObjectType(config); + } + } + }), }; } @@ -686,9 +682,9 @@ In theory, access to the query directives is available within the `info` resolve ## What about `directiveResolvers`? -The `makeExecutableSchema` function also takes a `directiveResolvers` option that can be used for implementing certain kinds of `@directive`s on fields that have resolver functions. +The `makeExecutableSchema` function used to take a `directiveResolvers` option that could be used for implementing certain kinds of `@directive`s on fields that have resolver functions. -The new abstraction is more general, since it can visit any kind of schema syntax, and do much more than just wrap resolver functions. However, the old `directiveResolvers` API has been left in place for backwards compatibility, though it is now implemented in terms of `mapSchema`: +The new abstraction is more general, since it can visit any kind of schema syntax, and do much more than just wrap resolver functions. The old `directiveResolvers` API can be implemented with the above new API as follows: ```typescript export function attachDirectiveResolvers( @@ -702,11 +698,12 @@ export function attachDirectiveResolvers( const newFieldConfig = { ...fieldConfig }; const directives = getDirectives(schema, fieldConfig); - for (const directiveName in directives) { + for (const directive of directives) { + const directiveName = directive.name; if (directiveResolvers[directiveName]) { const resolver = directiveResolvers[directiveName]; const originalResolver = newFieldConfig.resolve != null ? newFieldConfig.resolve : defaultFieldResolver; - const directiveArgs = directives[directiveName]; + const directiveArgs = directive.args; newFieldConfig.resolve = (source, originalArgs, context, info) => { return resolver( () => @@ -732,8 +729,6 @@ export function attachDirectiveResolvers( } ``` -Existing code that uses `directiveResolvers` could consider migrating to direct usage of `mapSchema`, though we have no immediate plans to deprecate `directiveResolvers`. - ## What about code-first schemas? You can use schema transformation functions with code-first schemas as well. By default, if a `directives` key exists within the `extensions` field for a given GraphQL entity, the `getDirectives` function will retrieve the directive data from the GraphQL entity's `extensions.directives` data rather than from the SDL. This, of course, allows schemas created without SDL to use any schema transformation functions created for directive use, as long as they define the necessary data within the GraphQL entity extensions.