├── .gitignore ├── src ├── index.js ├── create-schema-directives.js ├── jsdoc-types.js ├── utils.js └── create-directive.js ├── .travis.yml ├── tests ├── integration-test-cases │ ├── index.js │ ├── upper-case │ │ ├── index.js │ │ ├── directive.js │ │ ├── on-combined.js │ │ ├── on-field-definition.js │ │ └── on-object.js │ ├── auth │ │ ├── index.js │ │ ├── directive.js │ │ ├── on-object.js │ │ ├── on-field-definition.js │ │ ├── on-combined.js │ │ └── with-conflicting-args.js │ └── multiple-directives.js ├── create-schema-directives.test.js ├── utils.test.js ├── integration.test.js └── create-directive.test.js ├── LICENSE ├── package.json ├── README.md └── PROPOSAL.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const createDirective = require("./create-directive"); 2 | const createSchemaDirectives = require("./create-schema-directives"); 3 | 4 | module.exports = { 5 | createDirective, 6 | createSchemaDirectives, 7 | }; 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: true 2 | cache: npm 3 | language: node_js 4 | node_js: "10.15.3" 5 | before_install: 6 | - npm i 7 | jobs: 8 | include: 9 | - stage: test 10 | script: 11 | - npm run lint 12 | - npm run test:travis 13 | -------------------------------------------------------------------------------- /tests/integration-test-cases/index.js: -------------------------------------------------------------------------------- 1 | const auth = require("./auth"); 2 | const upperCase = require("./upper-case"); 3 | const multipleDirectives = require("./multiple-directives"); 4 | 5 | module.exports = { 6 | auth, 7 | upperCase, 8 | multipleDirectives, 9 | }; 10 | -------------------------------------------------------------------------------- /tests/integration-test-cases/upper-case/index.js: -------------------------------------------------------------------------------- 1 | const onObject = require("./on-object"); 2 | const onCombined = require("./on-combined"); 3 | const onFieldDefinition = require("./on-field-definition"); 4 | 5 | module.exports = { 6 | onObject, 7 | onCombined, 8 | onFieldDefinition, 9 | }; 10 | -------------------------------------------------------------------------------- /tests/integration-test-cases/auth/index.js: -------------------------------------------------------------------------------- 1 | const onObject = require("./on-object"); 2 | const onCombined = require("./on-combined"); 3 | const onFieldDefinition = require("./on-field-definition"); 4 | const withConflictingArgs = require("./with-conflicting-args"); 5 | 6 | module.exports = { 7 | onObject, 8 | onCombined, 9 | onFieldDefinition, 10 | withConflictingArgs, 11 | }; 12 | -------------------------------------------------------------------------------- /tests/integration-test-cases/upper-case/directive.js: -------------------------------------------------------------------------------- 1 | const { createDirective } = require("../../../src"); 2 | 3 | const upperCaseIfString = value => (typeof value === "string" ? value.toUpperCase() : value); 4 | 5 | const directiveConfig = { 6 | name: "upperCase", 7 | resolverReplacer: originalResolver => async function upperCaseResolver(...args) { 8 | const result = await originalResolver.apply(this, args); 9 | if (Array.isArray(result)) { 10 | return result.map(upperCaseIfString); 11 | } 12 | return upperCaseIfString(result); 13 | }, 14 | }; 15 | 16 | module.exports = { 17 | directiveConfig, 18 | upperCase: createDirective(directiveConfig), 19 | }; 20 | -------------------------------------------------------------------------------- /src/create-schema-directives.js: -------------------------------------------------------------------------------- 1 | require("./jsdoc-types"); 2 | const createDirective = require("./create-directive"); 3 | 4 | /** 5 | * @typedef {import('./jsdoc-types.js').DirectiveConfig} DirectiveConfig 6 | */ 7 | 8 | /** 9 | * Creates an Apollo Server config.schemaDirectives object 10 | * @param {{ directiveConfigs: [DirectiveConfig] }} config 11 | * @returns {Object} schemaDirectives 12 | */ 13 | const createSchemaDirectives = config => config.directiveConfigs.reduce( 14 | (schemaDirectives, directiveConfig) => ({ 15 | ...schemaDirectives, 16 | [directiveConfig.name]: createDirective(directiveConfig), 17 | }), 18 | {}, 19 | ); 20 | 21 | module.exports = createSchemaDirectives; 22 | -------------------------------------------------------------------------------- /tests/integration-test-cases/auth/directive.js: -------------------------------------------------------------------------------- 1 | const { createDirective } = require("../../../src"); 2 | 3 | const resolverReplacer = (originalResolver, directiveContext) => async function upperCaseResolver(...args) { 4 | const { role } = args[2]; // context 5 | const hasAuthorization = directiveContext.args.require.includes(role); 6 | 7 | if (!hasAuthorization) { 8 | throw new Error("Not authorized"); 9 | } 10 | 11 | return originalResolver.apply(this, args); 12 | }; 13 | 14 | const directiveConfig = { 15 | name: "auth", 16 | resolverReplacer, 17 | }; 18 | 19 | const typeDef = directiveTarget => ` 20 | directive @auth(require: [Role] = [ADMIN]) on ${directiveTarget} 21 | 22 | enum Role { 23 | SELF 24 | USER 25 | ADMIN 26 | } 27 | `; 28 | 29 | module.exports = { 30 | typeDef, 31 | directiveConfig, 32 | auth: createDirective(directiveConfig), 33 | }; 34 | -------------------------------------------------------------------------------- /tests/create-schema-directives.test.js: -------------------------------------------------------------------------------- 1 | const { createSchemaDirectives } = require("../src"); 2 | const createDirective = require("../src/create-directive"); 3 | 4 | jest.mock("../src/create-directive.js"); 5 | 6 | describe("core export: createSchemaDirectives", () => { 7 | const firstDirectiveConfig = { name: "first", resolverReplacer: () => {} }; 8 | const secondDirectiveConfig = { name: "second", resolverReplacer: () => {} }; 9 | const directiveConfigs = [firstDirectiveConfig, secondDirectiveConfig]; 10 | 11 | test("given a DirectiveConfig[] array: returns a schemaDirectives object in { directiveName: directiveResolverClass } form", () => { 12 | createDirective.mockImplementation(() => "directiveResolverClass"); 13 | expect(createSchemaDirectives({ directiveConfigs })).toEqual({ 14 | [firstDirectiveConfig.name]: "directiveResolverClass", 15 | [secondDirectiveConfig.name]: "directiveResolverClass", 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Vamp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/integration-test-cases/upper-case/on-combined.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require("graphql-tools"); 2 | const { upperCase } = require("./directive"); 3 | 4 | const typeDefs = ` 5 | directive @upperCase on FIELD_DEFINITION | OBJECT 6 | 7 | type Query { 8 | getNoDirective: String! # should not affect 9 | getString: String! @upperCase 10 | getPerson: Person! 11 | } 12 | 13 | type Person @upperCase { 14 | name: String! 15 | favoriteColor: String! 16 | } 17 | `; 18 | 19 | const resolvers = { 20 | Query: { 21 | getPerson: () => ({ 22 | name: "vamp", 23 | favoriteColor: "green", 24 | }), 25 | getString: () => "upperCase", 26 | getNoDirective: () => "lowerCase", 27 | }, 28 | }; 29 | 30 | const schema = makeExecutableSchema({ 31 | typeDefs, 32 | resolvers, 33 | schemaDirectives: { upperCase }, 34 | }); 35 | 36 | const testCases = [ 37 | { 38 | message: "Query.getPerson: all Person String fields returned in upper case", 39 | query: ` 40 | query { 41 | result: getPerson { 42 | name 43 | favoriteColor 44 | } 45 | } 46 | `, 47 | expectedResult: { 48 | name: "VAMP", 49 | favoriteColor: "GREEN", 50 | }, 51 | }, 52 | { 53 | message: "Query.getString: returned in upper case", 54 | query: ` 55 | query { 56 | result: getString 57 | } 58 | `, 59 | expectedResult: "UPPERCASE", 60 | }, 61 | { 62 | message: "Query.getNoDirective: returned in lower case", 63 | query: ` 64 | query { 65 | result: getNoDirective 66 | } 67 | `, 68 | expectedResult: "lowerCase", 69 | }, 70 | ]; 71 | 72 | module.exports = { 73 | schema, 74 | testCases, 75 | }; 76 | -------------------------------------------------------------------------------- /tests/integration-test-cases/upper-case/on-field-definition.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require("graphql-tools"); 2 | const { upperCase } = require("./directive"); 3 | 4 | const typeDefs = ` 5 | directive @upperCase on FIELD_DEFINITION 6 | 7 | type Query { 8 | getNoDirective: String! # should not affect 9 | getString: String! @upperCase 10 | getPerson: Person! 11 | } 12 | 13 | type Person { 14 | name: String! @upperCase 15 | favoriteColor: String! # should not affect 16 | } 17 | `; 18 | 19 | const resolvers = { 20 | Query: { 21 | getPerson: () => ({ 22 | name: "vamp", 23 | favoriteColor: "green", 24 | }), 25 | getString: () => "upperCase", 26 | getNoDirective: () => "lowerCase", 27 | }, 28 | }; 29 | 30 | const schema = makeExecutableSchema({ 31 | typeDefs, 32 | resolvers, 33 | schemaDirectives: { upperCase }, 34 | }); 35 | 36 | const testCases = [ 37 | { 38 | message: 39 | "Query.getPerson: Person.name upper case, Person.favoriteColor lower case", 40 | query: ` 41 | query { 42 | result: getPerson { 43 | name 44 | favoriteColor 45 | } 46 | } 47 | `, 48 | expectedResult: { 49 | name: "VAMP", 50 | favoriteColor: "green", 51 | }, 52 | }, 53 | { 54 | message: "Query.getString: returned in upper case", 55 | query: ` 56 | query { 57 | result: getString 58 | } 59 | `, 60 | expectedResult: "UPPERCASE", 61 | }, 62 | { 63 | message: "Query.getNoDirective: returned in lower case", 64 | query: ` 65 | query { 66 | result: getNoDirective 67 | } 68 | `, 69 | expectedResult: "lowerCase", 70 | }, 71 | ]; 72 | 73 | module.exports = { 74 | schema, 75 | testCases, 76 | }; 77 | -------------------------------------------------------------------------------- /src/jsdoc-types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} DirectiveConfig 3 | * @property {Hooks} [hooks] 4 | * @property {string} name the name of the directive (must match directive type definition) 5 | * @property {resolverReplacerSignature} resolverReplacer 6 | */ 7 | 8 | /** 9 | * @typedef {Object} DirectiveContext 10 | * @description additional context related to the directive implementation 11 | * @property {ObjectType} objectType Object Type the directive is applied to 12 | * @property {ObjectTypeField} field Object Type Field the directive is applied to 13 | * @property {string} name directive name 14 | * @property {Object} args directive arguments 15 | */ 16 | 17 | /** 18 | * @typedef {Object} Hooks 19 | * @description optional hooks fired during directive registration on server startup 20 | * @property {hookSignature} [onVisitObject] fired just before an Object Type directive registration 21 | * @property {hookSignature} [onApplyDirective] fired as the directive is being applied 22 | * @property {hookSignature} [onVisitFieldDefinition] fired just before an Object Type Field directive registration 23 | */ 24 | 25 | /** 26 | * @typedef {function} resolverSignature 27 | * @param {object} root 28 | * @param {object} args 29 | * @param {object} context 30 | * @param {object} info 31 | */ 32 | 33 | /** 34 | * @typedef {resolverSignature} resolverWrapperSignature 35 | */ 36 | 37 | /** 38 | * @typedef {function} resolverReplacerSignature 39 | * @description a higher order function that bridges directive registration with your resolverWrapper function 40 | * @param {resolverSignature} originalResolver the field's original resolver function 41 | * @param {DirectiveContext} directiveContext 42 | * @returns {resolverWrapperSignature} resolverWrapper function 43 | */ 44 | 45 | /** 46 | * @typedef {function} hookSignature 47 | * @param {DirectiveContext} directiveContext 48 | * @returns {void} 49 | */ 50 | module.exports = {}; 51 | -------------------------------------------------------------------------------- /tests/integration-test-cases/auth/on-object.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require("graphql-tools"); 2 | const { auth, typeDef } = require("./directive"); 3 | 4 | const typeDefs = ` 5 | ${typeDef("OBJECT")} 6 | 7 | type Query { 8 | getNoDirective: String! # should not affect 9 | getPerson: Person! 10 | } 11 | 12 | type Person @auth { 13 | age: Int! 14 | name: String! 15 | favoriteColor: String! 16 | } 17 | `; 18 | 19 | const person = { 20 | age: 100, 21 | name: "vamp", 22 | favoriteColor: "green", 23 | }; 24 | 25 | const resolvers = { 26 | Query: { 27 | getPerson: () => person, 28 | getNoDirective: () => "some string", 29 | }, 30 | }; 31 | 32 | const schema = makeExecutableSchema({ 33 | typeDefs, 34 | resolvers, 35 | schemaDirectives: { auth }, 36 | }); 37 | 38 | const personFieldsFragment = ` 39 | fragment PersonFields on Person { 40 | age 41 | name 42 | favoriteColor 43 | } 44 | `; 45 | 46 | const testCases = [ 47 | { 48 | message: "Query.getPerson, role = ADMIN: Person fields are resolved", 49 | query: ` 50 | ${personFieldsFragment} 51 | 52 | query { 53 | result: getPerson { 54 | ...PersonFields 55 | } 56 | } 57 | `, 58 | expectedResult: person, 59 | context: { role: "ADMIN" }, 60 | }, 61 | { 62 | message: "Query.getPerson, role = USER: throws authorization Error", 63 | query: ` 64 | ${personFieldsFragment} 65 | 66 | query { 67 | result: getPerson { 68 | ...PersonFields 69 | } 70 | } 71 | `, 72 | expectErrors: true, 73 | context: { role: "USER" }, 74 | }, 75 | { 76 | message: "Query.getNoDirective: resolves value", 77 | query: ` 78 | query { 79 | result: getNoDirective 80 | } 81 | `, 82 | expectedResult: "some string", 83 | }, 84 | ]; 85 | 86 | module.exports = { 87 | schema, 88 | testCases, 89 | }; 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apollo-directive", 3 | "version": "0.1.2", 4 | "description": "tools for simplifying the process of implementing custom GraphQL schema directives in Apollo Server", 5 | "main": "src/index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "test": "jest", 11 | "lint": "eslint .", 12 | "test:integration": "npm test -- -t integration --verbose", 13 | "test:travis": "npm run test -- --no-cache --coverage && cat ./coverage/lcov.info | coveralls" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/the-vampiire/apollo-directive.git" 18 | }, 19 | "keywords": [ 20 | "apollo", 21 | "custom", 22 | "directives", 23 | "graphql", 24 | "server", 25 | "schema" 26 | ], 27 | "author": "the-vampiire", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/the-vampiire/apollo-directive/issues" 31 | }, 32 | "homepage": "https://github.com/the-vampiire/apollo-directive#readme", 33 | "dependencies": { 34 | "graphql": "^14.4.2", 35 | "graphql-tools": "^4.0.5" 36 | }, 37 | "devDependencies": { 38 | "@types/jest": "^24.0.15", 39 | "coveralls": "^3.0.5", 40 | "eslint": "^5.6.0", 41 | "eslint-config-airbnb-base": "^13.2.0", 42 | "eslint-config-prettier": "^6.0.0", 43 | "eslint-plugin-import": "^2.18.2", 44 | "eslint-plugin-jest": "^22.14.0", 45 | "jest": "^24.8.0" 46 | }, 47 | "eslintConfig": { 48 | "env": { 49 | "es6": true, 50 | "commonjs": true, 51 | "jest/globals": true 52 | }, 53 | "extends": [ 54 | "prettier", 55 | "airbnb-base", 56 | "plugin:jest/recommended" 57 | ], 58 | "plugins": [ 59 | "jest", 60 | "import" 61 | ], 62 | "globals": { 63 | "Atomics": "readonly", 64 | "SharedArrayBuffer": "readonly" 65 | }, 66 | "parserOptions": { 67 | "ecmaVersion": 2018 68 | }, 69 | "rules": { 70 | "quotes": [ 71 | "error", 72 | "double" 73 | ], 74 | "max-len": [ 75 | 0, 76 | 100 77 | ] 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign, no-underscore-dangle */ 2 | 3 | const validateDirectiveConfig = (directiveConfig) => { 4 | const { name, resolverReplacer } = directiveConfig; 5 | 6 | let message; 7 | if (!name) { 8 | message = "directiveConfig.name is required"; 9 | } else if (!resolverReplacer) { 10 | message = "directiveConfig.resolverReplacer is required"; 11 | } else if (typeof name !== "string") { 12 | message = "directiveConfig.name must be a string"; 13 | } else if (typeof resolverReplacer !== "function") { 14 | message = "directiveConfig.resolverReplacer must be a function"; 15 | } else { 16 | return directiveConfig; 17 | } 18 | 19 | const error = new Error(message); 20 | error.name = "CreateDirectiveError"; 21 | 22 | throw error; 23 | }; 24 | 25 | const markDirectiveApplied = (directiveTarget, directiveName) => { 26 | if (!directiveTarget._appliedDirectives) { 27 | directiveTarget._appliedDirectives = {}; 28 | } 29 | 30 | directiveTarget._appliedDirectives[directiveName] = true; 31 | }; 32 | 33 | const shouldApplyToField = ( 34 | field, 35 | directiveName, 36 | applyingAtObjectLevel = false, 37 | ) => { 38 | if (!field._appliedDirectives) { 39 | field._appliedDirectives = {}; 40 | } 41 | 42 | const directiveAlreadyApplied = field._appliedDirectives[directiveName]; 43 | 44 | // if the directive is being applied from applyToObject and it has already been applied 45 | if (applyingAtObjectLevel && directiveAlreadyApplied) { 46 | /** 47 | * edge case where both the Object Type and its field have been tagged by the same directive 48 | * but each have different directive args 49 | * - give directive args precedence to field level args 50 | * - field level args have higher specificity than the more general object level args 51 | * 52 | * an alternative if the order of visitX methods is ever changed 53 | * - markDirectiveApplied: cache the args (stringified) applied at the field level / object level 54 | * - if applyingAtObjectLevel && argsDontMatch: compare the args and return false if object args !== field args 55 | */ 56 | return false; 57 | } 58 | 59 | return true; 60 | }; 61 | 62 | module.exports = { 63 | shouldApplyToField, 64 | markDirectiveApplied, 65 | validateDirectiveConfig, 66 | }; 67 | -------------------------------------------------------------------------------- /tests/integration-test-cases/auth/on-field-definition.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require("graphql-tools"); 2 | const { auth, typeDef } = require("./directive"); 3 | 4 | const typeDefs = ` 5 | ${typeDef("FIELD_DEFINITION")} 6 | 7 | type Query { 8 | getNoDirective: String! # should not affect 9 | getPerson: Person! 10 | } 11 | 12 | type Person { 13 | age: Int! 14 | name: String! @auth(require: [ADMIN, SELF]) 15 | favoriteColor: String! 16 | } 17 | `; 18 | 19 | const person = { 20 | age: 100, 21 | name: "vamp", 22 | favoriteColor: "green", 23 | }; 24 | 25 | const resolvers = { 26 | Query: { 27 | getPerson: () => person, 28 | getNoDirective: () => "some string", 29 | }, 30 | }; 31 | 32 | const schema = makeExecutableSchema({ 33 | typeDefs, 34 | resolvers, 35 | schemaDirectives: { auth }, 36 | }); 37 | 38 | const personFieldsFragment = ` 39 | fragment PersonFields on Person { 40 | age 41 | name 42 | favoriteColor 43 | } 44 | `; 45 | 46 | const testCases = [ 47 | { 48 | message: "Query.getPerson, role = SELF: Person fields are resolved", 49 | query: ` 50 | ${personFieldsFragment} 51 | 52 | query { 53 | result: getPerson { 54 | ...PersonFields 55 | } 56 | } 57 | `, 58 | expectedResult: person, 59 | context: { role: "SELF" }, 60 | }, 61 | { 62 | message: "Query.getPerson.name, role = USER: throws authorization Error", 63 | query: ` 64 | query { 65 | result: getPerson { 66 | name 67 | } 68 | } 69 | `, 70 | expectErrors: true, 71 | context: { role: "USER" }, 72 | }, 73 | { 74 | message: 75 | "Query.getPerson.[age, favoriteColor], role = USER: resolves values", 76 | query: ` 77 | query { 78 | result: getPerson { 79 | age 80 | favoriteColor 81 | } 82 | } 83 | `, 84 | expectedResult: { 85 | age: 100, 86 | favoriteColor: "green", 87 | }, 88 | context: { role: "USER" }, 89 | }, 90 | { 91 | message: "Query.getNoDirective: resolves value", 92 | query: ` 93 | query { 94 | result: getNoDirective 95 | } 96 | `, 97 | expectedResult: "some string", 98 | }, 99 | ]; 100 | 101 | module.exports = { 102 | schema, 103 | testCases, 104 | }; 105 | -------------------------------------------------------------------------------- /tests/integration-test-cases/upper-case/on-object.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require("graphql-tools"); 2 | const { upperCase } = require("./directive"); 3 | 4 | const typeDefs = ` 5 | directive @upperCase on OBJECT 6 | 7 | type Query { 8 | getNoDirective: String! # should not affect 9 | getPerson: Person! 10 | getPeople: [Person!]! 11 | } 12 | 13 | type Person @upperCase { 14 | name: String! 15 | favoriteColor: String! 16 | age: Int! # should not affect 17 | } 18 | `; 19 | 20 | const people = [ 21 | { 22 | age: 100, 23 | name: "vamp", 24 | favoriteColor: "green", 25 | }, 26 | { 27 | age: 100, 28 | name: "witch", 29 | favoriteColor: "red", 30 | }, 31 | ]; 32 | 33 | const resolvers = { 34 | Query: { 35 | getPerson: () => people[0], 36 | getPeople: () => people, 37 | getNoDirective: () => "lowercase", 38 | }, 39 | }; 40 | 41 | const schema = makeExecutableSchema({ 42 | typeDefs, 43 | resolvers, 44 | schemaDirectives: { upperCase }, 45 | }); 46 | 47 | const personFieldsFragment = ` 48 | fragment PersonFields on Person { 49 | age 50 | name 51 | favoriteColor 52 | } 53 | `; 54 | 55 | const testCases = [ 56 | { 57 | message: 58 | "Query.getPeople (list): each Person String field returned in upper case", 59 | query: ` 60 | ${personFieldsFragment} 61 | 62 | query { 63 | result: getPeople { 64 | ...PersonFields 65 | } 66 | } 67 | `, 68 | expectedResult: [ 69 | { 70 | age: 100, 71 | name: "VAMP", 72 | favoriteColor: "GREEN", 73 | }, 74 | { 75 | age: 100, 76 | name: "WITCH", 77 | favoriteColor: "RED", 78 | }, 79 | ], 80 | }, 81 | { 82 | message: "Query.getPerson: Person String fields returned in upper case", 83 | query: ` 84 | ${personFieldsFragment} 85 | 86 | query { 87 | result: getPerson { 88 | ...PersonFields 89 | } 90 | } 91 | `, 92 | expectedResult: { 93 | age: 100, 94 | name: "VAMP", 95 | favoriteColor: "GREEN", 96 | }, 97 | }, 98 | { 99 | message: "Query.getNoDirective: returned in lower case", 100 | query: ` 101 | query { 102 | result: getNoDirective 103 | } 104 | `, 105 | expectedResult: "lowercase", 106 | }, 107 | ]; 108 | 109 | module.exports = { 110 | schema, 111 | testCases, 112 | }; 113 | -------------------------------------------------------------------------------- /src/create-directive.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign, no-underscore-dangle */ 2 | const { defaultFieldResolver } = require("graphql"); 3 | const { SchemaDirectiveVisitor } = require("graphql-tools"); 4 | 5 | require("./jsdoc-types"); 6 | const { 7 | shouldApplyToField, 8 | markDirectiveApplied, 9 | validateDirectiveConfig, 10 | } = require("./utils"); 11 | 12 | /** 13 | * @param {import('./jsdoc-types.js').DirectiveConfig} directiveConfig 14 | */ 15 | const createDirective = (directiveConfig) => { 16 | const { 17 | name, 18 | resolverReplacer, 19 | hooks: { onVisitObject, onVisitFieldDefinition, onApplyDirective } = {}, 20 | } = validateDirectiveConfig(directiveConfig); 21 | 22 | return class ApolloDirective extends SchemaDirectiveVisitor { 23 | visitObject(objectType) { 24 | if (onVisitObject) { 25 | onVisitObject(this.createDirectiveContext(objectType)); 26 | } 27 | 28 | this.applyToObject(objectType); 29 | } 30 | 31 | visitFieldDefinition(field, details) { 32 | const { objectType } = details; 33 | 34 | if (onVisitFieldDefinition) { 35 | onVisitFieldDefinition(this.createDirectiveContext(objectType, field)); 36 | } 37 | 38 | this.replaceFieldResolver(objectType, field); 39 | } 40 | 41 | createDirectiveContext(objectType, field) { 42 | return { 43 | field, 44 | objectType, 45 | name: this.name, 46 | args: this.args, 47 | }; 48 | } 49 | 50 | replaceFieldResolver(objectType, field, applyingAtObjectLevel = false) { 51 | // if the directive has already been applied to the field exit early 52 | // prevents duplicate application of the directive 53 | if (!shouldApplyToField(field, name, applyingAtObjectLevel)) return; 54 | 55 | if (onApplyDirective) { 56 | onApplyDirective(this.createDirectiveContext(objectType, field)); 57 | } 58 | 59 | // mapped scalar fields (without custom resolvers) will use the defaultFieldResolver 60 | const originalResolver = field.resolve || defaultFieldResolver; 61 | 62 | field.resolve = resolverReplacer( 63 | originalResolver, 64 | this.createDirectiveContext(objectType, field), 65 | ); 66 | 67 | markDirectiveApplied(field, name); 68 | } 69 | 70 | applyToObject(objectType) { 71 | const fields = objectType.getFields(); 72 | 73 | Object.values(fields).forEach((field) => { 74 | this.replaceFieldResolver(objectType, field, true); 75 | }); 76 | } 77 | }; 78 | }; 79 | 80 | module.exports = createDirective; 81 | -------------------------------------------------------------------------------- /tests/integration-test-cases/auth/on-combined.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require("graphql-tools"); 2 | const { auth, typeDef } = require("./directive"); 3 | 4 | const typeDefs = ` 5 | ${typeDef("OBJECT | FIELD_DEFINITION")} 6 | 7 | type Query { 8 | getNoDirective: String! # should not affect 9 | getPerson: Person! 10 | getMessages: [String!]! @auth(require: [SELF]) 11 | } 12 | 13 | type Person @auth { 14 | age: Int! 15 | name: String! 16 | favoriteColor: String! 17 | } 18 | `; 19 | 20 | const person = { 21 | age: 100, 22 | name: "vamp", 23 | favoriteColor: "green", 24 | }; 25 | 26 | const messages = [ 27 | "hello, Clarice", 28 | "it could grip it by the husk! it's not a question of where it grips it...", 29 | ]; 30 | 31 | const resolvers = { 32 | Query: { 33 | getPerson: () => person, 34 | getMessages: () => messages, 35 | getNoDirective: () => "some string", 36 | }, 37 | }; 38 | 39 | const schema = makeExecutableSchema({ 40 | typeDefs, 41 | resolvers, 42 | schemaDirectives: { auth }, 43 | }); 44 | 45 | const personFieldsFragment = ` 46 | fragment PersonFields on Person { 47 | age 48 | name 49 | favoriteColor 50 | } 51 | `; 52 | 53 | const testCases = [ 54 | { 55 | message: "Query.getPerson, role = ADMIN: Person fields are resolved", 56 | query: ` 57 | ${personFieldsFragment} 58 | 59 | query { 60 | result: getPerson { 61 | ...PersonFields 62 | } 63 | } 64 | `, 65 | expectedResult: person, 66 | context: { role: "ADMIN" }, 67 | }, 68 | { 69 | message: "Query.getPerson, role = USER: throws authorization Error", 70 | query: ` 71 | ${personFieldsFragment} 72 | 73 | query { 74 | result: getPerson { 75 | ...PersonFields 76 | } 77 | } 78 | `, 79 | expectErrors: true, 80 | context: { role: "USER" }, 81 | }, 82 | { 83 | message: "Query.getMessages, role = SELF: resolves messages", 84 | query: ` 85 | query { 86 | result: getMessages 87 | } 88 | `, 89 | expectedResult: messages, 90 | context: { role: "SELF" }, 91 | }, 92 | { 93 | message: "Query.getMessages, role = ADMIN: throws authorization Error", 94 | query: ` 95 | query { 96 | result: getMessages 97 | } 98 | `, 99 | expectErrors: true, 100 | context: { role: "ADMIN" }, 101 | }, 102 | { 103 | message: "Query.getNoDirective: resolves value", 104 | query: ` 105 | query { 106 | result: getNoDirective 107 | } 108 | `, 109 | expectedResult: "some string", 110 | }, 111 | ]; 112 | 113 | module.exports = { 114 | schema, 115 | testCases, 116 | }; 117 | -------------------------------------------------------------------------------- /tests/integration-test-cases/auth/with-conflicting-args.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require("graphql-tools"); 2 | const { auth, typeDef } = require("./directive"); 3 | 4 | const typeDefs = ` 5 | ${typeDef("OBJECT | FIELD_DEFINITION")} 6 | 7 | type Query { 8 | getNoDirective: String! # should not affect 9 | getPerson: Person! 10 | } 11 | 12 | type Person @auth(require: [ADMIN, SELF]) { 13 | age: Int! @auth(require: [SELF]) 14 | name: String! 15 | favoriteColor: String! 16 | } 17 | `; 18 | 19 | const person = { 20 | age: 100, 21 | name: "vamp", 22 | favoriteColor: "green", 23 | }; 24 | 25 | const resolvers = { 26 | Query: { 27 | getPerson: () => person, 28 | getNoDirective: () => "some string", 29 | }, 30 | }; 31 | 32 | const schema = makeExecutableSchema({ 33 | typeDefs, 34 | resolvers, 35 | schemaDirectives: { auth }, 36 | }); 37 | 38 | const personFieldsFragment = ` 39 | fragment PersonFields on Person { 40 | age 41 | name 42 | favoriteColor 43 | } 44 | `; 45 | 46 | const testCases = [ 47 | { 48 | message: 49 | "Query.getPerson, role = SELF: directive args [SELF] precedence given to higher Field level specificity", 50 | query: ` 51 | ${personFieldsFragment} 52 | 53 | query { 54 | result: getPerson { 55 | ...PersonFields 56 | } 57 | } 58 | `, 59 | expectedResult: person, 60 | context: { role: "SELF" }, 61 | }, 62 | { 63 | message: 64 | "Query.getPerson, role = ADMIN: throws authorization Error, directive args [SELF] given to higher Field level specificity", 65 | query: ` 66 | ${personFieldsFragment} 67 | 68 | query { 69 | result: getPerson { 70 | ...PersonFields 71 | } 72 | } 73 | `, 74 | expectErrors: true, 75 | context: { role: "ADMIN" }, 76 | }, 77 | { 78 | message: 79 | "Query.getPerson.[name, favoriteColor], role = ADMIN: resolves values", 80 | query: ` 81 | query { 82 | result: getPerson { 83 | name 84 | favoriteColor 85 | } 86 | } 87 | `, 88 | expectedResult: { 89 | name: "vamp", 90 | favoriteColor: "green", 91 | }, 92 | context: { role: "ADMIN" }, 93 | }, 94 | { 95 | message: "Query.getPerson, role = USER: throws authorization Error", 96 | query: ` 97 | ${personFieldsFragment} 98 | 99 | query { 100 | result: getPerson { 101 | ...PersonFields 102 | } 103 | } 104 | `, 105 | expectErrors: true, 106 | context: { role: "USER" }, 107 | }, 108 | { 109 | message: "Query.getNoDirective: resolves value", 110 | query: ` 111 | query { 112 | result: getNoDirective 113 | } 114 | `, 115 | expectedResult: "some string", 116 | }, 117 | ]; 118 | 119 | module.exports = { 120 | schema, 121 | testCases, 122 | }; 123 | -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign, no-underscore-dangle */ 2 | 3 | const { 4 | shouldApplyToField, 5 | markDirectiveApplied, 6 | validateDirectiveConfig, 7 | } = require("../src/utils"); 8 | 9 | describe("utility functions", () => { 10 | describe("validateDirectiveConfig: ", () => { 11 | const directiveConfig = { 12 | name: "directiveName", 13 | resolverReplacer: () => {}, 14 | }; 15 | 16 | test("valid directiveConfig: returns the directiveConfig", () => expect(validateDirectiveConfig(directiveConfig)).toEqual( 17 | directiveConfig, 18 | )); 19 | 20 | describe("failure cases: throws an Error for missing or invalid required properties", () => { 21 | [ 22 | { 23 | message: "missing name", 24 | config: { ...directiveConfig, name: undefined }, 25 | }, 26 | { 27 | message: "missing resolverReplacer", 28 | config: { ...directiveConfig, resolverReplacer: undefined }, 29 | }, 30 | { 31 | message: "name is not a string", 32 | config: { ...directiveConfig, name: 5 }, 33 | }, 34 | { 35 | message: "resolverReplacer is not a function", 36 | config: { ...directiveConfig, resolverReplacer: "not a func" }, 37 | }, 38 | ].forEach(testCase => test(testCase.message, () => { 39 | expect(() => validateDirectiveConfig(testCase.config)).toThrow(); 40 | })); 41 | }); 42 | }); 43 | 44 | describe("shouldApplyToField: checks if the directive should be applied to the field", () => { 45 | const directiveName = "admin"; 46 | 47 | it("return true and sets the _appliedDirectives object property on the field if it is not already applied", () => { 48 | const field = {}; 49 | expect(shouldApplyToField(field, directiveName)).toBe(true); 50 | expect(field._appliedDirectives).toEqual({}); 51 | }); 52 | 53 | it("returns false if the directive has already been applied at the Field level and is attempting to be re-applied at the Object Type level", () => { 54 | const field = { _appliedDirectives: { [directiveName]: true } }; 55 | expect(shouldApplyToField(field, directiveName, true)).toBe(false); 56 | }); 57 | }); 58 | 59 | describe("markDirectiveApplied: sets a directive applied flag onto the directive target", () => { 60 | const directiveName = "upperCase"; 61 | 62 | it("attaches an _appliedDirectives object property to an unmarked target and flags the directive name as applied", () => { 63 | const directiveTarget = {}; 64 | markDirectiveApplied(directiveTarget, directiveName); 65 | expect(directiveTarget._appliedDirectives[directiveName]).toBe(true); 66 | }); 67 | 68 | it("flags the new directive name as applied to a target that has had other directives applied to it previously", () => { 69 | const directiveTarget = { _appliedDirectives: { admin: true } }; 70 | markDirectiveApplied(directiveTarget, directiveName); 71 | expect(directiveTarget._appliedDirectives).toEqual({ 72 | admin: true, 73 | [directiveName]: true, 74 | }); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/integration.test.js: -------------------------------------------------------------------------------- 1 | const { graphql } = require("graphql"); 2 | const { 3 | auth, 4 | upperCase, 5 | multipleDirectives, 6 | } = require("./integration-test-cases"); 7 | 8 | const runQueryTest = (schema, testCase) => { 9 | const { 10 | query, 11 | message, 12 | context = {}, 13 | expectedResult, 14 | expectErrors = false, 15 | } = testCase; 16 | 17 | test(message, async () => { 18 | const response = await graphql(schema, query, null, context); 19 | if (expectErrors) { 20 | expect(response.errors).toBeDefined(); 21 | } else { 22 | expect(response.data.result).toEqual(expectedResult); 23 | } 24 | }); 25 | }; 26 | 27 | describe("integration tests: applying schema directives and executing live queries", () => { 28 | describe("directive with no args: @upperCase", () => { 29 | const { onObject, onCombined, onFieldDefinition } = upperCase; 30 | 31 | describe("on OBJECT: @upperCase on Person", () => { 32 | const { schema, testCases } = onObject; 33 | testCases.forEach(testCase => runQueryTest(schema, testCase)); 34 | }); 35 | 36 | describe("on FIELD_DEFINITION: @upperCase on Person.name, Query.getString", () => { 37 | const { schema, testCases } = onFieldDefinition; 38 | testCases.forEach(testCase => runQueryTest(schema, testCase)); 39 | }); 40 | 41 | describe("on OBJECT | FIELD_DEFINITION: @upperCase on Person, Query.getString", () => { 42 | const { schema, testCases } = onCombined; 43 | testCases.forEach(testCase => runQueryTest(schema, testCase)); 44 | }); 45 | }); 46 | 47 | describe("directive with args: @auth(require: [Role] = [ADMIN])", () => { 48 | const { 49 | onObject, 50 | onCombined, 51 | onFieldDefinition, 52 | withConflictingArgs, 53 | } = auth; 54 | 55 | describe("on OBJECT: @auth (default [ADMIN]) on Person", () => { 56 | const { schema, testCases } = onObject; 57 | testCases.forEach(testCase => runQueryTest(schema, testCase)); 58 | }); 59 | 60 | describe("on FIELD_DEFINITION: @auth(require: [SELF, ADMIN]) on Person.name", () => { 61 | const { schema, testCases } = onFieldDefinition; 62 | testCases.forEach(testCase => runQueryTest(schema, testCase)); 63 | }); 64 | 65 | describe("on OBJECT | FIELD_DEFINITION: @auth (default [ADMIN]) on Person, @auth(require: [SELF]) on Query.getMessages", () => { 66 | const { schema, testCases } = onCombined; 67 | testCases.forEach(testCase => runQueryTest(schema, testCase)); 68 | }); 69 | 70 | describe("with conflicting args: @auth(require: [ADMIN, SELF]) on Person, @auth(require: [SELF]) on Person.age", () => { 71 | const { schema, testCases } = withConflictingArgs; 72 | testCases.forEach(testCase => runQueryTest(schema, testCase)); 73 | }); 74 | }); 75 | 76 | describe("multiple directives and locations using createSchemaDirectives", () => { 77 | describe("@auth on Person, @upperCase on Person.name, @auth(require: [SELF]) @upperCase on Query.getMessages", () => { 78 | const { schema, testCases } = multipleDirectives; 79 | testCases.forEach(testCase => runQueryTest(schema, testCase)); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tests/integration-test-cases/multiple-directives.js: -------------------------------------------------------------------------------- 1 | const { makeExecutableSchema } = require("graphql-tools"); 2 | const { createSchemaDirectives } = require("../../src"); 3 | 4 | const auth = require("./auth/directive"); 5 | const upperCase = require("./upper-case/directive"); 6 | 7 | const typeDefs = ` 8 | directive @upperCase on FIELD_DEFINITION | OBJECT 9 | ${auth.typeDef("OBJECT | FIELD_DEFINITION")} 10 | 11 | type Query { 12 | getNoDirective: String! # should not affect 13 | getPerson: Person! 14 | getMessages: [String!]! @auth(require: [SELF]) @upperCase 15 | } 16 | 17 | type Person @auth { 18 | age: Int! 19 | name: String! @upperCase 20 | favoriteColor: String! 21 | } 22 | `; 23 | 24 | const person = { 25 | age: 100, 26 | name: "vamp", 27 | favoriteColor: "green", 28 | }; 29 | 30 | const messages = [ 31 | "hello, Clarice", 32 | "it could grip it by the husk! it's not a question of where it grips it...", 33 | ]; 34 | 35 | const resolvers = { 36 | Query: { 37 | getPerson: () => person, 38 | getMessages: () => messages, 39 | getNoDirective: () => "some string", 40 | }, 41 | }; 42 | 43 | const schema = makeExecutableSchema({ 44 | typeDefs, 45 | resolvers, 46 | schemaDirectives: createSchemaDirectives({ 47 | directiveConfigs: [auth.directiveConfig, upperCase.directiveConfig], 48 | }), 49 | }); 50 | 51 | const personFieldsFragment = ` 52 | fragment PersonFields on Person { 53 | age 54 | name 55 | favoriteColor 56 | } 57 | `; 58 | 59 | const testCases = [ 60 | { 61 | message: 62 | "Query.getPerson, role = ADMIN: Person fields are resolved with Person.name in upper case", 63 | query: ` 64 | ${personFieldsFragment} 65 | 66 | query { 67 | result: getPerson { 68 | ...PersonFields 69 | } 70 | } 71 | `, 72 | expectedResult: { 73 | ...person, 74 | name: person.name.toUpperCase(), 75 | }, 76 | context: { role: "ADMIN" }, 77 | }, 78 | { 79 | message: "Query.getPerson, role = USER: throws authorization Error", 80 | query: ` 81 | ${personFieldsFragment} 82 | 83 | query { 84 | result: getPerson { 85 | ...PersonFields 86 | } 87 | } 88 | `, 89 | expectErrors: true, 90 | context: { role: "USER" }, 91 | }, 92 | { 93 | message: "Query.getMessages, role = SELF: resolves messages in upper case", 94 | query: ` 95 | query { 96 | result: getMessages 97 | } 98 | `, 99 | expectedResult: messages.map(message => message.toUpperCase()), 100 | context: { role: "SELF" }, 101 | }, 102 | { 103 | message: "Query.getMessages, role = ADMIN: throws authorization Error", 104 | query: ` 105 | query { 106 | result: getMessages 107 | } 108 | `, 109 | expectErrors: true, 110 | context: { role: "ADMIN" }, 111 | }, 112 | { 113 | message: "Query.getNoDirective: resolves value", 114 | query: ` 115 | query { 116 | result: getNoDirective 117 | } 118 | `, 119 | expectedResult: "some string", 120 | }, 121 | ]; 122 | 123 | module.exports = { 124 | schema, 125 | testCases, 126 | }; 127 | -------------------------------------------------------------------------------- /tests/create-directive.test.js: -------------------------------------------------------------------------------- 1 | const { createDirective } = require("../src"); 2 | 3 | const { 4 | shouldApplyToField, 5 | markDirectiveApplied, 6 | validateDirectiveConfig, 7 | } = require("../src/utils"); 8 | 9 | jest.mock("../src/utils.js", () => ({ 10 | shouldApplyToField: jest.fn(), 11 | markDirectiveApplied: jest.fn(), 12 | validateDirectiveConfig: jest.fn(config => config), 13 | })); 14 | 15 | /* eslint-disable no-param-reassign */ 16 | const mockMethods = (directiveInstance, methodsToMock) => { 17 | Object.keys(methodsToMock).forEach((methodName) => { 18 | directiveInstance[methodName] = jest.fn(); 19 | }); 20 | }; 21 | const replaceMethods = (directiveInstance, originalMethods) => { 22 | Object.entries(originalMethods).forEach((entry) => { 23 | const [methodName, originalMethod] = entry; 24 | directiveInstance[methodName] = originalMethod; 25 | }); 26 | }; 27 | 28 | const resolverReplacer = jest.fn(); 29 | const directiveName = "directiveName"; 30 | const objectType = { name: "Query " }; 31 | const field = { name: "getPerson", resolve: "original resolver" }; 32 | const directiveArgs = { first: "first arg", second: ["second", "arg", "list"] }; 33 | 34 | describe("core export: createDirective", () => { 35 | const directiveConfig = { 36 | name: directiveName, 37 | resolverReplacer, 38 | hooks: { 39 | onVisitObject: jest.fn(), 40 | onApplyDirective: jest.fn(), 41 | onVisitFieldDefinition: jest.fn(), 42 | }, 43 | }; 44 | 45 | const ApolloDirective = createDirective(directiveConfig); 46 | const directiveInstance = new ApolloDirective({ 47 | name: directiveName, 48 | args: directiveArgs, 49 | }); 50 | 51 | it("validates the directiveConfig object and returns an ApolloDirective class", () => { 52 | expect(validateDirectiveConfig).toHaveBeenCalled(); 53 | expect(directiveInstance.constructor.name).toBe("ApolloDirective"); 54 | }); 55 | 56 | it("can be used without providing hooks", () => { 57 | const hooklessDirective = new (createDirective({ 58 | ...directiveConfig, 59 | hooks: undefined, 60 | }))({ name: directiveName, args: directiveArgs }); 61 | 62 | expect(hooklessDirective.constructor.name).toBe("ApolloDirective"); 63 | }); 64 | 65 | describe("visitObject behavior", () => { 66 | const { applyToObject, createDirectiveContext } = directiveInstance; 67 | 68 | beforeAll(() => { 69 | jest.clearAllMocks(); 70 | mockMethods(directiveInstance, { applyToObject, createDirectiveContext }); 71 | directiveInstance.visitObject(objectType); 72 | }); 73 | afterAll(() => replaceMethods(directiveInstance, { 74 | applyToObject, 75 | createDirectiveContext, 76 | })); 77 | 78 | it("calls onVisitObject hook providing it the directiveContext with objectType", () => { 79 | expect(directiveInstance.createDirectiveContext).toHaveBeenCalledWith( 80 | objectType, 81 | ); 82 | expect(directiveConfig.hooks.onVisitObject).toHaveBeenCalledWith( 83 | directiveInstance.createDirectiveContext(), 84 | ); 85 | }); 86 | 87 | it("requests the directive be applied to the objectType object", () => expect(directiveInstance.applyToObject).toHaveBeenCalledWith(objectType)); 88 | }); 89 | 90 | describe("visitFieldDefinition behavior", () => { 91 | const { replaceFieldResolver, createDirectiveContext } = directiveInstance; 92 | 93 | beforeAll(() => { 94 | jest.clearAllMocks(); 95 | mockMethods(directiveInstance, { 96 | replaceFieldResolver, 97 | createDirectiveContext, 98 | }); 99 | directiveInstance.visitFieldDefinition(field, { objectType }); 100 | }); 101 | afterAll(() => replaceMethods(directiveInstance, { 102 | replaceFieldResolver, 103 | createDirectiveContext, 104 | })); 105 | 106 | it("calls onVisitFieldDefinition hook providing it the directiveContext with objectType and field", () => { 107 | expect(directiveInstance.createDirectiveContext).toHaveBeenCalledWith( 108 | objectType, 109 | field, 110 | ); 111 | expect(directiveConfig.hooks.onVisitFieldDefinition).toHaveBeenCalledWith( 112 | directiveInstance.createDirectiveContext(), 113 | ); 114 | }); 115 | 116 | it("requests the directive be applied to the field object", () => expect(directiveInstance.replaceFieldResolver).toHaveBeenCalledWith( 117 | objectType, 118 | field, 119 | )); 120 | }); 121 | 122 | describe("createDirectiveContext: builds a context object with information about the directive", () => { 123 | const expectedContext = { 124 | objectType, 125 | name: directiveName, 126 | args: directiveArgs, 127 | }; 128 | 129 | test("given an objectType: returns { objectType, name, args }", () => expect(directiveInstance.createDirectiveContext(objectType)).toEqual( 130 | expectedContext, 131 | )); 132 | test("given an objectType and field: returns { objectType, field, name, args }", () => expect( 133 | directiveInstance.createDirectiveContext(objectType, field), 134 | ).toEqual({ 135 | field, 136 | ...expectedContext, 137 | })); 138 | }); 139 | 140 | describe("applyToObject behavior", () => { 141 | const { replaceFieldResolver } = directiveInstance; 142 | const mockObjectFields = { 143 | first: "first", 144 | second: "second", 145 | third: "third", 146 | }; 147 | const mockObjectType = { getFields: () => mockObjectFields }; 148 | 149 | beforeAll(() => { 150 | jest.clearAllMocks(); 151 | mockMethods(directiveInstance, { replaceFieldResolver }); 152 | directiveInstance.applyToObject(mockObjectType); 153 | }); 154 | afterAll(() => replaceMethods(directiveInstance, { replaceFieldResolver })); 155 | 156 | // see utils.shouldApplyToField for notes 157 | it("sets the replaceFieldResolver method flag indicating the directive application is occurring from the Object Type level", () => expect(directiveInstance.replaceFieldResolver).toHaveBeenCalledWith( 158 | mockObjectType, 159 | mockObjectFields.first, 160 | true, 161 | )); 162 | 163 | it("requests the directive be applied for each of the objectType's fields", () => { 164 | const numFields = Object.keys(mockObjectFields).length; 165 | expect(directiveInstance.replaceFieldResolver).toHaveBeenCalledTimes( 166 | numFields, 167 | ); 168 | }); 169 | }); 170 | 171 | describe("replaceFieldResolver behavior", () => { 172 | const { createDirectiveContext } = directiveInstance; 173 | beforeAll(() => mockMethods(directiveInstance, { createDirectiveContext })); 174 | afterAll(() => replaceMethods(directiveInstance, { createDirectiveContext })); 175 | 176 | describe("when the directive has already been applied to the field and is being reapplied from the Object Type level", () => { 177 | it("exits early", () => { 178 | shouldApplyToField.mockImplementationOnce(() => false); 179 | directiveInstance.replaceFieldResolver(objectType, field, true); 180 | expect(markDirectiveApplied).not.toHaveBeenCalled(); 181 | }); 182 | }); 183 | 184 | describe("when the directive should be applied to the field", () => { 185 | let originalResolver; 186 | beforeAll(() => { 187 | originalResolver = field.resolve; 188 | shouldApplyToField.mockImplementationOnce(() => true); 189 | directiveInstance.replaceFieldResolver(objectType, field); 190 | }); 191 | 192 | it("calls onApplyDirective hook providing it the directiveContext with objectType and field", () => { 193 | expect(directiveInstance.createDirectiveContext).toHaveBeenCalledWith( 194 | objectType, 195 | field, 196 | ); 197 | expect(directiveConfig.hooks.onApplyDirective).toHaveBeenCalledWith( 198 | directiveInstance.createDirectiveContext(), 199 | ); 200 | }); 201 | 202 | it("replaces the field's original resolver using the resolverReplacer providing it the originalResolver and directiveContext", () => { 203 | expect(resolverReplacer).toHaveBeenCalledWith( 204 | originalResolver, 205 | directiveInstance.createDirectiveContext(), 206 | ); 207 | }); 208 | 209 | it("marks the field as having the directive applied", () => expect(markDirectiveApplied).toHaveBeenCalled()); 210 | }); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/the-vampiire/apollo-directive.svg?branch=master)](https://travis-ci.org/the-vampiire/apollo-directive) [![Coverage Status](https://coveralls.io/repos/github/the-vampiire/apollo-directive/badge.svg)](https://coveralls.io/github/the-vampiire/apollo-directive) [![NPM Package](https://img.shields.io/npm/v/apollo-directive.svg?label=NPM:%20apollo-directive)](https://npmjs.org/apollo-directive) 2 | 3 | ### Finally an easy way to create custom GraphQL directives! With this package creating a custom schema directive is as easy as writing any other Apollo resolver. 4 | 5 | This library aims to resolve this quote, and commonly shared opinion, from the [Schema Directives docs](https://www.apollographql.com/docs/graphql-tools/schema-directives/#using-schema-directives): 6 | 7 | > ...some of the examples may seem quite complicated. No matter how many tools and best practices you have at your disposal, it can be difficult to implement a non-trivial schema directive in a reliable, reusable way. 8 | 9 | # concept 10 | 11 | Implementing a custom schema directive used to be a very tedious and confusing process. With the addition of the `graphql-tools` `SchemaVisitor` class a big leap in the direction of usability was made. But there was still a lot of uncertainty about how it could be used, especially for beginners to GraphQL. Many authors opted for simpler alternatives like higher order function resolver wrappers that behaved like directives. These wrappers, while simple, are undocumented in the schema and often require repetitive application and upkeep throughout the codebase. 12 | 13 | What are the benefits of implementing directives vs using higher order resolver wrappers? 14 | 15 | - your directives are officially documented as part of the schema itself 16 | - write its resolver once and use it any number of times by simply `@directive` tagging Types and Type Fields in your schema that you want to apply it to 17 | - no more concerns of forgetting to wrap a resolver leading to unexpected behavior 18 | - there is no "hidden" magic that requires digging throughout the resolvers to understand 19 | 20 | This library makes implementing directives as simple as writing any other resolver in your Apollo Server. For those authors who are currently using higher order resolver wrappers transitioning to using directives is trivial. 21 | 22 | # current support 23 | 24 | - directive targets (covers the vast majority of use cases): 25 | - `OBJECT`: directives applied to `Type` definitions 26 | - the directive is applied to all the fields of the Object Type it is tagged on 27 | - `FIELD_DEFINITION`: directives applied to `Type.field` definitions 28 | - the directive is applied only to the specific Object Type Field it is tagged on 29 | - note this includes `Query.queryName` and `Mutation.mutationName` because `Query` and `Mutation` are considered Object Types 30 | - directive arguments 31 | - unit and integration tests are available in the `tests/` directory. the integration tests also serve as example implementations and can be run with 32 | 33 | ```sh 34 | # all tests 35 | $ npm test 36 | 37 | # integration tests 38 | $ npm run test:integration 39 | ``` 40 | 41 | # usage 42 | 43 | ```sh 44 | $ npm install apollo-directive 45 | ``` 46 | 47 | - once you have [written the directive type def](#schema-directive-type-definitions-and-usage) you can implement its resolver using one of the two package utilities: `createDirective` or `createSchemaDirectives` 48 | - both tools make use of a [`directiveConfig` object](#directive-config) 49 | 50 | ```js 51 | const directiveConfig = { 52 | name: string, // required, the directive name 53 | resolverReplacer: function, // required, see details below 54 | hooks: { function, ... }, // optional, see details below 55 | }; 56 | ``` 57 | 58 | ## resolverReplacer and directiveResolver 59 | 60 | ```js 61 | const resolverReplacer = (originalResolver, directiveContext) => 62 | async function directiveResolver(...resolverArgs) { 63 | // implement your directive logic in here 64 | 65 | // use any of the original resolver arguments as needed by destructuring 66 | const [root, args, context, info] = resolverArgs; 67 | 68 | // use the directive context as needed 69 | // access to information about the directive itself 70 | const { 71 | name, // the name of the directive 72 | objectType, // the Object Type the directive is applied to 73 | field, // the Object Type Field the directive is applied to 74 | // can be aliased to avoid namespace conflicts 75 | args: directiveArgs, // arguments supplied to the directive itself 76 | } = directiveContext; 77 | 78 | // you can execute the original resolver (to get its original return value): 79 | const result = originalResolver.apply(this, args); 80 | 81 | // or if the original resolver is async / returns a promise use await 82 | // if you use await dont forget to make the directiveResolver async! 83 | const result = await originalResolver.apply(this, args); 84 | 85 | // process the result as dictated by your directive 86 | 87 | // return a resolved value (this is what is sent back in the API response) 88 | return resolvedValue; 89 | }; 90 | ``` 91 | 92 | - the `resolverReplacer` and `directiveResolver` functions are used in a higher order function chain that returns a `resolvedValue` 93 | - `resolverReplacer` -> `directiveResolver` -> `resolvedValue` 94 | - this sounds complicated but as seen above the implementation on your end is as intuitive as writing any other resolver 95 | - `resolverReplacer` is used internally to replace the original resolver with your `directiveResolver` 96 | - used as a bridge between `apollo-directive` and your `directiveResolver` 97 | - brings the `originalResolver` and `directiveContext` parameters into the scope of your `directiveResolver` 98 | - the `directiveResolver` function receives the original field resolver's arguments 99 | - `(root, args, context, info)` 100 | - these can be abbreviated into an array as `(...resolverArgs)` to make using the `apply()` syntax easier (see below) 101 | - **the `directiveResolver` must be a function declaration not an arrow function** 102 | - executing the `originalResolver` must be done using the `apply` syntax 103 | 104 | ```js 105 | // resolverArgs: [root, args, context, info] 106 | result = originalResolver.apply(this, resolverArgs); 107 | 108 | // you can await if the original resolver is async / returns a promise 109 | result = await originalResolver.apply(this, resolverArgs); 110 | 111 | // if you dont spread the parameters in the directiveResolver 112 | // meaning you have directiveResolver(root, args, context, info) 113 | // they must be placed into an array in the .apply() call 114 | result = originalResolver.apply(this, [root, args, context, info]); 115 | ``` 116 | 117 | - boilerplates to get going quickly 118 | 119 | ```js 120 | // export the directiveConfig for use in createSchemaDirectives 121 | module.exports = { 122 | name, 123 | resolverReplacer: (originalResolver, directiveContext) => 124 | async function directiveResolver(...resolverArgs) { 125 | // implement directive logic 126 | // return the resolved value 127 | }, 128 | }; 129 | 130 | // export the created directive ready to be put into serverConfig.schemaDirectives object 131 | module.exports = createDirective({ 132 | name, 133 | resolverReplacer: (originalResolver, directiveContext) => 134 | async function directiveResolver(...resolverArgs) { 135 | // implement directive logic 136 | // return the resolved value 137 | }, 138 | }); 139 | ``` 140 | 141 | ## using createDirective 142 | 143 | - use for creating a single directive resolver 144 | - add the resolver to the Apollo Server `serverConfig.schemaDirectives` object 145 | - **the name must match the `` from the corresponding directive type definition in the schema** 146 | 147 | ```js 148 | const { ApolloServer } = require("apollo-server-X"); 149 | const { createDirective } = require("apollo-directives"); 150 | 151 | // assumes @admin directive type def has been added to schema 152 | 153 | const adminDirectiveConfig = { 154 | name: "admin", 155 | resolverReplacer: requireAdminReplacer, 156 | hooks: { /* optional hooks */ } 157 | }; 158 | 159 | const adminDirective = createDirective(adminDirectiveConfig); 160 | 161 | const server = new ApolloServer({ 162 | // typeDefs, resolvers, context, etc. 163 | ... 164 | schemaDirectives: { 165 | // the name key must match the directive name in the type defs, @admin in this case 166 | admin: adminDirective, 167 | }, 168 | }); 169 | ``` 170 | 171 | ## using createSchemaDirectives 172 | 173 | - accepts an array of [directive config](#directive-config) objects in `config.directiveConfigs` 174 | - assign the result to `serverConfig.schemaDirectives` in the Apollo Server constructor 175 | - creates each directive and provides them as the schemaDirectives object in `{ name: directiveResolver, ... }` form 176 | 177 | ```js 178 | const { ApolloServer } = require("apollo-server-X"); 179 | const { createSchemaDirectives } = require("apollo-directives"); 180 | 181 | // assumes @admin directive type def has been added to schema 182 | 183 | const adminDirectiveConfig = { 184 | name: "admin", // must match the name of the directive @ 185 | resolverReplacer: requireAdminReplacer, 186 | hooks: { /* optional hooks */ } 187 | }; 188 | 189 | const server = new ApolloServer({ 190 | // typeDefs, resolvers, context, etc. 191 | ... 192 | 193 | // pass an array of directive config objects to create the schemaDirectives object 194 | schemaDirectives: createSchemaDirectives({ 195 | directiveConfigs: [adminDirectiveConfig], 196 | }), // returns { name: directiveResolver, ... } 197 | }); 198 | ``` 199 | 200 | # directive config 201 | 202 | - `directiveConfig` is validated and will throw an Error for missing or invalid properties 203 | - shape 204 | 205 | ```js 206 | const directiveConfig = { 207 | name: string, // required, see details below 208 | resolverReplacer: function, // required, see signature below 209 | hooks: { function, ... }, // optional, see signatures below 210 | }; 211 | ``` 212 | 213 | ## resolverReplacer 214 | 215 | - a higher order function used to bridge information between `createDirective` and the directive logic in the `directiveResolver` 216 | - used in `createDirective` `config` parameter 217 | - **may not be** `async` 218 | - **must return a function that implements the `directiveResolver` signature** (the same as the standard Apollo resolver) 219 | - signature 220 | 221 | ```js 222 | resolverReplacer(originalResolver, directiveContext) -> 223 | directiveResolver(root, args, context, info) -> resolved value 224 | ``` 225 | 226 | ### directiveContext 227 | 228 | - the `directiveContext` object provides access to information about the directive itself 229 | - you can use this information in the `directiveResolver` as needed 230 | - see the [objectType] and [field] shapes 231 | 232 | ```js 233 | const { 234 | name, // the name of the directive 235 | objectType, // the Object Type the directive is applied to 236 | field, // the Object Type Field the directive is applied to 237 | // you can alias the args as directiveArgs to avoid naming conflicts in the directiveResolver 238 | args: directiveArgs, // object of arguments supplied to the directive itself 239 | } = directiveContext; 240 | ``` 241 | 242 | ### directiveResolver 243 | 244 | - a higher order function used to transform the result or behavior of the `originalResolver` 245 | - **must be a function declaration not an arrow function** 246 | - **may be** `async` if you need to work with promises 247 | - **must return** a valid resolved value (valid according to the schema) 248 | - for example if your schema dictates that the resolved value may not be `null` then you must support this rule by not returning `undefined` or `null` from the `directiveResolver` 249 | - signature: 250 | 251 | ```js 252 | directiveResolver(root, args, context, info) -> resolved value 253 | 254 | directiveResolver(...resolverArgs) -> resolved value 255 | ``` 256 | 257 | ## name 258 | 259 | - the name of the directive (same as the name in the directive type definition in the schema) 260 | - used for improving performance when directives are registered on server startup 261 | - added as `_DirectiveApplied` property on the `objectType` 262 | - you can read more from this [Apollo Docs: Schema Directives section](https://www.apollographql.com/docs/graphql-tools/schema-directives/#enforcing-access-permissions) 263 | - when using the `createSchemaDirectives` utility 264 | - used as the directive identifier in the `schemaDirectives` object 265 | - ex: directive type def `@admin` then `name = "admin"` 266 | 267 | ## hooks 268 | 269 | - provide access to each step of the process as the directive resolver is applied during server startup 270 | - purely observational, nothing returned from these functions is used 271 | - can be used for logging or debugging 272 | 273 | ### onVisitObject 274 | 275 | - called once for each Object Type definition that the directive has been applied to 276 | - called before the directive is applied to the Object Type 277 | - receives the [directiveContext](#directiveContext) object 278 | - note that `directiveContext.field` will be `undefined` for this hook 279 | - signature 280 | 281 | ```js 282 | onVisitObject(directiveContext) -> void 283 | ``` 284 | 285 | ### onVisitFieldDefinition 286 | 287 | - called once for each Object Type field definition that the directive has been applied to 288 | - called before the directive is applied to the field 289 | - receives the [directiveContext](#directiveContext) object 290 | - signature 291 | 292 | ```js 293 | onvisitFieldDefinition(directiveContext) -> void 294 | ``` 295 | 296 | ### onApplyDirective 297 | 298 | - called immediately before the directive is applied 299 | - directive applied to an Object Type (`on OBJECT`): called once for each field in the Object 300 | - directive applied to a field (`on FIELD_DEFINITION`): called once for the field 301 | - called after `onVisitObject` or `onVisitFieldDefinition` is executed 302 | - receives the [directiveContext](#directiveContext) object 303 | - technical note: using the directive name, `directiveConfig.name`, the internal method applying the directive will exit early for the following case: 304 | - directives that are applied to both an object and its individual field(s) will exit early to prevent duplicate application of the directive 305 | - `onApplyDirective` will not be called a second time for this case due to exiting early 306 | - this is a performance measure that you can read more about from this [Apollo Docs: Schema Directives section](https://www.apollographql.com/docs/graphql-tools/schema-directives/#enforcing-access-permissions) 307 | - signature 308 | 309 | ```js 310 | onApplyDirective(directiveContext) -> void; 311 | ``` 312 | 313 | # schema directive type definitions and usage 314 | 315 | - learn more about writing directive type defs or see the examples below 316 | - [official GraphQL Schema Directives spec](https://graphql.github.io/graphql-spec/draft/#sec-Type-System.Directives) 317 | - [apollo directives examples](https://www.apollographql.com/docs/graphql-tools/schema-directives/#implementing-schema-directives) 318 | 319 | ## creating schema directive type defs 320 | 321 | ```graphql 322 | # only able to tag Object Type Fields 323 | directive @ on FIELD_DEFINITION 324 | 325 | # only able to tag Object Types 326 | directive @ on OBJECT 327 | 328 | # able to tag Object Types and Object Type Fields 329 | directive @ on FIELD_DEFINITION | OBJECT 330 | 331 | # alternate accepted syntax 332 | directive @ on 333 | | FIELD_DEFINITION 334 | | OBJECT 335 | 336 | # adding a description to a directive 337 | """ 338 | directive description 339 | 340 | (can be multi-line) 341 | """ 342 | directive @ on FIELD_DEFINITION | OBJECT 343 | ``` 344 | 345 | ## using directives in your schema type defs 346 | 347 | - applying directives is as simple as "tagging" them onto an Object Type or one of its fields 348 | 349 | ```graphql 350 | # tagging an Object Type Field 351 | type SomeType { 352 | # the directive resolver is executed when access to the tagged field(s) is made 353 | aTaggedField: String @ 354 | } 355 | 356 | type Query { 357 | queryName: ReturnType @ 358 | } 359 | 360 | # tagging an Object Type 361 | type SomeType @ { 362 | # the directive is applied to every field in this Type 363 | # the directive resolver is executed when any access to this Type's fields (through queries / mutations / nesting) are made 364 | } 365 | 366 | # multiple directives can be tagged, space-separated 367 | type SomeType @firstDirective @secondDirective { 368 | # applying a directive to a list type must be to the right of the closing bracket 369 | aTaggedField: [TypeName] @ 370 | } 371 | ``` 372 | 373 | ## example of defining and using a schema directive 374 | 375 | - a basic example 376 | 377 | ```graphql 378 | """ 379 | returns all String scalar values in upper case 380 | """ 381 | directive @upperCase on FIELD_DEFINITION | OBJECT 382 | 383 | # the Object Type itself is tagged 384 | # all of the fields in this object will have the @upperCase directive applied 385 | type User @upperCase { 386 | id: ID! 387 | username: String! 388 | friends: [User!]! 389 | } 390 | 391 | type Dog { 392 | id: ID! 393 | # only Dog.streetAddress will have the directive applied 394 | streetAddress: String! @upperCase 395 | } 396 | ``` 397 | 398 | - a more complex example of an authentication / authorization directive 399 | - this directive can receive a `requires` argument with an array of `Role` enum elements 400 | - directives argument(s) are available in the `directiveResolver` through `directiveContext.args` 401 | - the `requires` argument has a default value set as `[ADMIN]` 402 | - if no argument is provided (just `@auth`) then this default argument will be provided as `["ADMIN"]` 403 | 404 | ```graphql 405 | # example of a directive to enforce authentication / authorization 406 | # you can provide a default value just like arguments to any other definition 407 | directive @auth(requires: [Role] = [ADMIN]) on FIELD_DEFINITION | OBJECT 408 | 409 | # assumes a ROLE enum has been defined 410 | enum Role { 411 | USER # any authenticated user 412 | SELF # the authenticated user only 413 | ADMIN # admins only 414 | } 415 | 416 | # apply the directive to an entire Object Type 417 | # because no argument is provided the default ([ADMIN]) is used 418 | type PaymentInfo @auth { 419 | # all of the fields in this Object Type will have the directive applied requiring ADMIN permissions 420 | } 421 | 422 | type User { 423 | # authorization for the authenticated user themself or an admin 424 | email: EmailAddress! @auth(requires: [SELF, ADMIN]) 425 | } 426 | ``` 427 | 428 | ## what targets should the directive be applied to? 429 | 430 | - note that queries and resolver type definitions are considered fields of the `Query` and `Mutation` Object Types 431 | - directive needs to transform the result of a resolver 432 | - tag the directive on a field 433 | - any access to the field will execute the directive 434 | - examples 435 | - upper case a value 436 | - translate a value 437 | - format a date string 438 | - directive needs to do some auxiliary behavior in a resolver 439 | - tag the directive on a field, object, or both 440 | - any queries that request values (directly or through nesting) from the tagged object and / or field will execute the directive 441 | - examples 442 | - enforcing authentication / authorization 443 | - logging 444 | 445 | # examples 446 | 447 | - annotated example from [Apollo Docs: Schema Directives - Uppercase String](https://www.apollographql.com/docs/graphql-tools/schema-directives/#uppercasing-strings) 448 | - corresponds to the following directive type def 449 | 450 | ```graphql 451 | directive @upperCase on FIELD_DEFINITION | OBJECT 452 | ``` 453 | 454 | ```js 455 | // the resolverReplacer function 456 | const upperCaseReplacer = (originalResolver, directiveContext) => 457 | // the directiveResolver function 458 | async function upperCaseResolver(...resolverArgs) { 459 | // execute the original resolver to store its output for directive processing below 460 | const result = await originalResolver.apply(this, resolverArgs); 461 | 462 | // return the a valid resolved value after directive processing 463 | if (typeof result === "string") { 464 | return result.toUpperCase(); 465 | } 466 | return result; 467 | }; 468 | 469 | module.exports = upperCaseReplacer; 470 | ``` 471 | 472 | # the objectType and field shapes 473 | 474 | - these two objects can be found in the [`directiveContext` object](#directiveContext) 475 | - provide access to information about the Object Type or Object Type Field the directive is being applied to 476 | - use the following shapes as a guide or use the [hooks](#hooks) to log these in more detail as needed 477 | - to expand the objects (incluidng AST nodes) in your log use `JSON.stringify(objectType | field, null, 2)` 478 | 479 | ## objectType 480 | 481 | - Object Type information 482 | 483 | ```js 484 | const { 485 | name, 486 | type, 487 | description, 488 | isDeprecated, 489 | deprecationReason, 490 | astNode, // AST object 491 | _fields, // the Object Type's fields { fieldName: fieldObject } 492 | } = objectType; 493 | ``` 494 | 495 | ## field 496 | 497 | - Object Type Field information 498 | 499 | ```js 500 | const { 501 | name, 502 | type, 503 | args: [{ 504 | name, 505 | type, 506 | description, 507 | defaultValue, 508 | astNode, 509 | }, ...], 510 | description, 511 | isDeprecated, 512 | deprecationReason, 513 | astNode, // AST object 514 | } = field; 515 | ``` 516 | 517 | ## astNode 518 | 519 | - it is unlikely you will need to access this property 520 | - this is a parsed object of the AST for the Object Type or Object Type Field 521 | 522 | ```js 523 | const { 524 | kind, 525 | description: { 526 | kind, 527 | value, 528 | block, 529 | loc: { start, end }, 530 | }, 531 | name: { 532 | kind, 533 | value, 534 | loc: { start, end }, 535 | }, 536 | interfaces: [], 537 | directives: [{ 538 | kind, 539 | name: { 540 | kind, 541 | value, 542 | loc: { start, end }, 543 | }, 544 | arguments: [{ 545 | kind, 546 | name: { 547 | kind, 548 | value, 549 | loc: { start, end }, 550 | } 551 | }, ...], 552 | }, ...], 553 | fields: [{ 554 | type, 555 | name, 556 | description, 557 | args, 558 | astNode: [ 559 | // for non-scalar types 560 | ] 561 | }, ...], 562 | } = astNode; 563 | ``` 564 | -------------------------------------------------------------------------------- /PROPOSAL.md: -------------------------------------------------------------------------------- 1 | # apollo-directives library proposal 2 | 3 | resources used to learn: 4 | 5 | - [Apollo Docs: Schema Directives](https://www.apollographql.com/docs/graphql-tools/schema-directives/) 6 | - [graphql-tools repo: SchemaVisitor class](https://github.com/apollographql/graphql-tools/blob/master/src/schemaVisitor.ts) 7 | 8 | the proposed library aims to resolve this quote, and commonly shared opinion, from the [Schema Directives docs](https://www.apollographql.com/docs/graphql-tools/schema-directives/#using-schema-directives): 9 | 10 | > ...some of the examples may seem quite complicated. No matter how many tools and best practices you have at your disposal, it can be difficult to implement a non-trivial schema directive in a reliable, reusable way. 11 | 12 | # documentation draft 13 | 14 | - currently supported directive targets: 15 | - `FIELD`: `Type.field`, `Query.queryName`, `Mutation.mutationName` 16 | - `OBJECT`: `Type`, `Query`, `Mutation` 17 | - **no current support for directive arguments** 18 | - **each directive resolver must have a corresponding type definition in the schema** 19 | - learn more about writing directive type defs 20 | - [official GraphQL directives spec]() 21 | - [apollo directives examples]() 22 | 23 | ## writing a directive type def 24 | 25 | ```graphql 26 | # only able to tag Object Type Fields 27 | directive @ on FIELD 28 | 29 | # only able to tag Object Types 30 | directive @ on OBJECT 31 | 32 | # able to tag Object Types and Type Fields 33 | directive @ on FIELD | OBJECT 34 | 35 | # alternate accepted syntax 36 | directive @ on 37 | | FIELD 38 | | OBJECT 39 | 40 | # adding a description to a directive 41 | """ 42 | directive description 43 | 44 | (can be multi-line) 45 | """ 46 | directive @ on FIELD | OBJECT 47 | 48 | ``` 49 | 50 | ## using a directive type def 51 | 52 | ```graphql 53 | # tagging an Object Type Field 54 | # directive is executed when access to the tagged field(s) is made 55 | type SomeType { 56 | aTaggedField: String @ 57 | } 58 | 59 | type Query { 60 | queryName: ReturnType @ 61 | } 62 | 63 | # tagging an Object Type 64 | type SomeType @ { 65 | # the directive is applied to every field in this Type 66 | # directive is executed when any access to this Type (through queries / mutations / nesting) is made 67 | } 68 | ``` 69 | 70 | ## what should the directive be applied to? 71 | 72 | - note that queries and resolver definitions are considered fields of the `Query` and `Mutation` objects 73 | - directive needs to transform the result of a resolver 74 | - tag the directive on a field 75 | - any access to the field will execute the directive 76 | - examples 77 | - upper case a value 78 | - translate a value 79 | - format a date string 80 | - directive needs to do some auxiliary behavior in a resolver 81 | - tag the directive on a field, object, or both 82 | - any queries that request values (directly or through nesting) from the tagged object and / or field will execute the directive 83 | - examples 84 | - enforcing authentication / authorization 85 | - logging 86 | 87 | ## using apollo-directives 88 | 89 | - once you have written the directive type def you can implement its resolver using `createDirective` or `createSchemaDirectives` 90 | - both tools make use of a [`directiveConfig` object](#directive-config) 91 | 92 | ```js 93 | const directiveConfig = { 94 | hooks: { function, ... }, // optional, see signatures below 95 | name: string, // required, see details below 96 | resolverReplacer: function, // required, see signature below 97 | }; 98 | ``` 99 | 100 | ### using createDirective 101 | 102 | - use for creating a single directive resolver 103 | - add the resolver to the Apollo Server `config.schemaDirectives` object 104 | - **the name must match the `` from the corresponding directive definition in the schema** 105 | 106 | ```js 107 | const { ApolloServer } = require("apollo-server-X"); 108 | const { createDirective } = require("apollo-directives"); 109 | 110 | // assumes @admin directive type def has been added to schema 111 | 112 | const adminDirectiveConfig = { 113 | name: "admin", 114 | /* 115 | assumes the following function has been implemented somewhere: 116 | 117 | requireAdmin(originalResolver, { objectType, field }) -> 118 | adminResolverWrapper(root, args, context, info) 119 | */ 120 | resolverReplacer: requireAdmin, 121 | hooks: { /* optional hooks */ } 122 | }; 123 | 124 | const adminDirective = createDirective(adminDirectiveConfig); 125 | 126 | const server = new ApolloServer({ 127 | // typeDefs, resolvers, context, etc. 128 | ... 129 | schemaDirectives: { 130 | // the name must match the directive name in the type defs, @admin in this case 131 | admin: adminDirective, 132 | }, 133 | }); 134 | ``` 135 | 136 | ### using createSchemaDirectives 137 | 138 | - accepts an array of [directive config](#directive-config) objects 139 | - assign the result to `serverConfig.schemaDirectives` in the Apollo Server constructor 140 | - creates each directive and provides them as the schemaDirectives object in `{ name: directiveConfig, ... }` form 141 | 142 | ```js 143 | const { ApolloServer } = require("apollo-server-X"); 144 | const { createSchemaDirectives } = require("apollo-directives"); 145 | 146 | // assumes @admin directive type def has been added to schema 147 | 148 | const adminDirectiveConfig = { 149 | name: "admin", 150 | /* 151 | assumes the following function has been implemented somewhere: 152 | 153 | requireAdmin(originalResolver, { objectType, field }) -> 154 | adminResolverWrapper(root, args, context, info) 155 | */ 156 | resolverReplacer: requireAdmin, 157 | hooks: { /* optional hooks */ } 158 | }; 159 | 160 | const server = new ApolloServer({ 161 | // typeDefs, resolvers, context, etc. 162 | ... 163 | 164 | // pass an array of directive config objects 165 | // creates each directive and provides them as the schemaDirectives object in { name: directiveConfig, ... } form 166 | schemaDirectives: createSchemaDirectives([adminDirectiveConfig]), 167 | }); 168 | ``` 169 | 170 | ### resolverReplacer and resolverWrapper 171 | 172 | - the `resolverReplacer` and `resolverWrapper` functions are used in a higher order function chain that returns a `resolvedValue` 173 | - `resolverReplacer` -> `resolverWrapper` -> `resolvedValue` 174 | - this sounds complicated but as seen below the implementation is intuitive 175 | - only the directive behavior logic needs to be written in `resolverWrapper` which returns a valid `resolvedValue` 176 | - `resolverReplacer` has a standard boilerplate 177 | - `resolverReplacer` curries (HoF term for carrying arguments through the chain) the `originalResolver` and `directiveContext` so they are in scope in `resolverWrapper` 178 | - the `resolverWrapper` function receives the original field resolver's arguments `(root, args, context, info)` 179 | - general example 180 | 181 | ```js 182 | // this is the resolverReplacer function boilerplate 183 | module.exports = (originalResolver, directiveContext) => 184 | // this is the resolverWrapper function that you implement 185 | function resolverWrapper(...args) { // put all the args into an array (makes it easier to use the .apply() syntax) 186 | 187 | // use any of the original resolver arguments as needed 188 | const [root, args, context, info] = args; 189 | 190 | // use the directive context as needed 191 | // access to information about the object or field that is being resolved 192 | const { objectType, field } = directiveContext; 193 | 194 | // implement directive logic 195 | 196 | // you can execute the original resolver (to get its return value): 197 | const result = originalResolver.apply(this, args); 198 | 199 | // or if the original resolver is async / returns a promise 200 | // if you use await dont forget to make the resolverWrapper async! 201 | const result = await originalResolver.apply(this, args); 202 | 203 | // process the result as dictated by your directive 204 | 205 | // return a resolved value (this is what is sent back in the API response) 206 | return resolvedValue; 207 | } 208 | ``` 209 | 210 | - annotated example from [Apollo Docs: Schema Directives - Uppercase String](https://www.apollographql.com/docs/graphql-tools/schema-directives/#uppercasing-strings) 211 | 212 | ```js 213 | // the resolverReplacer function 214 | const upperCaseReplacer = (originalResolver, { objectType, field }) => 215 | // the resolverWrapper function 216 | async function upperCaseResolver(...args) { 217 | // execute the original resolver to store its output 218 | const result = await originalResolver.apply(this, args); 219 | 220 | // return the a valid resolved value after directive processing 221 | if (typeof result === "string") { 222 | return result.toUpperCase(); 223 | } 224 | return result; 225 | }; 226 | 227 | module.exports = upperCaseReplacer; 228 | ``` 229 | 230 | - executing the `originalResolver` must be done using the `apply` syntax 231 | 232 | ```js 233 | // args: [root, args, context, info] 234 | result = originalResolver.apply(this, args); 235 | 236 | // you can await if the original resolver is async / returns a promise 237 | result = await originalResolver.apply(this, args); 238 | ``` 239 | 240 | ## directive config 241 | 242 | - `directiveConfig` is validated and will throw an Error for missing or invalid properties 243 | - shape 244 | 245 | ```js 246 | const directiveConfig = { 247 | name: string, // required, see details below 248 | resolverReplacer: function, // required, see signature below 249 | hooks: { function, ... }, // optional, see signatures below 250 | }; 251 | ``` 252 | 253 | ### resolverReplacer 254 | 255 | - a higher order function used to bridge information between `createDirective` and the directive logic in the `resolverWrapper` 256 | - used in `createDirective` `config` parameter 257 | - **may not be** `async` 258 | - **must return a function that implements the `resolverWrapper` signature** (the same as the standard Apollo resolver) 259 | - signature 260 | 261 | ```js 262 | // directiveContext: { objectType, field } 263 | resolverReplacer(originalResolver, directiveContext) -> 264 | resolverWrapper(root, args, context, info) 265 | ``` 266 | 267 | - boilerplate 268 | 269 | ```js 270 | const resolverReplacer = (originalResolver, { objectType, field }) => 271 | function resolverWrapper(root, args, context, info) {}; 272 | ``` 273 | 274 | ### resolverWrapper 275 | 276 | - a higher order function used to transform the result or behavior of the `originalResolver` 277 | - **must be returned from `resolverReplacer`** 278 | - **must be a function declaration not an arrow function** 279 | - **may be** `async` 280 | - signature: 281 | 282 | ```js 283 | resolverWrapper(root, args, context, info) -> resolved value 284 | ``` 285 | 286 | ### name 287 | 288 | - unique identifier for the directive 289 | - **must be unique across all directives registered on the schema** 290 | - used for improving performance when directives are registered on server startup 291 | - added as `_ { 372 | const { name, resolverReplacer, hooks = {} } = validateConfig(config); 373 | const { onVisitObject, onVisitFieldDefinition, onApplyToObjectType } = hooks; 374 | 375 | return class Directive extends SchemaDirectiveVisitor { 376 | visitObject(objectType) { 377 | if (onVisitObject) onVisitObject(objectType); 378 | this.applyToObjectType(objectType); 379 | } 380 | 381 | visitFieldDefinition(field, details) { 382 | if (onVisitFieldDefinition) onVisitFieldDefinition(field, details); 383 | this.applyToObjectType(details.objectType); 384 | } 385 | 386 | applyToObjectType(objectType) { 387 | if (onApplyToObjectType) onApplyToObjectType(objectType); 388 | 389 | // exit early if the directive has already been applied to the object type 390 | if (objectType[`_${name}DirectiveApplied`]) return; 391 | objectType[`_${name}DirectiveApplied`] = true; // otherwise set _DirectiveApplied flag 392 | 393 | const fields = objectType.getFields(); 394 | 395 | Object.values(fields).forEach(field => { 396 | // mapped scalar fields (without custom resolvers) will use the defaultFieldResolver 397 | const originalResolver = field.resolve || defaultFieldResolver; 398 | 399 | // replace the original resolver with the resolverWrapper returned from resolverReplacer 400 | field.resolve = resolverReplacer(originalResolver, { 401 | field, 402 | objectType, 403 | }); 404 | }); 405 | } 406 | }; 407 | }; 408 | ``` 409 | 410 | ## createSchemaDirectives 411 | 412 | - builds a `schemaDirectives` object in `{ name: directiveConfig, ... ]` form 413 | - accepts an array of directive config objects 414 | - assign its output to Apollo Server `serverConfig.schemaDirectives` 415 | 416 | ```js 417 | const createSchemaDirectives = directiveConfigs => 418 | directiveConfigs.reduce( 419 | (schemaDirectives, directiveConfig) => ({ 420 | ...schemaDirectives, 421 | [directiveConfig.name]: createDirective(directiveConfig), 422 | }), 423 | {}, 424 | ); 425 | ``` 426 | 427 | ## validateConfig 428 | 429 | ```js 430 | const validateConfig = config => { 431 | const { name, resolverReplacer } = config; 432 | 433 | let message; 434 | if (!name || !resolverReplacer) { 435 | message = "config.name is required"; 436 | } else if (!resolverReplacer) { 437 | message = "config.resolverReplacer is required"; 438 | } else if (typeof name !== "string") { 439 | message = "config.name must be a string"; 440 | } else if (typeof resolverReplacer !== "function") { 441 | message = "config.resolverReplacer must be a function"; 442 | } else { 443 | return config; 444 | } 445 | 446 | const error = new Error(message); 447 | error.name = "CreateDirectiveError"; 448 | 449 | throw error; 450 | }; 451 | ``` 452 | 453 | # notes on deriving the pattern 454 | 455 | - the `visitX` methods are executed on server startup to register the respective directive implementation 456 | - each `visitX` method should utilize (at minimum) a function that wraps the `objectType` 457 | - **`applyToObjectType` function ** 458 | - executes the function reassignment for `field.resolve` 459 | - **`resolverReplacer` function** 460 | - captures the resolver wrapper function returned by the `resolverReplacer` function 461 | - **`resolverWrapper` function** 462 | - adding a marker flag property to the Object prevents redundant application of a directive that has already been applied 463 | - for cases where more than one `visitX` method / directive target like `OBJECT` and `FIELD` are used 464 | - [apollo docs discussing this concept](https://www.apollographql.com/docs/graphql-tools/schema-directives/#enforcing-access-permissions) 465 | - best practice to implement and utilize the `applyToObjectType` function even if only a single visitor method / directive target is used 466 | - consistency of usage pattern 467 | - makes extending the directive to multiple locations less error-prone 468 | - **`_DirectiveApplied` property** should be added directly to the `objectType` in the `applyToObjectType` function 469 | - each directive needs a unique `` because an Object Type can be tagged with multiple directives 470 | - **`` must be unique across all directive `SchemaVisitor` subclass implementations to avoid naming collisions** 471 | 472 | ## directives vs higher order resolver wrappers 473 | 474 | - HoF have traditionally been much easier to write 475 | - directives are known to be complicated to implement and even moreso to explain / understand 476 | - but directives have the benefit of being documented and visible across the team's stack by being written directly in the schema, the contract of your API 477 | - AED extends the abstraction that `SchemaVisitor` began 478 | - finally makes the process of designing and implementing directives painless and with easy to follow code 479 | - AED makes it easy to transition existing HoF wrappers into directives 480 | - most HoF implementations can be easily transition into the `resolverReplacer` and `resolverWrapper` signatures 481 | - after the HoF is transition the consumer just has to implement the directive type defs and provide their corresponding `name` 482 | 483 | ## [internal] visitObject method 484 | 485 | - called during server startup directive registration chain 486 | - once for each Object Type definition that the directive has been tagged on 487 | - exposed through `onVisitObject` hook 488 | - signature: `onVisitObject(objectType)` 489 | - called before the `applyToObjectType` method is executed 490 | 491 | ## [internal] visitFieldDefinition method 492 | 493 | - called during server startup directive registration chain 494 | - once for each Object Type field definition that the directive has been tagged on 495 | - exposed through `onvisitFieldDefinition` hook 496 | - signature: `onvisitFieldDefinition(field, details)` 497 | - `details.objectType` access 498 | - called before the `applyToObjectType` method is executed 499 | 500 | ## [internal] applyToObjectType function 501 | 502 | - called during server startup directive registration chain 503 | 504 | ## resolverReplacer and resolverWrapper 505 | 506 | - the `resolverReplacer` and `resolverWrapper` functions are used in a higher order function chain which must return a `resolvedValue` that is allowed by the schema's definitions 507 | - `resolverReplacer` -> `resolverWrapper` -> `resolvedValue` 508 | - the library consumer only has to implement directive behavior logic in `resolverWrapper` and return a valid `resolvedValue` 509 | - the `resolverWrapper` function receives the original field resolver's arguments `(root, args, context, info)` 510 | - `resolverReplacer` curries the `originalResolver` and `directiveContext` so they are in scope in `resolverWrapper` 511 | - they can be used as needed in when implementing the directive logic 512 | 513 | ### [library] resolverReplacer function 514 | 515 | - implemented by library consumer 516 | - a higher order function used to bridge information between `createDirective` and the consumer's directive resolver logic 517 | - provided by library consumer in `createDirective` `config` parameter 518 | - **may not be** `async` 519 | - **must return a function that implements the `resolverWrapper` signature** (the same as the standard Apollo resolver) 520 | - signature 521 | 522 | ```js 523 | // directiveContext: { objectType, field } 524 | resolverReplacer(originalResolver, directiveContext) -> 525 | resolverWrapper(root, args, context, info) 526 | ``` 527 | 528 | - example 529 | 530 | ```js 531 | module.exports = (originalResolver, { objectType, field }) => 532 | function resolverWrapper(...args) { 533 | // implement directive logic 534 | 535 | return resolvedValue; 536 | }; 537 | ``` 538 | 539 | ### [library] resolverWrapper function 540 | 541 | - a higher order function used to transform the result or behavior of the `originalResolver` 542 | - **must be returned from `resolverReplacer`** 543 | - **must be a function declaration not an arrow function** 544 | - **may be** `async` 545 | - signature: 546 | 547 | ```js 548 | resolverWrapper(root, args, context, info) -> resolved value 549 | ``` 550 | 551 | - annotated example from [Apollo Docs: Schema Directives - Uppercase String](https://www.apollographql.com/docs/graphql-tools/schema-directives/#uppercasing-strings) 552 | 553 | ```js 554 | async function (...args) { 555 | // use any of the original resolver arguments as needed 556 | // args: [root, args, context, info] 557 | 558 | // execute the original resolver to store its output 559 | const result = await originalResolver.apply(this, args); 560 | 561 | // implement other directive logic as needed 562 | 563 | // return the resolved value after directive processing 564 | if (typeof result === "string") { 565 | return result.toUpperCase(); 566 | } 567 | return result; 568 | }; 569 | ``` 570 | 571 | ## the objectType and field shapes 572 | 573 | ### objectType 574 | 575 | - can be found in: 576 | - `visitObject(objectType)`: first parameter 577 | - `visitFieldDefinition(field, details)`: second parameter 578 | - through `details.objectType` 579 | - `resolverReplacer(originalResolver, directiveContext)`: second parameter 580 | - through `directiveContext.objectType` 581 | - shape 582 | 583 | ```json 584 | 585 | ``` 586 | 587 | ### field 588 | 589 | - can be found in: `visitFieldDefinition` first parameter 590 | - shape 591 | 592 | ```json 593 | 594 | ``` 595 | --------------------------------------------------------------------------------