├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .npmignore ├── .nvmrc ├── .prettierrc.json ├── .scripts └── patch-cjs-package.sh ├── .vscode └── settings.json ├── README.md ├── commitlint.config.cjs ├── examples ├── access-control-directives.ts ├── federation.ts └── value-validation-directives.ts ├── jest.config.cjs ├── lib ├── EasyDirectiveVisitor.test.ts ├── EasyDirectiveVisitor.ts ├── ValidateDirectiveVisitor.test.ts ├── ValidateDirectiveVisitor.ts ├── auth.test.ts ├── auth.ts ├── capitalize.ts ├── cleanupPattern.test.ts ├── cleanupPattern.ts ├── createSchemaMapperForVisitor.ts ├── createValidateDirectiveVisitor.test.ts ├── createValidateDirectiveVisitor.ts ├── errors │ ├── AuthenticationError.ts │ ├── ForbiddenError.ts │ └── ValidationError.ts ├── foreignNodeId.test.ts ├── foreignNodeId.ts ├── hasPermissions.test.ts ├── hasPermissions.ts ├── index.ts ├── listLength.test.ts ├── listLength.ts ├── multipleDirectives.test.ts ├── pattern.test.ts ├── pattern.ts ├── patternCommon.ts ├── range.test.ts ├── range.ts ├── selfNodeId.test.ts ├── selfNodeId.ts ├── stringLength.test.ts ├── stringLength.ts ├── test-utils.test.ts ├── trim.test.ts ├── trim.ts ├── utils │ ├── applyDirectivesToSchema.ts │ ├── neverAssertion.ts │ └── printer.ts ├── validateArrayOrValue.test.ts ├── validateArrayOrValue.ts └── validateThrow.test.ts ├── package.json ├── tsconfig.cjs.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | build/* 2 | **/node_modules/** 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "jest": true 5 | }, 6 | "plugins": [ 7 | "@typescript-eslint", 8 | "import" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "extends": [ 12 | "airbnb-base", 13 | "plugin:prettier/recommended", 14 | "plugin:import/errors", 15 | "prettier", 16 | "plugin:@typescript-eslint/recommended" 17 | ], 18 | "globals": { 19 | "Atomics": "readonly", 20 | "SharedArrayBuffer": "readonly" 21 | }, 22 | "parserOptions": { 23 | "ecmaVersion": 2019, 24 | "sourceType": "module" 25 | }, 26 | "settings": { 27 | "import/resolver": { 28 | "typescript": {} 29 | } 30 | }, 31 | "rules": { 32 | "import/no-extraneous-dependencies": ["error", {"devDependencies": ["examples/*.*", "lib/*.test.ts"]}], 33 | "import/extensions": ["error", "never"], 34 | "@typescript-eslint/consistent-type-imports": ["error"], 35 | "@typescript-eslint/consistent-type-assertions": [ 36 | "error", 37 | { 38 | "assertionStyle": "as", 39 | "objectLiteralTypeAssertions": "allow-as-parameter" 40 | } 41 | ], 42 | "@typescript-eslint/explicit-function-return-type": "error", 43 | "@typescript-eslint/explicit-member-accessibility": "off", 44 | "@typescript-eslint/ban-types": [ 45 | "off", 46 | { 47 | "types": { 48 | "{}": false, 49 | "object": false 50 | } 51 | } 52 | ], 53 | "no-use-before-define": "off", 54 | "@typescript-eslint/no-use-before-define": "error", 55 | "no-shadow": "off", 56 | "@typescript-eslint/no-shadow": "error", 57 | "no-unused-vars": "off", 58 | "@typescript-eslint/no-unused-vars": [ 59 | "error", 60 | { 61 | "varsIgnorePattern": "^_", 62 | "argsIgnorePattern": "^_" 63 | } 64 | ], 65 | "func-names": [ 66 | 2, 67 | "never" 68 | ], 69 | "func-style": [ 70 | 2, 71 | "expression", 72 | { 73 | "allowArrowFunctions": true 74 | } 75 | ], 76 | "global-require": 0, 77 | "import/no-cycle": "error", 78 | "import/no-unresolved": "error", 79 | "import/order": [ 80 | "error", 81 | { 82 | "groups": [ 83 | [ 84 | "builtin", 85 | "external" 86 | ], 87 | [ 88 | "sibling", 89 | "parent", 90 | "internal", 91 | "index" 92 | ] 93 | ], 94 | "newlines-between": "always-and-inside-groups" 95 | } 96 | ], 97 | "max-classes-per-file": 0, 98 | "no-restricted-imports": [ 99 | "error", 100 | { 101 | "patterns": [ 102 | "../../*" 103 | ] 104 | } 105 | ], 106 | "sort-keys": "error", 107 | "strict": [ 108 | 0, 109 | "global" 110 | ], 111 | "@typescript-eslint/no-explicit-any": "warn" 112 | }, 113 | "overrides": [ 114 | { 115 | "files": [ 116 | "*.js" 117 | ], 118 | "rules": { 119 | "@typescript-eslint/no-var-requires": "off" 120 | } 121 | } 122 | ] 123 | } 124 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | types: 11 | - opened 12 | - synchronize 13 | - reopened 14 | - ready_for_review 15 | 16 | concurrency: 17 | group: ci-check-${{ github.ref }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | build: 22 | if: github.event.pull_request.draft != true 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Use Node.js 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version-file: .nvmrc 30 | cache: yarn 31 | - run: yarn --frozen-lockfile 32 | - run: yarn install-peers -f 33 | - run: yarn lint 34 | - run: yarn build 35 | - run: yarn test --coverage 36 | env: 37 | CI: true 38 | - name: Ensure GIT is clean after everything is done 39 | uses: numtide/clean-git-action@v1 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /coverage 4 | /*.tgz 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | set -e 5 | 6 | yarn commitlint --edit "$1" 7 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | set -e 5 | 6 | yarn lint-staged 7 | yarn test --coverage 8 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | set -e 5 | 6 | yarn lint 7 | yarn test --coverage 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.eslint* 2 | /.prettierrc.json 3 | /.vscode 4 | /commitlint.config.js 5 | /coverage 6 | /examples 7 | /jest.config.js 8 | /lib 9 | /tsconfig.json 10 | /*.tgz 11 | /build/examples 12 | /.github 13 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.17.1 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "parser": "typescript", 5 | "semi": true, 6 | "printWidth": 80, 7 | "tabWidth": 2, 8 | "useTabs": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.scripts/patch-cjs-package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cat >build/cjs/package.json < { 41 | state.isAuthenticated = isAuthenticated; 42 | return isAuthenticated; 43 | }, 44 | setPermissions: ( 45 | _, 46 | { permissions }: { permissions: string[] | null }, 47 | ): string[] | null => { 48 | state.grantedPermissions = permissions; 49 | return permissions; 50 | }, 51 | }, 52 | Query: { 53 | authenticated: (): boolean => state.isAuthenticated || false, 54 | handleMissingPermissions: ( 55 | _, 56 | __, 57 | ___, 58 | { missingPermissions }: MissingPermissionsResolverInfo, 59 | ): string[] | null => missingPermissions || null, 60 | throwIfMissingPermissions: (): number => 123, 61 | }, 62 | }, 63 | typeDefs: [ 64 | ...yourTypeDefs, 65 | ...auth.getTypeDefs(), 66 | ...hasPermissions.getTypeDefs(), 67 | ], 68 | }), 69 | ); 70 | 71 | type Context = ReturnType & 72 | ReturnType; 73 | 74 | const server = new ApolloServer({ 75 | schema, 76 | }); 77 | 78 | startStandaloneServer(server, { 79 | context: async (expressContext): Promise => { 80 | // This example allows for state to be passed in the headers: 81 | // - authorization: any value results in authenticated 82 | // - permissions: json-serialized array of strings or null 83 | // 84 | // However to make it easier to test using the built-in playground 85 | // one can use the mutations to set state: 86 | // - setAuthenticated(isAuthenticated: Boolean) 87 | // - setPermissions(permissions: [String!]) 88 | 89 | if (state.isAuthenticated === null) { 90 | const { authorization } = expressContext.req.headers; 91 | state.isAuthenticated = !!authorization; 92 | } 93 | if (state.grantedPermissions === null) { 94 | const { permissions } = expressContext.req.headers; 95 | state.grantedPermissions = permissions 96 | ? JSON.parse(Array.isArray(permissions) ? permissions[0] : permissions) 97 | : null; 98 | } 99 | return { 100 | ...auth.createDirectiveContext({ 101 | isAuthenticated: state.isAuthenticated, 102 | }), 103 | ...hasPermissions.createDirectiveContext({ 104 | grantedPermissions: state.grantedPermissions || undefined, 105 | }), 106 | }; 107 | }, 108 | listen: { port: 4000 }, 109 | }).then(({ url }) => { 110 | // eslint-disable-next-line no-console 111 | console.log(`🚀 Server ready at ${url}`); 112 | }); 113 | -------------------------------------------------------------------------------- /examples/federation.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GraphQLSchema, 3 | DocumentNode, 4 | GraphQLFieldResolver, 5 | } from 'graphql'; 6 | import { ApolloServer } from '@apollo/server'; 7 | import { startStandaloneServer } from '@apollo/server/standalone'; 8 | import { ApolloGateway } from '@apollo/gateway'; 9 | import { buildSubgraphSchema } from '@apollo/subgraph'; 10 | import { gql } from 'graphql-tag'; 11 | import type { GraphQLResolverMap } from '@apollo/subgraph/dist/schema-helper'; 12 | 13 | import { 14 | ValidateDirectiveVisitor, 15 | range, 16 | stringLength, 17 | applyDirectivesToSchema, 18 | } from '../lib/index.js'; 19 | 20 | /* 21 | When using apollo federation all 22 | directives should be available to all 23 | federated nodes. 24 | */ 25 | 26 | type Directive = typeof range; 27 | 28 | const buildSchema = ( 29 | resolvers: GraphQLResolverMap<{}>, 30 | typeDefs: DocumentNode, 31 | directives: Directive[], 32 | ): GraphQLSchema => { 33 | const finalTypeDefs = [ 34 | typeDefs, 35 | ...ValidateDirectiveVisitor.getMissingCommonTypeDefs(), 36 | ...directives.reduce( 37 | (acc, d) => acc.concat(d.getTypeDefs()), 38 | [], 39 | ), 40 | ]; 41 | const schema = applyDirectivesToSchema( 42 | directives, 43 | buildSubgraphSchema({ 44 | resolvers: resolvers as GraphQLResolverMap, 45 | typeDefs: finalTypeDefs, 46 | }), 47 | ); 48 | return schema; 49 | }; 50 | 51 | interface ServicesSetup { 52 | directives: Directive[]; 53 | port: number; 54 | resolvers: { 55 | [typeName: string]: { 56 | [fieldName: string]: GraphQLFieldResolver; 57 | }; 58 | }; 59 | typeDefs: DocumentNode; 60 | } 61 | 62 | const services: ServicesSetup[] = [ 63 | { 64 | directives: [range], 65 | port: 4001, 66 | resolvers: { 67 | Query: { 68 | myNumber: (_: unknown, { args }): number => args, 69 | }, 70 | }, 71 | typeDefs: gql` 72 | type Query { 73 | myNumber(args: Int @range(max: 100, policy: THROW)): Int 74 | @range(min: 2, policy: THROW) 75 | } 76 | `, 77 | }, 78 | { 79 | directives: [stringLength], 80 | port: 4002, 81 | resolvers: { 82 | Query: { 83 | myString: (_: unknown, { args }): string => args, 84 | }, 85 | }, 86 | typeDefs: gql` 87 | type Query { 88 | myString(args: String @stringLength(max: 200, policy: THROW)): String 89 | @stringLength(min: 3, policy: THROW) 90 | } 91 | `, 92 | }, 93 | ]; 94 | 95 | const start = async (): Promise => { 96 | const runningString = await Promise.all( 97 | services.map(({ resolvers, typeDefs, port, directives }) => 98 | startStandaloneServer( 99 | new ApolloServer({ 100 | schema: buildSchema(resolvers, typeDefs, directives), 101 | }), 102 | { listen: { port } }, 103 | ), 104 | ), 105 | ); 106 | // eslint-disable-next-line no-console 107 | console.log(runningString.map(({ url }) => url).join('\n')); 108 | const apolloGateway = new ApolloGateway({ 109 | serviceList: [ 110 | { 111 | name: 'string-service', 112 | url: 'http://localhost:4002', 113 | }, 114 | { 115 | name: 'number-service', 116 | url: 'http://localhost:4001', 117 | }, 118 | ], 119 | }); 120 | const server = new ApolloServer({ 121 | gateway: apolloGateway, 122 | }); 123 | 124 | const { url } = await startStandaloneServer(server); 125 | // eslint-disable-next-line no-console 126 | console.log(`🚀 Server ready at ${url}`); 127 | }; 128 | 129 | // eslint-disable-next-line no-console 130 | start().catch(console.error); 131 | -------------------------------------------------------------------------------- /examples/value-validation-directives.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from '@apollo/server'; 2 | import { startStandaloneServer } from '@apollo/server/standalone'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | import { gql } from 'graphql-tag'; 5 | 6 | import type { GraphQLResolveInfo } from 'graphql'; 7 | import { graphql, print } from 'graphql'; 8 | 9 | import { 10 | listLength, 11 | pattern, 12 | range, 13 | stringLength, 14 | ValidateDirectiveVisitor, 15 | trim, 16 | applyDirectivesToSchema, 17 | } from '../lib'; 18 | import type ValidationError from '../lib/errors/ValidationError.js'; 19 | 20 | interface ValidationErrorsResolverInfo extends GraphQLResolveInfo { 21 | validationErrors?: ValidationError[]; 22 | } 23 | 24 | const yourTypeDefs = [ 25 | gql` 26 | # ValidatedInputErrorOutput and ValidatedInputError are defined by 27 | # ValidateDirectiveVisitor.getMissingCommonTypeDefs() 28 | type IntRangeExample { 29 | arg: Int 30 | validationErrors: [ValidatedInputErrorOutput!] 31 | } 32 | 33 | type FloatRangeExample { 34 | arg: Int 35 | validationErrors: [ValidatedInputErrorOutput!] 36 | } 37 | 38 | type PatternExample { 39 | arg: String 40 | validationErrors: [ValidatedInputErrorOutput!] 41 | } 42 | 43 | type StringLengthExample { 44 | arg: String 45 | validationErrors: [ValidatedInputErrorOutput!] 46 | } 47 | 48 | type ListLengthExample { 49 | arg: [Int] 50 | validationErrors: [ValidatedInputErrorOutput!] 51 | } 52 | 53 | type TrimExample { 54 | arg: String 55 | validationErrors: [ValidatedInputErrorOutput!] 56 | } 57 | 58 | type Query { 59 | intRangeExample(arg: Int @range(min: -10, max: 10)): IntRangeExample 60 | floatRangeExample( 61 | arg: Float @range(min: -0.5, max: 0.5) 62 | ): FloatRangeExample 63 | patternExample( 64 | arg: String @pattern(regexp: "[a-z]+", flags: "i") 65 | ): PatternExample 66 | stringLengthExample( 67 | arg: String @stringLength(min: 1, max: 3) 68 | ): StringLengthExample 69 | listLengthExample( 70 | arg: [Int] @listLength(min: 1, max: 100) 71 | ): ListLengthExample 72 | throwingIntRangeExample( 73 | arg: Int @range(min: -10, max: 10, policy: THROW) 74 | ): IntRangeExample 75 | trimExample(arg: String @trim(mode: TRIM_ALL)): TrimExample 76 | } 77 | `, 78 | ]; 79 | 80 | const argsResolver = ( 81 | _: unknown, 82 | { arg }: { arg: unknown }, 83 | __: unknown, 84 | { validationErrors }: ValidationErrorsResolverInfo, 85 | ): object => ({ arg, validationErrors }); 86 | 87 | const directives = [listLength, pattern, range, stringLength, trim]; 88 | 89 | const schema = applyDirectivesToSchema( 90 | directives, 91 | makeExecutableSchema({ 92 | resolvers: { 93 | Query: { 94 | floatRangeExample: argsResolver, 95 | intRangeExample: argsResolver, 96 | listLengthExample: argsResolver, 97 | patternExample: argsResolver, 98 | stringLengthExample: argsResolver, 99 | throwingIntRangeExample: argsResolver, 100 | trimExample: argsResolver, 101 | }, 102 | }, 103 | typeDefs: [ 104 | ...yourTypeDefs, 105 | ...ValidateDirectiveVisitor.getMissingCommonTypeDefs(), 106 | ...listLength.getTypeDefs(), 107 | ...pattern.getTypeDefs(), 108 | ...range.getTypeDefs(), 109 | ...stringLength.getTypeDefs(), 110 | ...trim.getTypeDefs(), 111 | ], 112 | }), 113 | ); 114 | 115 | // works as test and sample queries 116 | const tests = { 117 | AllInvalid: { 118 | query: gql` 119 | query AllInvalid { 120 | floatRangeExample(arg: -1) { 121 | arg 122 | validationErrors { 123 | message 124 | path 125 | } 126 | } 127 | intRangeExample(arg: 100) { 128 | arg 129 | validationErrors { 130 | message 131 | path 132 | } 133 | } 134 | listLengthExample(arg: []) { 135 | arg 136 | validationErrors { 137 | message 138 | path 139 | } 140 | } 141 | patternExample(arg: "12") { 142 | arg 143 | validationErrors { 144 | message 145 | path 146 | } 147 | } 148 | stringLengthExample(arg: "hi there") { 149 | arg 150 | validationErrors { 151 | message 152 | path 153 | } 154 | } 155 | } 156 | `, 157 | result: { 158 | data: { 159 | floatRangeExample: { 160 | arg: null, 161 | validationErrors: [ 162 | { 163 | message: 'Less than -0.5', 164 | path: ['arg'], 165 | }, 166 | ], 167 | }, 168 | intRangeExample: { 169 | arg: null, 170 | validationErrors: [ 171 | { 172 | message: 'More than 10', 173 | path: ['arg'], 174 | }, 175 | ], 176 | }, 177 | listLengthExample: { 178 | arg: null, 179 | validationErrors: [ 180 | { 181 | message: 'List Length is Less than 1', 182 | path: ['arg'], 183 | }, 184 | ], 185 | }, 186 | patternExample: { 187 | arg: null, 188 | validationErrors: [ 189 | { 190 | message: 'Does not match pattern: /[a-z]+/i', 191 | path: ['arg'], 192 | }, 193 | ], 194 | }, 195 | stringLengthExample: { 196 | arg: null, 197 | validationErrors: [ 198 | { 199 | message: 'String Length is More than 3', 200 | path: ['arg'], 201 | }, 202 | ], 203 | }, 204 | }, 205 | }, 206 | }, 207 | AllValid: { 208 | query: gql` 209 | query AllValid { 210 | floatRangeExample(arg: 0) { 211 | arg 212 | validationErrors { 213 | message 214 | path 215 | } 216 | } 217 | intRangeExample(arg: 1) { 218 | arg 219 | validationErrors { 220 | message 221 | path 222 | } 223 | } 224 | listLengthExample(arg: [1, 2]) { 225 | arg 226 | validationErrors { 227 | message 228 | path 229 | } 230 | } 231 | patternExample(arg: "hello") { 232 | arg 233 | validationErrors { 234 | message 235 | path 236 | } 237 | } 238 | stringLengthExample(arg: "hi") { 239 | arg 240 | validationErrors { 241 | message 242 | path 243 | } 244 | } 245 | trimExample(arg: ${JSON.stringify( 246 | ' \t \r \n \r\n trimmed! \n\n \t \r\n', 247 | )}){ 248 | arg 249 | validationErrors { 250 | message 251 | path 252 | } 253 | } 254 | } 255 | `, 256 | result: { 257 | data: { 258 | floatRangeExample: { 259 | arg: 0, 260 | validationErrors: null, 261 | }, 262 | intRangeExample: { 263 | arg: 1, 264 | validationErrors: null, 265 | }, 266 | listLengthExample: { 267 | arg: [1, 2], 268 | validationErrors: null, 269 | }, 270 | patternExample: { 271 | arg: 'hello', 272 | validationErrors: null, 273 | }, 274 | stringLengthExample: { 275 | arg: 'hi', 276 | validationErrors: null, 277 | }, 278 | trimExample: { 279 | arg: 'trimmed!', 280 | validationErrors: null, 281 | }, 282 | }, 283 | }, 284 | }, 285 | Throwing: { 286 | query: gql` 287 | query Throwing { 288 | throwingIntRangeExample(arg: 100) { 289 | arg 290 | validationErrors { 291 | message 292 | path 293 | } 294 | } 295 | } 296 | `, 297 | result: { 298 | // keep same order as in GQL so JSON.stringify() serializes the same 299 | /* eslint-disable sort-keys */ 300 | errors: [ 301 | { 302 | message: 'More than 10', 303 | locations: [ 304 | { 305 | line: 2, 306 | column: 3, 307 | }, 308 | ], 309 | path: ['throwingIntRangeExample'], 310 | extensions: { 311 | code: 'GRAPHQL_VALIDATION_FAILED', 312 | validation: { 313 | path: ['arg'], 314 | }, 315 | }, 316 | }, 317 | ], 318 | data: { 319 | throwingIntRangeExample: null, 320 | }, 321 | }, 322 | /* eslint-enable sort-keys */ 323 | }, 324 | }; 325 | 326 | const test = async (): Promise => 327 | Promise.all( 328 | Object.entries(tests).map( 329 | async ([name, { query, result: expected }]): Promise => { 330 | const source = print(query); 331 | const result = await graphql({ schema, source }); 332 | if (JSON.stringify(result) !== JSON.stringify(expected)) { 333 | throw Error(`test ${name} failed`); 334 | } 335 | // eslint-disable-next-line no-console 336 | console.log(`✅ test ${name} works:\n${source}\n`); 337 | }, 338 | ), 339 | ); 340 | 341 | test().catch(error => { 342 | // eslint-disable-next-line no-console 343 | console.error('💥test queries failed:', error); 344 | process.exit(1); 345 | }); 346 | 347 | const server = new ApolloServer({ schema }); 348 | startStandaloneServer(server).then(({ url }) => { 349 | // eslint-disable-next-line no-console 350 | console.log(`🚀 Server ready at ${url}`); 351 | }); 352 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | coveragePathIgnorePatterns: [ 3 | '/node_modules/', 4 | '/build/', 5 | 'lib/test-utils.test.ts', 6 | ], 7 | coverageThreshold: { 8 | global: { 9 | branches: 100, 10 | functions: 100, 11 | lines: 100, 12 | statements: 100, 13 | }, 14 | }, 15 | moduleFileExtensions: ['ts', 'js'], 16 | moduleNameMapper: { 17 | '^(\\.{1,2}/.*)\\.js$': '$1', 18 | }, 19 | modulePaths: ['/lib/'], 20 | preset: 'ts-jest', 21 | testEnvironment: 'node', 22 | testPathIgnorePatterns: ['/node_modules/', '/build/', 'test-utils.test.ts'], 23 | transform: { 24 | '^.+\\.(mt|t|cj|j)s$': [ 25 | 'ts-jest', 26 | { 27 | useESM: true, 28 | }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /lib/EasyDirectiveVisitor.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DirectiveLocation, 3 | GraphQLNonNull, 4 | GraphQLObjectType, 5 | GraphQLInputObjectType, 6 | GraphQLEnumType, 7 | GraphQLScalarType, 8 | GraphQLList, 9 | GraphQLInt, 10 | GraphQLBoolean, 11 | } from 'graphql'; 12 | import { makeExecutableSchema } from '@graphql-tools/schema'; 13 | import { gql } from 'graphql-tag'; 14 | 15 | import print from './utils/printer.js'; 16 | import EasyDirectiveVisitor from './EasyDirectiveVisitor.js'; 17 | 18 | describe('EasyDirectiveVisitor', (): void => { 19 | const minimalTypeDef = gql` 20 | type T { 21 | i: Int 22 | } 23 | `; 24 | const SomeScalar = new GraphQLScalarType({ 25 | description: 'test custom scalar', 26 | name: 'SomeScalar', 27 | serialize: (value: unknown): string => String(value), 28 | }); 29 | const SomeEnum = new GraphQLEnumType({ 30 | description: 'test custom enum', 31 | name: 'SomeEnum', 32 | values: { 33 | aValue: { value: 'aValue' }, 34 | bValue: { value: 'bValue' }, 35 | }, 36 | }); 37 | const SomeType = new GraphQLObjectType({ 38 | description: 'test custom output type', 39 | fields: { 40 | out: { type: SomeScalar }, 41 | }, 42 | name: 'SomeType', 43 | }); 44 | const SomeInput = new GraphQLInputObjectType({ 45 | description: 'test custom input type', 46 | fields: { 47 | in: { type: SomeEnum }, 48 | }, 49 | name: 'SomeInput', 50 | }); 51 | 52 | const commonTypes = [ 53 | SomeType, 54 | new GraphQLNonNull(new GraphQLList(SomeInput)), 55 | ] as const; 56 | const locations = [ 57 | DirectiveLocation.OBJECT, 58 | DirectiveLocation.INTERFACE, 59 | ] as const; 60 | const locationsStr = locations.join(' | '); 61 | const name = 'test'; 62 | const commonTypeDefs: string[] = [ 63 | `\ 64 | """test custom output type""" 65 | type SomeType { 66 | out: SomeScalar 67 | } 68 | `, 69 | `\ 70 | """test custom input type""" 71 | input SomeInput { 72 | in: SomeEnum 73 | } 74 | `, 75 | `\ 76 | """test custom enum""" 77 | enum SomeEnum { 78 | aValue 79 | bValue 80 | } 81 | `, 82 | ]; 83 | 84 | describe('Directive Without Args', (): void => { 85 | class DirectiveWithoutArgs extends EasyDirectiveVisitor< 86 | Record, 87 | Record 88 | > { 89 | public static readonly commonTypes = commonTypes; 90 | 91 | public static readonly config = { locations } as const; 92 | } 93 | 94 | describe('getMissingCommonTypeDefs()', (): void => { 95 | it('is correct without schema', (): void => { 96 | expect( 97 | DirectiveWithoutArgs.getMissingCommonTypeDefs().map(print), 98 | ).toEqual(commonTypeDefs); 99 | }); 100 | 101 | it('is correct with minimum schema', (): void => { 102 | const schema = makeExecutableSchema({ typeDefs: minimalTypeDef }); 103 | expect( 104 | DirectiveWithoutArgs.getMissingCommonTypeDefs(schema).map(print), 105 | ).toEqual(commonTypeDefs); 106 | }); 107 | 108 | it('is correct with existing common types', (): void => { 109 | const schema = makeExecutableSchema({ 110 | typeDefs: [ 111 | minimalTypeDef, 112 | gql` 113 | scalar SomeScalar 114 | enum SomeEnum { 115 | aValue 116 | bValue 117 | } 118 | type SomeType { 119 | out: SomeScalar 120 | } 121 | input SomeInput { 122 | in: SomeInput 123 | } 124 | `, 125 | ], 126 | }); 127 | expect( 128 | DirectiveWithoutArgs.getMissingCommonTypeDefs(schema).map(print), 129 | ).toEqual([]); 130 | }); 131 | }); 132 | 133 | describe('getDirectiveDeclaration', (): void => { 134 | it('creates one if none exists', (): void => { 135 | const schema = makeExecutableSchema({ typeDefs: minimalTypeDef }); 136 | const directive = DirectiveWithoutArgs.getDirectiveDeclaration( 137 | name, 138 | schema, 139 | ); 140 | const conf = directive.toConfig(); 141 | expect(conf).toEqual({ 142 | args: {}, 143 | astNode: undefined, 144 | description: undefined, 145 | extensions: {}, 146 | isRepeatable: false, 147 | locations, 148 | name, 149 | }); 150 | }); 151 | 152 | it('patches, if already exists, to guarantee essential location', (): void => { 153 | const schema = makeExecutableSchema({ 154 | typeDefs: [ 155 | minimalTypeDef, 156 | gql` 157 | # location will be extended to be all locations 158 | directive @${name}( 159 | alien: Int = 123 160 | ) on OBJECT | ARGUMENT_DEFINITION 161 | `, 162 | ], 163 | }); 164 | const directive = DirectiveWithoutArgs.getDirectiveDeclaration( 165 | name, 166 | schema, 167 | ); 168 | const conf = directive.toConfig(); 169 | expect(conf.args).toEqual({ 170 | alien: { 171 | // left untouched 172 | astNode: expect.objectContaining({ 173 | name: expect.objectContaining({ kind: 'Name', value: 'alien' }), 174 | }), 175 | defaultValue: 123, 176 | deprecationReason: undefined, 177 | description: undefined, 178 | extensions: {}, 179 | type: GraphQLInt, 180 | }, 181 | }); 182 | expect(conf.name).toEqual(name); 183 | expect(conf.locations).toEqual([ 184 | // will be first, since it was declared in DSL 185 | DirectiveLocation.OBJECT, 186 | DirectiveLocation.ARGUMENT_DEFINITION, 187 | // these will be later, as they are pushed into the array 188 | DirectiveLocation.INTERFACE, 189 | ]); 190 | }); 191 | }); 192 | 193 | describe('getTypeDefs()', (): void => { 194 | const schema = makeExecutableSchema({ typeDefs: minimalTypeDef }); 195 | const expectedDirectiveTypeDef = `directive @${name} on ${locationsStr}\n`; 196 | it('works with includeUnknownTypes=true, includeCommonTypes=false', (): void => { 197 | expect( 198 | DirectiveWithoutArgs.getTypeDefs(name, schema, true, false).map( 199 | print, 200 | ), 201 | ).toEqual([expectedDirectiveTypeDef]); 202 | }); 203 | 204 | it('works with includeUnknownTypes=false, includeCommonTypes=false', (): void => { 205 | expect( 206 | DirectiveWithoutArgs.getTypeDefs(name, schema, false, false).map( 207 | print, 208 | ), 209 | ).toEqual([expectedDirectiveTypeDef]); 210 | }); 211 | 212 | it('works with includeUnknownTypes=false, includeCommonTypes=true', (): void => { 213 | expect( 214 | DirectiveWithoutArgs.getTypeDefs(name, schema, false, true).map( 215 | print, 216 | ), 217 | ).toEqual([expectedDirectiveTypeDef, ...commonTypeDefs]); 218 | }); 219 | 220 | it('works with default parameters', (): void => { 221 | expect(DirectiveWithoutArgs.getTypeDefs(name).map(print)).toEqual([ 222 | expectedDirectiveTypeDef, 223 | ...commonTypeDefs, 224 | ]); 225 | }); 226 | 227 | it('works with repeatable directive', (): void => { 228 | class RepeatableDirective extends DirectiveWithoutArgs { 229 | public static readonly config = { 230 | ...DirectiveWithoutArgs.config, 231 | isRepeatable: true, 232 | } as const; 233 | } 234 | expect( 235 | RepeatableDirective.getTypeDefs(name, schema, false, false).map( 236 | print, 237 | ), 238 | ).toEqual([`directive @${name} repeatable on ${locationsStr}\n`]); 239 | }); 240 | 241 | it('works with description', (): void => { 242 | class DescriptionDirective extends DirectiveWithoutArgs { 243 | public static readonly config = { 244 | ...DirectiveWithoutArgs.config, 245 | description: 'Some Docs Here', 246 | } as const; 247 | } 248 | expect( 249 | DescriptionDirective.getTypeDefs(name, schema, false, false).map( 250 | print, 251 | ), 252 | ).toEqual([`"""Some Docs Here"""\n${expectedDirectiveTypeDef}`]); 253 | }); 254 | }); 255 | }); 256 | 257 | describe('Directive With Args', (): void => { 258 | class DirectiveWithArgs extends EasyDirectiveVisitor< 259 | Record, 260 | Record 261 | > { 262 | public static readonly commonTypes = commonTypes; 263 | 264 | public static readonly config = { 265 | args: { 266 | bool: { 267 | type: GraphQLBoolean, 268 | }, 269 | complex: { 270 | defaultValue: [{ field: { value: 42 } }], 271 | description: 'some docs for complex argument', 272 | type: new GraphQLNonNull( 273 | new GraphQLList( 274 | new GraphQLInputObjectType({ 275 | description: 'Input Type Description', 276 | fields: { 277 | field: { 278 | defaultValue: { value: 0 }, 279 | description: 'complex field', 280 | type: new GraphQLNonNull( 281 | new GraphQLInputObjectType({ 282 | fields: { 283 | value: { type: GraphQLInt }, 284 | }, 285 | name: 'NestedInputType', 286 | }), 287 | ), 288 | }, 289 | }, 290 | name: 'InputType', 291 | }), 292 | ), 293 | ), 294 | }, 295 | customEnum: { 296 | defaultValue: 'enumValueHere', 297 | type: new GraphQLEnumType({ 298 | name: 'CustomEnum', 299 | values: { 300 | enumValueHere: { value: 'enumValueHere' }, 301 | otherValueHere: { value: 'otherValueHere' }, 302 | }, 303 | }), 304 | }, 305 | int: { 306 | defaultValue: 12, 307 | type: GraphQLInt, 308 | }, 309 | nullField: { 310 | defaultValue: null, 311 | type: GraphQLInt, 312 | }, 313 | }, 314 | locations, 315 | } as const; 316 | } 317 | 318 | const expectedArgs = { 319 | bool: { 320 | ...DirectiveWithArgs.config.args.bool, 321 | astNode: undefined, 322 | defaultValue: undefined, 323 | deprecationReason: undefined, 324 | description: undefined, 325 | extensions: {}, 326 | }, 327 | complex: { 328 | ...DirectiveWithArgs.config.args.complex, 329 | astNode: undefined, 330 | deprecationReason: undefined, 331 | extensions: {}, 332 | }, 333 | customEnum: { 334 | ...DirectiveWithArgs.config.args.customEnum, 335 | astNode: undefined, 336 | deprecationReason: undefined, 337 | description: undefined, 338 | extensions: {}, 339 | }, 340 | int: { 341 | ...DirectiveWithArgs.config.args.int, 342 | astNode: undefined, 343 | deprecationReason: undefined, 344 | description: undefined, 345 | extensions: {}, 346 | }, 347 | nullField: { 348 | ...DirectiveWithArgs.config.args.nullField, 349 | astNode: undefined, 350 | deprecationReason: undefined, 351 | description: undefined, 352 | extensions: {}, 353 | }, 354 | }; 355 | 356 | describe('getDirectiveDeclaration', (): void => { 357 | it('creates one if none exists', (): void => { 358 | const schema = makeExecutableSchema({ typeDefs: minimalTypeDef }); 359 | const directive = DirectiveWithArgs.getDirectiveDeclaration( 360 | name, 361 | schema, 362 | ); 363 | const conf = directive.toConfig(); 364 | expect(conf).toEqual({ 365 | args: expectedArgs, 366 | astNode: undefined, 367 | description: undefined, 368 | extensions: {}, 369 | isRepeatable: false, 370 | locations, 371 | name, 372 | }); 373 | }); 374 | 375 | it('patches, if already exists, to guarantee essential args', (): void => { 376 | const schema = makeExecutableSchema({ 377 | typeDefs: [ 378 | minimalTypeDef, 379 | gql` 380 | # location will be extended to be all locations 381 | directive @${name}( 382 | alien: Int = 123 383 | ) on OBJECT | ARGUMENT_DEFINITION 384 | `, 385 | ], 386 | }); 387 | const directive = DirectiveWithArgs.getDirectiveDeclaration( 388 | name, 389 | schema, 390 | ); 391 | const conf = directive.toConfig(); 392 | expect(conf.args).toEqual({ 393 | ...expectedArgs, 394 | alien: { 395 | // left untouched 396 | astNode: expect.objectContaining({ 397 | name: expect.objectContaining({ kind: 'Name', value: 'alien' }), 398 | }), 399 | defaultValue: 123, 400 | deprecationReason: undefined, 401 | description: undefined, 402 | extensions: {}, 403 | type: GraphQLInt, 404 | }, 405 | }); 406 | expect(conf.name).toEqual(name); 407 | expect(conf.locations).toEqual([ 408 | // will be first, since it was declared in DSL 409 | DirectiveLocation.OBJECT, 410 | DirectiveLocation.ARGUMENT_DEFINITION, 411 | // these will be later, as they are pushed into the array 412 | DirectiveLocation.INTERFACE, 413 | ]); 414 | }); 415 | 416 | it('patches, if already exists, to guarantee essential arg type', (): void => { 417 | const schema = makeExecutableSchema({ 418 | typeDefs: [ 419 | minimalTypeDef, 420 | gql` 421 | # location will be extended to be all locations 422 | directive @${name}( 423 | """Docs will be kept""" 424 | bool: Int # will be fixed! 425 | ) on ${locationsStr} 426 | `, 427 | ], 428 | }); 429 | const directive = DirectiveWithArgs.getDirectiveDeclaration( 430 | name, 431 | schema, 432 | ); 433 | const conf = directive.toConfig(); 434 | expect(conf.args).toEqual({ 435 | ...expectedArgs, 436 | bool: { 437 | ...expectedArgs.bool, 438 | astNode: expect.objectContaining({ 439 | name: expect.objectContaining({ kind: 'Name', value: 'bool' }), 440 | }), 441 | description: 'Docs will be kept', 442 | extensions: {}, 443 | }, 444 | }); 445 | expect(conf.name).toEqual(name); 446 | expect(conf.locations).toEqual(locations); 447 | }); 448 | }); 449 | 450 | describe('getTypeDefs()', (): void => { 451 | const schema = makeExecutableSchema({ typeDefs: minimalTypeDef }); 452 | const expectedDirectiveTypeDef = `\ 453 | directive @${name}( 454 | bool: Boolean 455 | """some docs for complex argument""" 456 | complex: [InputType]! = [{field: {value: 42}}] 457 | customEnum: CustomEnum = enumValueHere 458 | int: Int = 12 459 | nullField: Int = null 460 | ) on ${locationsStr} 461 | `; 462 | it('works with includeUnknownTypes=true, includeCommonTypes=true', (): void => { 463 | expect( 464 | DirectiveWithArgs.getTypeDefs(name, schema, true, true).map(print), 465 | ).toEqual([ 466 | expectedDirectiveTypeDef, 467 | `\ 468 | """Input Type Description""" 469 | input InputType { 470 | """complex field""" 471 | field: NestedInputType! = {value: 0} 472 | } 473 | `, 474 | `\ 475 | input NestedInputType { 476 | value: Int 477 | } 478 | `, 479 | `\ 480 | enum CustomEnum { 481 | enumValueHere 482 | otherValueHere 483 | } 484 | `, 485 | ...commonTypeDefs, 486 | ]); 487 | }); 488 | }); 489 | }); 490 | }); 491 | -------------------------------------------------------------------------------- /lib/EasyDirectiveVisitor.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DirectiveLocation, 3 | DocumentNode, 4 | GraphQLArgument, 5 | GraphQLDirectiveConfig, 6 | GraphQLField, 7 | GraphQLFieldConfig, 8 | GraphQLInputField, 9 | GraphQLInputType, 10 | GraphQLInterfaceType, 11 | GraphQLNamedType, 12 | GraphQLObjectType, 13 | GraphQLSchema, 14 | GraphQLType, 15 | } from 'graphql'; 16 | import { 17 | astFromValue, 18 | GraphQLDirective, 19 | GraphQLInputObjectType, 20 | GraphQLList, 21 | GraphQLNonNull, 22 | isSpecifiedScalarType, 23 | print, 24 | printType, 25 | } from 'graphql'; 26 | import { gql } from 'graphql-tag'; 27 | 28 | import createSchemaMapperForVisitor from './createSchemaMapperForVisitor.js'; 29 | 30 | export type ReadonlyGraphQLDirectiveConfigWithoutName = Readonly<{ 31 | [P in keyof Omit]: Readonly< 32 | GraphQLDirectiveConfig[P] 33 | >; 34 | }>; 35 | 36 | // TODO: is there any exported version of this? 37 | // I just found a way to print directive via printSchema() 38 | const printInputValue = (spec: GraphQLArgument): string => { 39 | const { description, name, type, defaultValue } = spec; 40 | const dsl: string[] = []; 41 | 42 | if (spec.description) dsl.push(`"""${description}"""\n`); 43 | 44 | dsl.push(`${name}: ${type}`); 45 | if (defaultValue !== undefined) { 46 | const ast = astFromValue(defaultValue, type); 47 | // istanbul ignore else (should never happen) 48 | if (ast) dsl.push(` = ${print(ast)}`); 49 | } 50 | 51 | return dsl.join(''); 52 | }; 53 | 54 | const printDirective = (directive: GraphQLDirective): string => { 55 | const dsl: string[] = []; 56 | if (directive.description) dsl.push(`"""${directive.description}"""\n`); 57 | dsl.push(`directive @${directive.name}`); 58 | if (directive.args.length > 0) { 59 | dsl.push('(\n'); 60 | directive.args.forEach(arg => { 61 | dsl.push(printInputValue(arg)); 62 | dsl.push('\n'); 63 | }); 64 | dsl.push(')'); 65 | } 66 | 67 | if (directive.isRepeatable) dsl.push(' repeatable'); 68 | 69 | dsl.push(` on ${directive.locations.join(' | ')}`); 70 | 71 | return dsl.join(''); 72 | }; 73 | 74 | const collectUnknownNamedTypes = ( 75 | schema: GraphQLSchema | undefined, 76 | type: GraphQLInputType | GraphQLNamedType, 77 | unknownTypes: GraphQLNamedType[], 78 | ): void => { 79 | if (type instanceof GraphQLNonNull || type instanceof GraphQLList) { 80 | collectUnknownNamedTypes(schema, type.ofType, unknownTypes); 81 | return; 82 | } 83 | 84 | // istanbul ignore else (should never happen) 85 | if ('name' in type) { 86 | if ( 87 | !isSpecifiedScalarType(type) && 88 | (!schema || !schema.getType(type.name)) 89 | ) { 90 | unknownTypes.push(type); 91 | 92 | // only unknown types should point to other unknown types 93 | // keep the loop below inside this branch! 94 | 95 | if (type instanceof GraphQLInputObjectType) { 96 | Object.values(type.getFields()).forEach(field => 97 | collectUnknownNamedTypes(schema, field.type, unknownTypes), 98 | ); 99 | } 100 | } 101 | } 102 | }; 103 | 104 | // Ensures the directive contains the given `locations` and `args`, if those 105 | // are given. 106 | // 107 | // Extra locations are kept, while missing locations are added. 108 | // 109 | // Extra arguments are kept. Existing arguments will have their types forced 110 | // to the given config.type (default values and other properties are 111 | // untouched). Missing arguments are added. 112 | const patchDirective = ( 113 | directive: GraphQLDirective, 114 | { args, locations }: ReadonlyGraphQLDirectiveConfigWithoutName, 115 | ): GraphQLDirective => { 116 | const directiveConfig = directive.toConfig(); 117 | locations.forEach(loc => { 118 | if (!directive.locations.includes(loc)) { 119 | directiveConfig.locations = [...directiveConfig.locations, loc]; 120 | } 121 | }); 122 | 123 | if (args) { 124 | Object.entries(args).forEach( 125 | ([ 126 | argName, 127 | { 128 | astNode, 129 | defaultValue, 130 | deprecationReason, 131 | description, 132 | extensions, 133 | type, 134 | }, 135 | ]) => { 136 | const arg = directive.args.find(({ name }) => argName === name); 137 | if (arg) { 138 | arg.type = type; 139 | directiveConfig.args[argName] = arg; 140 | } else { 141 | directiveConfig.args[argName] = { 142 | astNode, 143 | defaultValue, 144 | deprecationReason, 145 | description, 146 | extensions, 147 | type, 148 | }; 149 | } 150 | }, 151 | ); 152 | } 153 | 154 | return new GraphQLDirective(directiveConfig); 155 | }; 156 | 157 | export const getDirectiveDeclaration = ( 158 | defaultName: string, 159 | config: ReadonlyGraphQLDirectiveConfigWithoutName, 160 | givenDirectiveName?: string, 161 | schema?: GraphQLSchema, 162 | ): GraphQLDirective => { 163 | const directiveName = givenDirectiveName || defaultName; 164 | const previousDirective = schema && schema.getDirective(directiveName); 165 | if (previousDirective) { 166 | return patchDirective(previousDirective, config); 167 | } 168 | 169 | const { locations, ...partialConfig } = config; 170 | return new GraphQLDirective({ 171 | ...partialConfig, 172 | locations: Array.from(locations), 173 | name: directiveName, 174 | }); 175 | }; 176 | 177 | /** 178 | * Abstract class to implement helpers to aid `SchemaDirectiveVisitor` 179 | * implementation. 180 | * 181 | * It will provide useful static methods such as: 182 | * - `getDirectiveDeclaration()` based on a class-defined `config`. 183 | * - `getMissingCommonTypeDefs()` checks which of the class-defined 184 | * `commonTypes` (named types such as objects, input, enums and scalars) 185 | * are missing in `schema` and return their parsed AST `DocumentNode` to 186 | * be used in `makeExecutableSchema()`. 187 | * - `getTypeDefs()` returns the default type defs based on class-defined 188 | * `config`. 189 | */ 190 | abstract class EasyDirectiveVisitor< 191 | TArgs extends object, 192 | TContext extends object, 193 | TLocation extends DirectiveLocation = never, 194 | > { 195 | args: TArgs; 196 | 197 | /** 198 | * How the directive should be configured. 199 | * 200 | * The given arguments and location will be ensured in the final directive 201 | * when it's created: 202 | * - arguments will be added if does not exist, or their types will be 203 | * patched to ensure the given types. 204 | * - locations will be added if not contained in the existing directive 205 | * locations. 206 | */ 207 | public static readonly config: ReadonlyGraphQLDirectiveConfigWithoutName = { 208 | locations: [], 209 | }; 210 | 211 | /** 212 | * Declares the types indirectly used by this directive. 213 | * 214 | * For instance, if the directive may extend the return or input 215 | * types, you may list them here. 216 | * 217 | * @note List here types that are not part of the directive itself! 218 | * 219 | * @note do not use directly, prefer `getMissingCommonTypeDefs()` or 220 | * `getTypeDefs()`. 221 | */ 222 | public static readonly commonTypes: Readonly< 223 | ( 224 | | GraphQLNamedType 225 | | GraphQLList 226 | | GraphQLNonNull 227 | )[] 228 | > = []; 229 | 230 | /** 231 | * The default name to use with this directive. 232 | * 233 | * This is used in `getDirectiveDeclaration()` and 234 | * `getTypeDefs()` if no directive name is given. 235 | */ 236 | public static readonly defaultName: string = ''; 237 | 238 | /** 239 | * Implements getDirectiveDeclaration() based on class-defined `config` 240 | * 241 | * If a directive already exists, then the directive will 242 | * be patched to contain all of the given locations. 243 | * 244 | * If a directive already exists and `args` is given, then the directive 245 | * will be patched to contain at least those arguments. If an argument 246 | * already exists, it's type is forced to the given argument type ( 247 | * default value and the other properties are not touched). If an argument 248 | * does not exist, it's created with the given config. 249 | */ 250 | public static getDirectiveDeclaration( 251 | givenDirectiveName?: string, 252 | schema?: GraphQLSchema, 253 | ): GraphQLDirective { 254 | return getDirectiveDeclaration( 255 | this.defaultName, 256 | this.config, 257 | givenDirectiveName, 258 | schema, 259 | ); 260 | } 261 | 262 | /** 263 | * Concrete classes should be able to return the parsed typeDefs 264 | * for this directive and required types (if `includeUnknownTypes: true`) 265 | * and the given `schema` doesn't know about them. 266 | * 267 | * @note internally calls `getDirectiveDeclaration(directiveName, schema)` 268 | * 269 | * @param directiveName will generate `@${directiveName}` directive 270 | * @param schema will be used to lookup for existing directive and types. 271 | * @param includeUnknownTypes also output any unknown input object, scalars 272 | * or enums used by this directive that are unknown in the `schema`. 273 | * @param includeCommonTypes if true will also call 274 | * `getMissingCommonTypeDefs()` on the schema and concatenate the 275 | * results, making it easy to use. 276 | * 277 | * @returns array of parsed `DocumentNode`. 278 | */ 279 | public static getTypeDefs( 280 | directiveName?: string, 281 | schema?: GraphQLSchema, 282 | includeUnknownTypes = true, 283 | includeCommonTypes = true, 284 | ): DocumentNode[] { 285 | const directive = this.getDirectiveDeclaration(directiveName, schema); 286 | const typeDefs: DocumentNode[] = [gql(printDirective(directive))]; 287 | 288 | if (includeUnknownTypes) { 289 | const unknownTypes: GraphQLNamedType[] = []; 290 | directive.args.forEach(({ type }) => 291 | collectUnknownNamedTypes(schema, type, unknownTypes), 292 | ); 293 | unknownTypes.forEach(type => typeDefs.push(gql(printType(type)))); 294 | } 295 | 296 | if (includeCommonTypes) { 297 | this.getMissingCommonTypeDefs(schema).forEach(def => typeDefs.push(def)); 298 | } 299 | 300 | return typeDefs; 301 | } 302 | 303 | /** 304 | * These parsed `DocumentNode` contains input types used by the injected 305 | * argument/input object validation directive and are missing in the 306 | * given schema. See 307 | * `ValidateDirectiveVisitor.validationErrorsArgumentName`. 308 | */ 309 | public static getMissingCommonTypeDefs( 310 | schema?: GraphQLSchema, 311 | ): DocumentNode[] { 312 | const unknownTypes: GraphQLNamedType[] = []; 313 | this.commonTypes.forEach(type => 314 | // @ts-expect-error (FIXME: type is not assignable) 315 | collectUnknownNamedTypes(schema, type, unknownTypes), 316 | ); 317 | return unknownTypes.map(type => gql(printType(type))); 318 | } 319 | 320 | /* eslint-disable class-methods-use-this, class-methods-use-this, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */ 321 | // istanbul ignore next (should be overridden and never reached) 322 | public visitArgumentDefinition( 323 | argument: GraphQLArgument, 324 | { field }: { field: GraphQLField }, 325 | ): TLocation extends DirectiveLocation.ARGUMENT_DEFINITION ? void : never { 326 | throw new Error('Method not implemented.'); 327 | } 328 | 329 | // istanbul ignore next (should be overridden and never reached) 330 | public visitInputObject(object: GraphQLInputObjectType): void { 331 | throw new Error('Method not implemented.'); 332 | } 333 | 334 | // istanbul ignore next (should be overridden and never reached) 335 | public visitInputFieldDefinition( 336 | field: GraphQLInputField, 337 | { objectType }: { objectType: GraphQLInputObjectType }, 338 | ): TLocation extends DirectiveLocation.INPUT_FIELD_DEFINITION ? void : never { 339 | throw new Error('Method not implemented.'); 340 | } 341 | 342 | // istanbul ignore next (should be overridden and never reached) 343 | public visitFieldDefinition( 344 | field: GraphQLFieldConfig, 345 | { objectType }: { objectType: GraphQLObjectType }, 346 | ): TLocation extends DirectiveLocation.FIELD_DEFINITION ? void : never { 347 | throw new Error('Method not implemented.'); 348 | } 349 | 350 | // istanbul ignore next (should be overridden and never reached) 351 | public visitObject( 352 | object: GraphQLInterfaceType | GraphQLObjectType, 353 | ): TLocation extends DirectiveLocation.OBJECT ? void : never { 354 | throw new Error('Method not implemented.'); 355 | } 356 | 357 | // istanbul ignore next (should be overridden and never reached) 358 | public visitObjectFieldsAndArgumentInputs( 359 | object: GraphQLObjectType, 360 | schema: GraphQLSchema, 361 | directiveName: string, 362 | ): GraphQLObjectType { 363 | throw new Error('Method not implemented.'); 364 | } 365 | 366 | public addInputTypesValidations( 367 | schema: GraphQLSchema, 368 | directiveName: string, 369 | ): void {} 370 | /* eslint-enable class-methods-use-this, class-methods-use-this, @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any */ 371 | 372 | public applyToSchema(schema: GraphQLSchema): GraphQLSchema { 373 | const mapper = createSchemaMapperForVisitor( 374 | (this.constructor as typeof EasyDirectiveVisitor).defaultName, 375 | this, 376 | ); 377 | return mapper(schema); 378 | } 379 | } 380 | 381 | export default EasyDirectiveVisitor; 382 | -------------------------------------------------------------------------------- /lib/auth.test.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLResolveInfo } from 'graphql'; 2 | import { graphql } from 'graphql'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | import { gql } from 'graphql-tag'; 5 | 6 | import print from './utils/printer.js'; 7 | 8 | import AuthDirective from './auth.js'; 9 | import AuthenticationError from './errors/AuthenticationError.js'; 10 | 11 | describe('@auth()', (): void => { 12 | const name = 'auth'; 13 | const directiveTypeDefs = AuthDirective.getTypeDefs(name); 14 | 15 | it('exports correct typeDefs', (): void => { 16 | expect(directiveTypeDefs.map(print)).toEqual([ 17 | `\ 18 | """ensures is authenticated before calling the resolver""" 19 | directive @${name} on OBJECT | FIELD_DEFINITION 20 | `, 21 | ]); 22 | }); 23 | 24 | it('defaultName is correct', (): void => { 25 | expect(directiveTypeDefs).toEqual(AuthDirective.getTypeDefs()); 26 | }); 27 | 28 | describe('createDirectiveContext()', (): void => { 29 | it('supports function', (): void => { 30 | const isAuthenticated = (): boolean => true; 31 | const ctx = AuthDirective.createDirectiveContext({ 32 | isAuthenticated, 33 | }); 34 | expect(ctx.isAuthenticated).toBe(isAuthenticated); 35 | }); 36 | 37 | it('supports constant', (): void => { 38 | const isAuthenticated = false; 39 | const ctx = AuthDirective.createDirectiveContext({ 40 | isAuthenticated, 41 | }); 42 | expect(ctx.isAuthenticated({}, {}, {}, {} as GraphQLResolveInfo)).toBe( 43 | isAuthenticated, 44 | ); 45 | }); 46 | }); 47 | 48 | describe('works on object field', (): void => { 49 | const auth = new AuthDirective(); 50 | const schema = auth.applyToSchema( 51 | makeExecutableSchema({ 52 | typeDefs: [ 53 | ...directiveTypeDefs, 54 | gql` 55 | type SomeObject { 56 | authenticatedField: Int @auth 57 | publicField: String 58 | } 59 | type Query { 60 | test: SomeObject 61 | } 62 | `, 63 | ], 64 | }), 65 | ); 66 | const source = print(gql` 67 | query { 68 | test { 69 | authenticatedField 70 | publicField 71 | } 72 | } 73 | `); 74 | const rootValue = { 75 | test: { 76 | authenticatedField: 42, 77 | publicField: 'hello', 78 | }, 79 | }; 80 | 81 | it('if authenticated, returns all', async (): Promise => { 82 | const contextValue = AuthDirective.createDirectiveContext({ 83 | isAuthenticated: true, 84 | }); 85 | const result = await graphql({ 86 | contextValue, 87 | rootValue, 88 | schema, 89 | source, 90 | }); 91 | expect(result).toEqual({ 92 | data: rootValue, 93 | }); 94 | }); 95 | 96 | it('if NOT authenticated, returns partial', async (): Promise => { 97 | const contextValue = AuthDirective.createDirectiveContext({ 98 | isAuthenticated: false, 99 | }); 100 | const result = await graphql({ contextValue, rootValue, schema, source }); 101 | expect(result).toEqual({ 102 | data: { 103 | test: { 104 | authenticatedField: null, 105 | publicField: rootValue.test.publicField, 106 | }, 107 | }, 108 | errors: [new AuthenticationError('Unauthenticated')], 109 | }); 110 | }); 111 | }); 112 | 113 | describe('works on whole object', (): void => { 114 | const auth = new AuthDirective(); 115 | const schema = auth.applyToSchema( 116 | makeExecutableSchema({ 117 | typeDefs: [ 118 | ...directiveTypeDefs, 119 | gql` 120 | type MyAuthenticatedObject @auth { 121 | authenticatedField: Int # behaves as @auth 122 | anotherAuthenticatedField: String # behaves as @auth 123 | } 124 | type Query { 125 | test: MyAuthenticatedObject 126 | } 127 | `, 128 | ], 129 | }), 130 | ); 131 | const source = print(gql` 132 | query { 133 | test { 134 | authenticatedField 135 | anotherAuthenticatedField 136 | } 137 | } 138 | `); 139 | const rootValue = { 140 | test: { 141 | anotherAuthenticatedField: 'hello', 142 | authenticatedField: 42, 143 | }, 144 | }; 145 | 146 | it('if authenticated, returns all', async (): Promise => { 147 | const contextValue = AuthDirective.createDirectiveContext({ 148 | isAuthenticated: true, 149 | }); 150 | const result = await graphql({ 151 | contextValue, 152 | rootValue, 153 | schema, 154 | source, 155 | }); 156 | expect(result).toEqual({ 157 | data: rootValue, 158 | }); 159 | }); 160 | 161 | it('if NOT authenticated, returns partial', async (): Promise => { 162 | const contextValue = AuthDirective.createDirectiveContext({ 163 | isAuthenticated: false, 164 | }); 165 | const result = await graphql({ 166 | contextValue, 167 | rootValue, 168 | schema, 169 | source, 170 | }); 171 | expect(result).toEqual({ 172 | data: { 173 | test: { 174 | anotherAuthenticatedField: null, 175 | authenticatedField: null, 176 | }, 177 | }, 178 | errors: [ 179 | new AuthenticationError('Unauthenticated'), 180 | new AuthenticationError('Unauthenticated'), 181 | ], 182 | }); 183 | }); 184 | }); 185 | 186 | describe('works on query field', (): void => { 187 | const auth = new AuthDirective(); 188 | const schema = auth.applyToSchema( 189 | makeExecutableSchema({ 190 | typeDefs: [ 191 | ...directiveTypeDefs, 192 | gql` 193 | type MyAuthenticatedObject { 194 | authenticatedField: Int 195 | anotherAuthenticatedField: String 196 | } 197 | type Query { 198 | test: MyAuthenticatedObject @${name} 199 | } 200 | `, 201 | ], 202 | }), 203 | ); 204 | const source = print(gql` 205 | query { 206 | test { 207 | authenticatedField 208 | anotherAuthenticatedField 209 | } 210 | } 211 | `); 212 | const rootValue = { 213 | test: { 214 | anotherAuthenticatedField: 'hello', 215 | authenticatedField: 42, 216 | }, 217 | }; 218 | 219 | it('if authenticated, returns value', async (): Promise => { 220 | const contextValue = AuthDirective.createDirectiveContext({ 221 | isAuthenticated: true, 222 | }); 223 | const result = await graphql({ 224 | contextValue, 225 | rootValue, 226 | schema, 227 | source, 228 | }); 229 | expect(result).toEqual({ 230 | data: rootValue, 231 | }); 232 | }); 233 | 234 | it('if NOT authenticated, returns partial', async (): Promise => { 235 | const contextValue = AuthDirective.createDirectiveContext({ 236 | isAuthenticated: false, 237 | }); 238 | const result = await graphql({ 239 | contextValue, 240 | rootValue, 241 | schema, 242 | source, 243 | }); 244 | expect(result).toEqual({ 245 | data: { 246 | test: null, 247 | }, 248 | errors: [new AuthenticationError('Unauthenticated')], 249 | }); 250 | }); 251 | }); 252 | 253 | describe('works on mutation fields', (): void => { 254 | const mockResolver = jest.fn().mockReturnValue(42); 255 | const schema = new AuthDirective().applyToSchema( 256 | makeExecutableSchema({ 257 | resolvers: { 258 | Mutation: { 259 | testMutation: mockResolver, 260 | }, 261 | }, 262 | typeDefs: [ 263 | ...directiveTypeDefs, 264 | gql` 265 | type Query { 266 | test: Int 267 | } 268 | type Mutation { 269 | testMutation: Int @${name} 270 | otherMutation: Int 271 | } 272 | `, 273 | ], 274 | }), 275 | ); 276 | const source = print(gql` 277 | mutation { 278 | testMutation 279 | } 280 | `); 281 | const rootValue = { 282 | test: 0, 283 | }; 284 | 285 | beforeEach(() => { 286 | mockResolver.mockClear(); 287 | }); 288 | 289 | it('if authenticated, performs mutation', async (): Promise => { 290 | const contextValue = AuthDirective.createDirectiveContext({ 291 | isAuthenticated: true, 292 | }); 293 | const result = await graphql({ 294 | contextValue, 295 | rootValue, 296 | schema, 297 | source, 298 | }); 299 | expect(result).toEqual({ 300 | data: { 301 | testMutation: 42, 302 | }, 303 | }); 304 | expect(mockResolver).toHaveBeenCalledTimes(1); 305 | }); 306 | 307 | it('if NOT authenticated, throws error and does not call the resolver', async (): Promise => { 308 | const contextValue = AuthDirective.createDirectiveContext({ 309 | isAuthenticated: false, 310 | }); 311 | const result = await graphql({ 312 | contextValue, 313 | rootValue, 314 | schema, 315 | source, 316 | }); 317 | expect(result).toEqual({ 318 | data: { 319 | testMutation: null, 320 | }, 321 | errors: [new AuthenticationError('Unauthenticated')], 322 | }); 323 | expect(mockResolver).not.toHaveBeenCalled(); 324 | }); 325 | }); 326 | }); 327 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver, DirectiveLocation } from 'graphql'; 2 | import type { 3 | GraphQLField, 4 | GraphQLFieldResolver, 5 | GraphQLInterfaceType, 6 | GraphQLObjectType, 7 | GraphQLFieldConfig, 8 | } from 'graphql'; 9 | 10 | import EasyDirectiveVisitor from './EasyDirectiveVisitor.js'; 11 | import AuthenticationError from './errors/AuthenticationError.js'; 12 | 13 | type ResolverArgs = Parameters< 14 | GraphQLFieldResolver 15 | >; 16 | 17 | export type AuthContext = { 18 | isAuthenticated: (...args: ResolverArgs) => boolean; 19 | }; 20 | 21 | class AuthDirectiveVisitor< 22 | TContext extends AuthContext, 23 | > extends EasyDirectiveVisitor< 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | any, 26 | TContext, 27 | | DirectiveLocation.QUERY 28 | | DirectiveLocation.OBJECT 29 | | DirectiveLocation.FIELD_DEFINITION 30 | | DirectiveLocation.MUTATION 31 | > { 32 | public errorMessage = 'Unauthenticated'; 33 | 34 | public static readonly config: (typeof EasyDirectiveVisitor)['config'] = { 35 | description: 'ensures is authenticated before calling the resolver', 36 | locations: [DirectiveLocation.OBJECT, DirectiveLocation.FIELD_DEFINITION], 37 | }; 38 | 39 | public static readonly defaultName: string = 'auth'; 40 | 41 | public static createDirectiveContext({ 42 | isAuthenticated, 43 | }: { 44 | isAuthenticated: boolean | ((...args: ResolverArgs) => boolean); 45 | }): AuthContext { 46 | return { 47 | isAuthenticated: 48 | typeof isAuthenticated === 'function' 49 | ? isAuthenticated 50 | : (): boolean => !!isAuthenticated, 51 | }; 52 | } 53 | 54 | public visitObject(object: GraphQLObjectType | GraphQLInterfaceType): void { 55 | Object.values(object.getFields()).forEach(field => { 56 | this.visitFieldDefinition(field); 57 | }); 58 | } 59 | 60 | public visitFieldDefinition( 61 | field: 62 | | GraphQLFieldConfig 63 | | GraphQLField, 64 | ): void { 65 | const { resolve = defaultFieldResolver } = field; 66 | const { errorMessage } = this; 67 | 68 | // eslint-disable-next-line no-param-reassign 69 | field.resolve = function (...args): unknown { 70 | const { isAuthenticated } = args[2]; 71 | if (!isAuthenticated.apply(this, args)) { 72 | throw new AuthenticationError(errorMessage); 73 | } 74 | 75 | return resolve.apply(this, args); 76 | }; 77 | } 78 | 79 | // eslint-disable-next-line class-methods-use-this 80 | public visitObjectFieldsAndArgumentInputs( 81 | object: GraphQLObjectType, 82 | ): GraphQLObjectType { 83 | return object; 84 | } 85 | } 86 | 87 | export default AuthDirectiveVisitor; 88 | 89 | /* 90 | graphql-tools changed the typing for SchemaDirectiveVisitor and if you define a type for TArgs and TContext, 91 | you'll get this error: "Type 'typeof Your_Directive_Class' is not assignable to type 'typeof SchemaDirectiveVisitor'.". 92 | If you are using the old graphql-tools, you can use: 93 | extends EasyDirectiveVisitor, TContext> 94 | */ 95 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 96 | export const AuthDirectiveVisitorNonTyped: typeof AuthDirectiveVisitor = 97 | AuthDirectiveVisitor; 98 | -------------------------------------------------------------------------------- /lib/capitalize.ts: -------------------------------------------------------------------------------- 1 | const capitalize = (str: string): string => str[0].toUpperCase() + str.slice(1); 2 | 3 | export default capitalize; 4 | -------------------------------------------------------------------------------- /lib/cleanupPattern.test.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLSchema } from 'graphql'; 2 | import { gql } from 'graphql-tag'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | 5 | import CleanupPattern from './cleanupPattern.js'; 6 | import capitalize from './capitalize.js'; 7 | 8 | import type { 9 | CreateSchemaConfig, 10 | ExpectedTestResult, 11 | } from './test-utils.test.js'; 12 | import { 13 | testEasyDirective, 14 | validationDirectivePolicyArgs, 15 | } from './test-utils.test.js'; 16 | 17 | type RootValue = { 18 | arrayTest?: (string | null)[] | null; 19 | test?: string | null; 20 | number?: number; 21 | bool?: boolean; 22 | obj?: { toString(): string }; 23 | }; 24 | 25 | const createSchema = ({ 26 | name, 27 | testCase: { directiveArgs }, 28 | }: CreateSchemaConfig): GraphQLSchema => 29 | new CleanupPattern().applyToSchema( 30 | makeExecutableSchema({ 31 | typeDefs: [ 32 | ...CleanupPattern.getTypeDefs(name, undefined, true, true), 33 | gql` 34 | type Query { 35 | test: String @${name}${directiveArgs} 36 | } 37 | `, 38 | ], 39 | }), 40 | ); 41 | 42 | const expectedResult = ( 43 | value: string, 44 | key: keyof RootValue = 'test', 45 | ): ExpectedTestResult => ({ 46 | data: { [key]: value }, 47 | }); 48 | 49 | const name = 'cleanupPattern'; 50 | 51 | const noNumbers = 'No Numbers'; 52 | 53 | testEasyDirective({ 54 | createSchema, 55 | DirectiveVisitor: CleanupPattern, 56 | expectedArgsTypeDefs: `\ 57 | ( 58 | flags: String 59 | regexp: String! 60 | replaceWith: String! = "" 61 | ${validationDirectivePolicyArgs(capitalize(name))} 62 | )`, 63 | name, 64 | testCases: [ 65 | { 66 | directiveArgs: `(regexp: "\\\\d", flags: "g", replaceWith:"${noNumbers}")`, 67 | operation: '{ test }', 68 | tests: [ 69 | { rootValue: { test: noNumbers } }, 70 | { 71 | expected: expectedResult(noNumbers.repeat(3)), 72 | rootValue: { test: '123' }, 73 | }, 74 | { 75 | expected: expectedResult(`${noNumbers} abc, cd`), 76 | rootValue: { test: '1 abc, cd' }, 77 | }, 78 | ], 79 | }, 80 | { 81 | directiveArgs: '(regexp: "")', 82 | operation: '{ test }', 83 | tests: [{ rootValue: { test: 'abc' } }, { rootValue: { test: '123' } }], 84 | }, 85 | { 86 | directiveArgs: '(regexp: "[a-z]+")', 87 | operation: '{ test }', 88 | tests: [ 89 | { expected: expectedResult(''), rootValue: { test: 'abc' } }, 90 | { rootValue: { test: '123' } }, 91 | { rootValue: { test: '+-*/5' } }, 92 | { 93 | expected: expectedResult('!@#$%¨&'), 94 | rootValue: { test: '!@#$%¨&a' }, 95 | }, 96 | ], 97 | }, 98 | ], 99 | }); 100 | -------------------------------------------------------------------------------- /lib/cleanupPattern.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLNonNull, GraphQLString } from 'graphql'; 2 | 3 | import type { ValidateFunction } from './ValidateDirectiveVisitor.js'; 4 | import createValidateDirectiveVisitor from './createValidateDirectiveVisitor.js'; 5 | import type { PatternDirectiveArgs } from './patternCommon.js'; 6 | import createPatternHandler, { defaultArgs } from './patternCommon.js'; 7 | 8 | type CleanUpPatternArgs = PatternDirectiveArgs & { replaceWith: string }; 9 | 10 | const createValidate = ({ 11 | regexp, 12 | flags = null, 13 | replaceWith, 14 | }: CleanUpPatternArgs): ValidateFunction | undefined => { 15 | if (!regexp) return undefined; 16 | const re = new RegExp(regexp, flags || undefined); 17 | return createPatternHandler((value: string): string => 18 | value.replace(re, replaceWith), 19 | ); 20 | }; 21 | 22 | const Visitor = createValidateDirectiveVisitor({ 23 | createValidate, 24 | defaultName: 'cleanupPattern', 25 | directiveConfig: { 26 | args: { 27 | ...defaultArgs, 28 | replaceWith: { 29 | defaultValue: '', 30 | type: new GraphQLNonNull(GraphQLString), 31 | }, 32 | }, 33 | description: 'replaces a text based on a regex', 34 | }, 35 | }); 36 | 37 | export default Visitor; 38 | -------------------------------------------------------------------------------- /lib/createSchemaMapperForVisitor.ts: -------------------------------------------------------------------------------- 1 | import type { SchemaMapper } from '@graphql-tools/utils'; 2 | import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'; 3 | import type { 4 | DirectiveLocation, 5 | GraphQLFieldConfig, 6 | GraphQLObjectType, 7 | GraphQLSchema, 8 | } from 'graphql'; 9 | import { isObjectType } from 'graphql'; 10 | 11 | import type EasyDirectiveVisitor from './EasyDirectiveVisitor.js'; 12 | 13 | export type SchemaMapperFunction = (schema: GraphQLSchema) => GraphQLSchema; 14 | 15 | export const createMapper = ( 16 | directiveName: string, 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | visitor: EasyDirectiveVisitor, 19 | ): SchemaMapper => { 20 | let haveVisitedInputs = false; 21 | return { 22 | [MapperKind.OBJECT_TYPE](type, schema): GraphQLObjectType { 23 | if (!haveVisitedInputs) { 24 | visitor.addInputTypesValidations(schema, directiveName); 25 | haveVisitedInputs = true; 26 | } 27 | visitor.visitObjectFieldsAndArgumentInputs(type, schema, directiveName); 28 | const [directive] = getDirective(schema, type, directiveName) ?? []; 29 | if (!directive) return type; 30 | // eslint-disable-next-line no-param-reassign 31 | visitor.args = directive; 32 | visitor.visitObject(type); 33 | return type; 34 | }, 35 | [MapperKind.OBJECT_FIELD]( 36 | fieldConfig, 37 | _fieldName, 38 | typeName, 39 | schema, 40 | ): GraphQLFieldConfig { 41 | const [directive] = 42 | getDirective(schema, fieldConfig, directiveName) ?? []; 43 | if (!directive) return fieldConfig; 44 | // eslint-disable-next-line no-param-reassign 45 | visitor.args = directive; 46 | const objectType = schema.getType(typeName); 47 | if (isObjectType(objectType)) { 48 | visitor.visitFieldDefinition(fieldConfig, { 49 | objectType, 50 | }); 51 | } 52 | return fieldConfig; 53 | }, 54 | }; 55 | }; 56 | 57 | export const createSchemaMapperForVisitor = 58 | ( 59 | directiveName: string, 60 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 61 | visitor: EasyDirectiveVisitor, 62 | ): SchemaMapperFunction => 63 | (unmappedSchema: GraphQLSchema): GraphQLSchema => 64 | mapSchema(unmappedSchema, createMapper(directiveName, visitor)); 65 | 66 | export default createSchemaMapperForVisitor; 67 | -------------------------------------------------------------------------------- /lib/createValidateDirectiveVisitor.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql, GraphQLBoolean, GraphQLObjectType } from 'graphql'; 2 | import { makeExecutableSchema } from '@graphql-tools/schema'; 3 | import { gql } from 'graphql-tag'; 4 | 5 | import createValidateDirectiveVisitor from './createValidateDirectiveVisitor.js'; 6 | import ValidateDirectiveVisitor from './ValidateDirectiveVisitor.js'; 7 | 8 | describe('createValidateDirectiveVisitor', (): void => { 9 | const validate = jest.fn((x: unknown): unknown => 10 | typeof x === 'number' ? x * 2 : x, 11 | ); 12 | const createValidate = jest.fn(() => validate); 13 | const defaultName = 'testDirective'; 14 | 15 | beforeEach((): void => { 16 | validate.mockClear(); 17 | createValidate.mockClear(); 18 | }); 19 | 20 | it('defaults work', async (): Promise => { 21 | const Directive = createValidateDirectiveVisitor({ 22 | createValidate, 23 | defaultName, 24 | }); 25 | const schema = new Directive().applyToSchema( 26 | makeExecutableSchema({ 27 | typeDefs: [ 28 | ...Directive.getTypeDefs(), 29 | gql` 30 | type Query { 31 | item: Int @${defaultName} 32 | list: [Int] @${defaultName} 33 | } 34 | `, 35 | ], 36 | }), 37 | ); 38 | const rootValue = { item: 2, list: [3, 4] }; 39 | 40 | expect(Directive.name).toBe('TestDirectiveDirectiveVisitor'); 41 | expect(Directive.defaultName).toBe(defaultName); 42 | expect(Directive.commonTypes).toBe(ValidateDirectiveVisitor.commonTypes); 43 | expect(Directive.config).toBe(ValidateDirectiveVisitor.config); 44 | 45 | const result = await graphql({ 46 | rootValue, 47 | schema, 48 | source: '{ item list }', 49 | }); 50 | expect(result).toEqual({ 51 | data: { 52 | item: rootValue.item * 2, 53 | list: rootValue.list.map(x => x * 2), 54 | }, 55 | }); 56 | expect(validate).toBeCalledTimes(3); 57 | expect(createValidate).toBeCalledTimes(2); 58 | }); 59 | 60 | it('custom', async (): Promise => { 61 | const extraCommonTypes = [ 62 | new GraphQLObjectType({ 63 | fields: { 64 | field: { type: GraphQLBoolean }, 65 | }, 66 | name: 'CustomObject', 67 | }), 68 | ]; 69 | const directiveConfig = { 70 | args: { 71 | arg: { type: GraphQLBoolean }, 72 | }, 73 | }; 74 | const Directive = createValidateDirectiveVisitor({ 75 | createValidate, 76 | defaultName, 77 | directiveConfig, 78 | extraCommonTypes, 79 | isValidateArrayOrValue: false, 80 | }); 81 | const schema = new Directive().applyToSchema( 82 | makeExecutableSchema({ 83 | typeDefs: [ 84 | ...Directive.getTypeDefs(), 85 | gql` 86 | type Query { 87 | item: Int @${defaultName}(arg: true) 88 | list: [Int] @${defaultName}(arg: true) 89 | } 90 | `, 91 | ], 92 | }), 93 | ); 94 | const rootValue = { item: 2, list: [3, 4] }; 95 | 96 | expect(Directive.name).toBe('TestDirectiveDirectiveVisitor'); 97 | expect(Directive.defaultName).toBe(defaultName); 98 | expect(Directive.commonTypes).toEqual([ 99 | ...ValidateDirectiveVisitor.commonTypes, 100 | ...extraCommonTypes, 101 | ]); 102 | expect(Directive.config).toEqual({ 103 | ...ValidateDirectiveVisitor.config, 104 | ...directiveConfig, 105 | args: { 106 | ...directiveConfig.args, 107 | ...ValidateDirectiveVisitor.config.args, 108 | }, 109 | }); 110 | 111 | const result = await graphql({ 112 | rootValue, 113 | schema, 114 | source: '{ item list }', 115 | }); 116 | expect(result).toEqual({ 117 | data: { 118 | item: rootValue.item * 2, 119 | list: rootValue.list, 120 | }, 121 | }); 122 | expect(validate).toBeCalledTimes(2); 123 | expect(createValidate).toBeCalledTimes(2); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /lib/createValidateDirectiveVisitor.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ValidateFunction, 3 | ValidationDirectiveArgs, 4 | } from './ValidateDirectiveVisitor.js'; 5 | import { ValidateDirectiveVisitorNonTyped } from './ValidateDirectiveVisitor.js'; 6 | import validateArrayOrValue from './validateArrayOrValue.js'; 7 | 8 | export type CreateValidate = ( 9 | args: TArgs, 10 | ) => ValidateFunction | undefined; 11 | 12 | /* 13 | graphql-tools changed the typing for SchemaDirectiveVisitor and if you define a type for TArgs and TContext, 14 | you'll get this error: "Type 'typeof Your_Directive_Class' is not assignable to type 'typeof SchemaDirectiveVisitor'.". 15 | If you are using the old graphql-tools, you can use: 16 | 17 | export class ConcreteValidateDirectiveVisitor< 18 | TArgs extends ValidationDirectiveArgs, 19 | TContext extends object, 20 | > extends ValidateDirectiveVisitor { 21 | */ 22 | export class ConcreteValidateDirectiveVisitor extends ValidateDirectiveVisitorNonTyped { 23 | // istanbul ignore next (this shouldn't be used) 24 | // eslint-disable-next-line class-methods-use-this 25 | public getValidationForArgs(): ValidateFunction | undefined { 26 | throw new Error( 27 | 'ValidateDirectiveVisitor.getValidationForArgs() must be implemented', 28 | ); 29 | } 30 | } 31 | 32 | const createValidateDirectiveVisitor = ({ 33 | createValidate, 34 | defaultName, 35 | directiveConfig, 36 | extraCommonTypes, 37 | isValidateArrayOrValue = true, 38 | }: { 39 | createValidate: CreateValidate; 40 | defaultName: string; 41 | directiveConfig?: Partial< 42 | (typeof ValidateDirectiveVisitorNonTyped)['config'] 43 | >; 44 | extraCommonTypes?: (typeof ValidateDirectiveVisitorNonTyped)['commonTypes']; 45 | isValidateArrayOrValue?: boolean; // if true uses validateArrayOrValue() 46 | }): typeof ConcreteValidateDirectiveVisitor => { 47 | class CreateValidateDirectiveVisitor extends ConcreteValidateDirectiveVisitor { 48 | public static readonly commonTypes = extraCommonTypes 49 | ? ValidateDirectiveVisitorNonTyped.commonTypes.concat(extraCommonTypes) 50 | : ValidateDirectiveVisitorNonTyped.commonTypes; 51 | 52 | public static readonly config = directiveConfig 53 | ? ({ 54 | ...ValidateDirectiveVisitorNonTyped.config, 55 | ...directiveConfig, 56 | } as const) 57 | : ValidateDirectiveVisitorNonTyped.config; 58 | 59 | public static readonly defaultName = defaultName; 60 | 61 | // eslint-disable-next-line class-methods-use-this 62 | public getValidationForArgs(): ValidateFunction | undefined { 63 | const validate = createValidate(this.args); 64 | if ( 65 | typeof validate === 'function' && 66 | !('validateProperties' in validate) 67 | ) { 68 | Object.defineProperty(validate, 'validateProperties', { 69 | value: { 70 | args: this.args, 71 | directive: defaultName, 72 | }, 73 | writable: false, 74 | }); 75 | } 76 | return isValidateArrayOrValue ? validateArrayOrValue(validate) : validate; 77 | } 78 | } 79 | 80 | Object.defineProperty(CreateValidateDirectiveVisitor, 'name', { 81 | value: `${ 82 | defaultName[0].toUpperCase() + defaultName.slice(1) 83 | }DirectiveVisitor`, 84 | writable: false, 85 | }); 86 | 87 | return CreateValidateDirectiveVisitor as typeof ConcreteValidateDirectiveVisitor; 88 | }; 89 | 90 | export default createValidateDirectiveVisitor; 91 | -------------------------------------------------------------------------------- /lib/errors/AuthenticationError.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | export default class AuthenticationError extends GraphQLError { 4 | constructor(message: string) { 5 | super(message, { 6 | extensions: { 7 | code: 'UNAUTHENTICATED', 8 | }, 9 | }); 10 | Object.defineProperty(this, 'name', { value: AuthenticationError.name }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/errors/ForbiddenError.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql'; 2 | 3 | export default class ForbiddenError extends GraphQLError { 4 | constructor(message: string) { 5 | super(message, { 6 | extensions: { 7 | code: 'FORBIDDEN', 8 | }, 9 | }); 10 | Object.defineProperty(this, 'name', { value: ForbiddenError.name }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/errors/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import type { SourceLocation } from 'graphql'; 2 | import { GraphQLError } from 'graphql'; 3 | 4 | export default class ValidationError extends GraphQLError { 5 | path: string[]; 6 | 7 | locations: SourceLocation[]; 8 | 9 | constructor(message: string) { 10 | super(message, { 11 | extensions: { 12 | code: 'GRAPHQL_VALIDATION_FAILED', 13 | }, 14 | }); 15 | Object.defineProperty(this, 'name', { value: ValidationError.name }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/foreignNodeId.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import { makeExecutableSchema } from '@graphql-tools/schema'; 3 | import { gql } from 'graphql-tag'; 4 | 5 | import print from './utils/printer.js'; 6 | import type { ToNodeId } from './foreignNodeId.js'; 7 | import ForeignNodeId from './foreignNodeId.js'; 8 | import { 9 | validationDirectivePolicyArgs, 10 | validationDirectionEnumTypeDefs, 11 | } from './test-utils.test.js'; 12 | import capitalize from './capitalize.js'; 13 | import ValidationError from './errors/ValidationError.js'; 14 | 15 | describe('@foreignNodeId()', (): void => { 16 | const toNodeId = (typenane: string, id: string): string => 17 | Buffer.from(`${typenane}:${id}`).toString('base64'); 18 | const fromNodeId = (id: string): ReturnType> => { 19 | const r = Buffer.from(id, 'base64').toString('ascii').split(':'); 20 | return { 21 | id: r[1], 22 | typename: r[0], 23 | }; 24 | }; 25 | const name = 'foreignNodeId'; 26 | const capitalizedName = capitalize(name); 27 | const directiveTypeDefs = ForeignNodeId.getTypeDefs(name); 28 | 29 | it('exports correct typeDefs', (): void => { 30 | expect(directiveTypeDefs.map(print)).toEqual([ 31 | `\ 32 | """Converts a global unique ID to a type ID""" 33 | directive @${name}( 34 | """The typename that this ID should match""" 35 | typename: String! 36 | ${validationDirectivePolicyArgs(capitalizedName)} 37 | ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION 38 | `, 39 | `\ 40 | ${validationDirectionEnumTypeDefs(capitalizedName)} 41 | `, 42 | ]); 43 | }); 44 | 45 | it('defaultName is correct', (): void => { 46 | expect(directiveTypeDefs.map(print)).toEqual( 47 | ForeignNodeId.getTypeDefs().map(print), 48 | ); 49 | }); 50 | 51 | it('createDirectiveContext()', (): void => { 52 | const ctx = ForeignNodeId.createDirectiveContext({ 53 | fromNodeId, 54 | }); 55 | expect(ctx.fromNodeId).toBe(fromNodeId); 56 | }); 57 | 58 | it('should not work if fromNodeId returns null', async (): Promise => { 59 | const typename = 'X'; 60 | const schema = new ForeignNodeId().applyToSchema( 61 | makeExecutableSchema({ 62 | typeDefs: [ 63 | ...directiveTypeDefs, 64 | gql` 65 | type Query { 66 | work(arg: ID! @foreignNodeId(typename: "${typename}")): Boolean 67 | } 68 | `, 69 | ], 70 | }), 71 | ); 72 | const source = print(gql` 73 | query MyQuery($arg: ID!) { 74 | work(arg: $arg) 75 | } 76 | `); 77 | const variableValues = { 78 | arg: '1', 79 | }; 80 | const contextValue = ForeignNodeId.createDirectiveContext({ 81 | fromNodeId: () => null, 82 | }); 83 | const result = await graphql({ 84 | contextValue, 85 | schema, 86 | source, 87 | variableValues, 88 | }); 89 | expect(result).toEqual({ 90 | data: { work: null }, 91 | errors: [new ValidationError(`Could not decode ID to ${typename}`)], 92 | }); 93 | }); 94 | 95 | it('should not work on non string types', async (): Promise => { 96 | const schema = new ForeignNodeId().applyToSchema( 97 | makeExecutableSchema({ 98 | typeDefs: [ 99 | ...directiveTypeDefs, 100 | gql` 101 | input Input1 { 102 | typeId: Int! @foreignNodeId(typename: "A") 103 | } 104 | type Query { 105 | work(input: Input1!): Boolean 106 | } 107 | `, 108 | ], 109 | }), 110 | ); 111 | const source = print(gql` 112 | query MyQuery($input: Input1!) { 113 | work(input: $input) 114 | } 115 | `); 116 | const variableValues = { 117 | input: { 118 | typeId: 1, 119 | }, 120 | }; 121 | const contextValue = ForeignNodeId.createDirectiveContext({ 122 | fromNodeId, 123 | }); 124 | const result = await graphql({ 125 | contextValue, 126 | schema, 127 | source, 128 | variableValues, 129 | }); 130 | expect(result).toEqual({ 131 | data: { work: null }, 132 | errors: [ 133 | new ValidationError('foreignNodeId directive only works on strings'), 134 | ], 135 | }); 136 | }); 137 | 138 | it('typename does not match', async (): Promise => { 139 | const wrongName = 'wrong'; 140 | const typename = 'typename'; 141 | const schema = new ForeignNodeId().applyToSchema( 142 | makeExecutableSchema({ 143 | typeDefs: [ 144 | ...directiveTypeDefs, 145 | gql` 146 | type Query { 147 | work(arg: ID! @foreignNodeId(typename: "${typename}")): Boolean 148 | } 149 | `, 150 | ], 151 | }), 152 | ); 153 | const source = print(gql` 154 | query MyQuery($arg: ID!) { 155 | work(arg: $arg) 156 | } 157 | `); 158 | const variableValues = { 159 | arg: toNodeId(wrongName, '1'), 160 | }; 161 | const contextValue = ForeignNodeId.createDirectiveContext({ 162 | fromNodeId, 163 | }); 164 | const result = await graphql({ 165 | contextValue, 166 | schema, 167 | source, 168 | variableValues, 169 | }); 170 | expect(result).toEqual({ 171 | data: { work: null }, 172 | errors: [ 173 | new ValidationError( 174 | `Converted ID typename does not match. Expected: ${typename}`, 175 | ), 176 | ], 177 | }); 178 | }); 179 | 180 | it('correctly convert types', async (): Promise => { 181 | const idsMap = [ 182 | { id: 'bbb', typeName: 'Type1' }, 183 | { id: 'id2', typeName: 'Type2' }, 184 | { id: 'id3', typeName: 'Type3' }, 185 | { id: 'id4', typeName: 'Type4' }, 186 | { id: 'aaaaa', typeName: 'Type5' }, 187 | ]; 188 | const schema = new ForeignNodeId().applyToSchema( 189 | makeExecutableSchema({ 190 | resolvers: { 191 | Query: { 192 | work: (_, { arg, input }) => [ 193 | arg, 194 | input.typeId, 195 | input.typeId2, 196 | input.typeId3, 197 | input.typeId4, 198 | input.typeId5, 199 | ], 200 | }, 201 | }, 202 | typeDefs: [ 203 | ...directiveTypeDefs, 204 | gql` 205 | input Input1 { 206 | typeId: ID! @foreignNodeId(typename: "${idsMap[1].typeName}") 207 | typeId2: ID! @foreignNodeId(typename: "${idsMap[2].typeName}") 208 | typeId3: ID! @foreignNodeId(typename: "${idsMap[3].typeName}") 209 | typeId4: String! @foreignNodeId(typename: "${idsMap[4].typeName}") 210 | typeId5: String @foreignNodeId(typename: "${idsMap[4].typeName}") 211 | } 212 | type Query { 213 | work( 214 | input: Input1! 215 | arg: ID! @foreignNodeId(typename: "${idsMap[0].typeName}") 216 | ): [String]! 217 | } 218 | `, 219 | ], 220 | }), 221 | ); 222 | const source = print(gql` 223 | query MyQuery($input: Input1!, $arg: ID!) { 224 | work(input: $input, arg: $arg) 225 | secondWork: work(input: $input, arg: $arg) 226 | } 227 | `); 228 | const variableValues = { 229 | arg: toNodeId(idsMap[0].typeName, idsMap[0].id), 230 | input: { 231 | typeId: toNodeId(idsMap[1].typeName, idsMap[1].id), 232 | typeId2: toNodeId(idsMap[2].typeName, idsMap[2].id), 233 | typeId3: toNodeId(idsMap[3].typeName, idsMap[3].id), 234 | typeId4: toNodeId(idsMap[4].typeName, idsMap[4].id), 235 | typeId5: null, 236 | }, 237 | }; 238 | const workResult = idsMap 239 | .map(({ id }) => id as string | null) 240 | .concat([null]); 241 | const rootValue = { 242 | secondWork: workResult, 243 | work: workResult, 244 | }; 245 | 246 | const contextValue = ForeignNodeId.createDirectiveContext({ 247 | fromNodeId, 248 | }); 249 | const result = await graphql({ 250 | contextValue, 251 | rootValue, 252 | schema, 253 | source, 254 | variableValues, 255 | }); 256 | expect(result).toEqual({ data: rootValue }); 257 | }); 258 | 259 | it('should decode arguments in type field argument', async (): Promise => { 260 | const schema = new ForeignNodeId().applyToSchema( 261 | makeExecutableSchema({ 262 | resolvers: { 263 | Query: {}, 264 | TestType: { 265 | typeIds: (_, { ids }) => { 266 | return ids; 267 | }, 268 | }, 269 | }, 270 | typeDefs: [ 271 | ...directiveTypeDefs, 272 | gql` 273 | type TestType { 274 | typeIds( 275 | ids: [ID!]! @foreignNodeId(typename: "TypeID") 276 | otherArgs: String 277 | ): [String!]! 278 | } 279 | type Query { 280 | testType: TestType! 281 | unusedQuery: TestType! 282 | } 283 | `, 284 | ], 285 | }), 286 | ); 287 | const source = print(gql` 288 | query MyQuery($typeIds: [ID!]!) { 289 | testType { 290 | typeIds(ids: $typeIds) 291 | } 292 | } 293 | `); 294 | const decodedIds = ['123', '345', '678', '910']; 295 | const encodedIds = decodedIds.map(id => toNodeId('TypeID', id)); 296 | const variableValues = { 297 | typeIds: encodedIds, 298 | }; 299 | const rootValue = { 300 | testType: {}, 301 | }; 302 | 303 | const contextValue = ForeignNodeId.createDirectiveContext({ 304 | fromNodeId, 305 | }); 306 | const spy = jest.spyOn(contextValue, 'fromNodeId'); 307 | const result = await graphql({ 308 | contextValue, 309 | rootValue, 310 | schema, 311 | source, 312 | variableValues, 313 | }); 314 | expect(spy).toHaveBeenCalledTimes(encodedIds.length); 315 | expect(result).toEqual({ 316 | data: { 317 | testType: { 318 | typeIds: decodedIds, 319 | }, 320 | }, 321 | }); 322 | }); 323 | 324 | it('should work when used on mutation inputs', async (): Promise => { 325 | const mockResolver = jest.fn().mockReturnValue('return value'); 326 | const typeName = 'MyType'; 327 | const decodedId = 'abc'; 328 | const encodedId = toNodeId('MyType', decodedId); 329 | const schema = new ForeignNodeId().applyToSchema( 330 | makeExecutableSchema({ 331 | resolvers: { 332 | Mutation: { 333 | testDirective: mockResolver, 334 | }, 335 | Query: { 336 | dummy: () => '', 337 | }, 338 | }, 339 | typeDefs: [ 340 | ...directiveTypeDefs, 341 | gql` 342 | input Input1 { 343 | typeId: ID! @foreignNodeId(typename: "${typeName}") 344 | } 345 | type Query { 346 | dummy: String 347 | } 348 | type Mutation { 349 | testDirective(input: Input1): String 350 | } 351 | `, 352 | ], 353 | }), 354 | ); 355 | const source = print(gql` 356 | mutation Testing($input: Input1!) { 357 | testDirective(input: $input) 358 | } 359 | `); 360 | const contextValue = ForeignNodeId.createDirectiveContext({ 361 | fromNodeId, 362 | }); 363 | 364 | await graphql({ 365 | contextValue, 366 | schema, 367 | source, 368 | variableValues: { 369 | input: { 370 | typeId: encodedId, 371 | }, 372 | }, 373 | }); 374 | expect(mockResolver).toHaveBeenCalledTimes(1); 375 | expect(mockResolver).toHaveBeenCalledWith( 376 | undefined, 377 | { 378 | input: { 379 | typeId: decodedId, 380 | }, 381 | }, 382 | contextValue, 383 | expect.any(Object), 384 | ); 385 | }); 386 | 387 | it('should not duplicate validation when same type is used on Query and Mutation', async () => { 388 | const mockResolver = jest.fn().mockReturnValue('return value'); 389 | const typeName = 'MyType'; 390 | const decodedId = 'abc'; 391 | const encodedId = toNodeId('MyType', decodedId); 392 | const schema = new ForeignNodeId().applyToSchema( 393 | makeExecutableSchema({ 394 | resolvers: { 395 | Mutation: { 396 | testDirective: mockResolver, 397 | }, 398 | Query: { 399 | dummy: () => '', 400 | }, 401 | }, 402 | typeDefs: [ 403 | ...directiveTypeDefs, 404 | gql` 405 | input Input1 { 406 | typeId: ID! @foreignNodeId(typename: "${typeName}") 407 | } 408 | type Query { 409 | dummy(input: Input1): String 410 | } 411 | type Mutation { 412 | testDirective(input: Input1): String 413 | otherMutation(input: Input1): String 414 | } 415 | `, 416 | ], 417 | }), 418 | ); 419 | const source = print(gql` 420 | mutation Testing($input: Input1!) { 421 | testDirective(input: $input) 422 | } 423 | `); 424 | const contextValue = ForeignNodeId.createDirectiveContext({ 425 | fromNodeId, 426 | }); 427 | const fromNodeIdSpy = jest.spyOn(contextValue, 'fromNodeId'); 428 | 429 | await graphql({ 430 | contextValue, 431 | schema, 432 | source, 433 | variableValues: { 434 | input: { 435 | typeId: encodedId, 436 | }, 437 | }, 438 | }); 439 | 440 | expect(fromNodeIdSpy).toHaveBeenCalledTimes(1); 441 | expect(fromNodeIdSpy).toHaveBeenCalledWith(encodedId); 442 | }); 443 | 444 | it('should be applied ', async () => { 445 | const typeName = 'MyType'; 446 | const decodedIds = ['abc', '123e']; 447 | const encodedIds = decodedIds.map(toNodeId.bind(null, typeName)); 448 | const schema = new ForeignNodeId().applyToSchema( 449 | makeExecutableSchema({ 450 | resolvers: { 451 | Query: { 452 | users: () => [{}], 453 | }, 454 | User: { 455 | dummyField: () => 'hi', 456 | fieldWithInput: (_, { input: { typeIds } }) => typeIds, 457 | }, 458 | }, 459 | typeDefs: [ 460 | ...directiveTypeDefs, 461 | gql` 462 | input DummyInput { 463 | field: String 464 | } 465 | input Input1 { 466 | typeIds: [ID!]! @foreignNodeId(typename: "${typeName}") 467 | } 468 | type User { 469 | dummyField(input: DummyInput): String 470 | fieldWithInput(input: Input1!): [String!]! 471 | } 472 | type Query { 473 | users: [User!]! 474 | } 475 | `, 476 | ], 477 | }), 478 | ); 479 | const source = print(gql` 480 | query Test($input: Input1!) { 481 | users { 482 | dummyField 483 | fieldWithInput(input: $input) 484 | } 485 | } 486 | `); 487 | const contextValue = ForeignNodeId.createDirectiveContext({ 488 | fromNodeId, 489 | }); 490 | const fromNodeIdSpy = jest.spyOn(contextValue, 'fromNodeId'); 491 | 492 | const result = await graphql({ 493 | contextValue, 494 | schema, 495 | source, 496 | variableValues: { 497 | input: { 498 | typeIds: encodedIds, 499 | }, 500 | }, 501 | }); 502 | 503 | expect(fromNodeIdSpy).toHaveBeenCalledTimes(2); 504 | expect(result).toEqual({ 505 | data: { 506 | users: [ 507 | { 508 | dummyField: 'hi', 509 | fieldWithInput: decodedIds, 510 | }, 511 | ], 512 | }, 513 | error: undefined, 514 | }); 515 | }); 516 | }); 517 | -------------------------------------------------------------------------------- /lib/foreignNodeId.ts: -------------------------------------------------------------------------------- 1 | import { DirectiveLocation, GraphQLNonNull, GraphQLString } from 'graphql'; 2 | 3 | import type { 4 | ValidateFunction, 5 | ValidationDirectiveArgs, 6 | } from './ValidateDirectiveVisitor.js'; 7 | import { ValidateDirectiveVisitorNonTyped } from './ValidateDirectiveVisitor.js'; 8 | import validateArrayOrValue from './validateArrayOrValue.js'; 9 | import ValidationError from './errors/ValidationError.js'; 10 | 11 | export type ToNodeId = ( 12 | id: string, 13 | ) => { typename: string; id: IdType } | null; 14 | 15 | export type ForeignNodeIdContext = { 16 | fromNodeId: ToNodeId; 17 | }; 18 | 19 | export type Args = { 20 | typename: string; 21 | } & ValidationDirectiveArgs; 22 | 23 | export default class ForeignNodeIdDirective< 24 | IdType, 25 | _ extends ForeignNodeIdContext, 26 | > extends ValidateDirectiveVisitorNonTyped { 27 | // eslint-disable-next-line class-methods-use-this 28 | public getValidationForArgs(): 29 | | ValidateFunction> 30 | | undefined { 31 | const { typename } = this.args; 32 | const wrongUsageErrorMessage = `foreignNodeId directive only works on strings`; 33 | const wrongTypeNameErrorMessage = `Converted ID typename does not match. Expected: ${typename}`; 34 | const couldNotDecodeErrorMessage = `Could not decode ID to ${typename}`; 35 | const itemValidate = ( 36 | value: unknown, 37 | _: unknown, 38 | __: unknown, 39 | { fromNodeId }: ForeignNodeIdContext, 40 | ): IdType | undefined | null => { 41 | if (typeof value !== 'string') { 42 | if (value === null || value === undefined) { 43 | return value; 44 | } 45 | throw new ValidationError(wrongUsageErrorMessage); 46 | } 47 | const decodedId = fromNodeId(value); 48 | if (!decodedId) { 49 | throw new ValidationError(couldNotDecodeErrorMessage); 50 | } 51 | const { id, typename: fromNodeTypeName } = decodedId; 52 | if (fromNodeTypeName !== typename) { 53 | throw new ValidationError(wrongTypeNameErrorMessage); 54 | } 55 | return id; 56 | }; 57 | return validateArrayOrValue(itemValidate); 58 | } 59 | 60 | public static readonly config: (typeof ValidateDirectiveVisitorNonTyped)['config'] = 61 | { 62 | args: { 63 | typename: { 64 | description: 'The typename that this ID should match', 65 | type: new GraphQLNonNull(GraphQLString), 66 | }, 67 | }, 68 | description: 'Converts a global unique ID to a type ID', 69 | locations: [ 70 | DirectiveLocation.ARGUMENT_DEFINITION, 71 | DirectiveLocation.INPUT_FIELD_DEFINITION, 72 | ], 73 | }; 74 | 75 | public static readonly defaultName: string = 'foreignNodeId'; 76 | 77 | public static createDirectiveContext(ctx: { 78 | fromNodeId: ToNodeId; 79 | }): ForeignNodeIdContext { 80 | return ctx; 81 | } 82 | } 83 | 84 | /* 85 | graphql-tools changed the typing for SchemaDirectiveVisitor and if you define a type for TArgs and TContext, 86 | you'll get this error: "Type 'typeof Your_Directive_Class' is not assignable to type 'typeof SchemaDirectiveVisitor'.". 87 | If you are using the old graphql-tools, you can use: 88 | extends EasyDirectiveVisitor, TContext> 89 | */ 90 | export const ForeignNodeIdDirectiveNonTyped: typeof ForeignNodeIdDirective< 91 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 92 | any, 93 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 94 | any 95 | > = ForeignNodeIdDirective; 96 | -------------------------------------------------------------------------------- /lib/hasPermissions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GraphQLFieldResolver, 3 | GraphQLResolveInfo, 4 | GraphQLSchema, 5 | GraphQLDirective, 6 | GraphQLInputObjectType, 7 | GraphQLArgument, 8 | GraphQLObjectType, 9 | } from 'graphql'; 10 | import { 11 | DirectiveLocation, 12 | GraphQLEnumType, 13 | GraphQLList, 14 | GraphQLNonNull, 15 | GraphQLString, 16 | isInputObjectType, 17 | } from 'graphql'; 18 | 19 | import isEqual from 'lodash.isequal'; 20 | 21 | import EasyDirectiveVisitor from './EasyDirectiveVisitor.js'; 22 | import ForbiddenError from './errors/ForbiddenError.js'; 23 | 24 | import type { ValidateFunction } from './ValidateDirectiveVisitor.js'; 25 | import ValidateDirectiveVisitor, { 26 | ValidateDirectivePolicy, 27 | } from './ValidateDirectiveVisitor.js'; 28 | 29 | const isDebug = !!( 30 | process && 31 | process.env && 32 | process.env.NODE_ENV !== 'production' 33 | ); 34 | 35 | type ResolverArgs = Parameters< 36 | GraphQLFieldResolver 37 | >; 38 | 39 | export interface MissingPermissionsResolverInfo extends GraphQLResolveInfo { 40 | missingPermissions?: string[]; 41 | } 42 | 43 | export type CheckMissingPermissions = ( 44 | requiredPermissions: string[], 45 | cacheKey: string, 46 | ...args: ResolverArgs 47 | ) => null | string[]; 48 | 49 | export type HasPermissionsContext = { 50 | checkMissingPermissions: CheckMissingPermissions; 51 | }; 52 | 53 | export type FilterMissingPermissions = ( 54 | grantedPermissions: Set | undefined, 55 | requiredPermissions: string[], 56 | ) => null | string[]; 57 | 58 | // gather all missing permissions, only useful during debug since it's slower 59 | // but lists everything at once, which helps debug 60 | export const debugFilterMissingPermissions = ( 61 | grantedPermissions: Set | undefined, 62 | requiredPermissions: string[], 63 | ): null | string[] => { 64 | if (!grantedPermissions) { 65 | return requiredPermissions; 66 | } 67 | const missing = requiredPermissions.filter(p => !grantedPermissions.has(p)); 68 | if (missing.length === 0) return null; 69 | return missing; 70 | }; 71 | 72 | // faster version that fails on the first missing permission, reports only that 73 | export const prodFilterMissingPermissions = ( 74 | grantedPermissions: Set | undefined, 75 | requiredPermissions: string[], 76 | ): null | string[] => { 77 | if (!grantedPermissions) { 78 | return requiredPermissions; 79 | } 80 | const missing = requiredPermissions.find(p => !grantedPermissions.has(p)); 81 | if (!missing) return null; 82 | return [missing]; 83 | }; 84 | 85 | /* istanbul ignore next */ 86 | const defaultFilterMissingPermissions = isDebug 87 | ? debugFilterMissingPermissions 88 | : prodFilterMissingPermissions; 89 | 90 | export type GetErrorMessage = (missingPermissions: string[]) => string; 91 | 92 | const errorMessage = 'Missing Permissions'; 93 | 94 | export const debugGetErrorMessage = (missingPermissions: string[]): string => 95 | `${errorMessage}: ${missingPermissions.join(', ')}`; 96 | 97 | export const prodGetErrorMessage = (): string => errorMessage; 98 | 99 | export type HasPermissionsDirectiveArgs = { 100 | permissions: string[]; 101 | policy: ValidateDirectivePolicy; 102 | }; 103 | 104 | const defaultPolicyOutsideClass: ValidateDirectivePolicy = 105 | ValidateDirectivePolicy.THROW; 106 | 107 | export const getDefaultValue = ( 108 | container: GraphQLArgument | GraphQLInputObjectType | GraphQLObjectType, 109 | path?: Array, 110 | ): unknown => { 111 | if ('defaultValue' in container) { 112 | return container.defaultValue; 113 | } 114 | if (isInputObjectType(container) && path && path.length > 0) { 115 | const fieldName = path[path.length - 1]; 116 | const field = container.getFields()[fieldName]; 117 | return field.defaultValue; 118 | } 119 | return undefined; 120 | }; 121 | 122 | export class HasPermissionsDirectiveVisitor< 123 | TArgs extends HasPermissionsDirectiveArgs, 124 | TContext extends HasPermissionsContext, 125 | > extends ValidateDirectiveVisitor { 126 | public static readonly defaultName: string = 'hasPermissions'; 127 | 128 | public static readonly defaultPolicy: ValidateDirectivePolicy = 129 | defaultPolicyOutsideClass; 130 | 131 | public readonly applyValidationToOutputTypesAfterOriginalResolver: Boolean = 132 | false; 133 | 134 | public static readonly config: (typeof ValidateDirectiveVisitor)['config'] = { 135 | args: { 136 | permissions: { 137 | description: 138 | 'All permissions required by this field (or object). All must be fulfilled', 139 | type: new GraphQLNonNull( 140 | new GraphQLList(new GraphQLNonNull(GraphQLString)), 141 | ), 142 | }, 143 | policy: { 144 | defaultValue: defaultPolicyOutsideClass, 145 | description: 'How to handle missing permissions', 146 | type: new GraphQLEnumType({ 147 | name: 'HasPermissionsDirectivePolicy', 148 | values: { 149 | RESOLVER: { 150 | description: 151 | 'Field resolver is responsible to evaluate it using `missingPermissions` injected argument', 152 | value: ValidateDirectivePolicy.RESOLVER, 153 | }, 154 | THROW: { 155 | description: 156 | 'Field resolver is not called if permissions are missing, it throws `ForbiddenError`', 157 | value: ValidateDirectivePolicy.THROW, 158 | }, 159 | }, 160 | }), 161 | }, 162 | }, 163 | description: 'ensures it has permissions before calling the resolver', 164 | locations: [ 165 | DirectiveLocation.ARGUMENT_DEFINITION, 166 | DirectiveLocation.FIELD_DEFINITION, 167 | DirectiveLocation.INPUT_FIELD_DEFINITION, 168 | DirectiveLocation.INPUT_OBJECT, 169 | DirectiveLocation.OBJECT, 170 | ], 171 | }; 172 | 173 | public static getDirectiveDeclaration( 174 | givenDirectiveName?: string, 175 | schema?: GraphQLSchema, 176 | ): GraphQLDirective { 177 | return EasyDirectiveVisitor.getDirectiveDeclaration.apply(this, [ 178 | givenDirectiveName, 179 | schema, 180 | ]); 181 | } 182 | 183 | public static createDirectiveContext({ 184 | grantedPermissions: rawGrantedPermissions, 185 | filterMissingPermissions = defaultFilterMissingPermissions, 186 | }: { 187 | grantedPermissions: string[] | undefined; 188 | filterMissingPermissions?: FilterMissingPermissions; 189 | }): HasPermissionsContext { 190 | const grantedPermissions = rawGrantedPermissions 191 | ? new Set(rawGrantedPermissions) 192 | : undefined; 193 | 194 | const missingPermissionsCache: { [key: string]: string[] | null } = {}; 195 | 196 | const checkMissingPermissions = ( 197 | requiredPermissions: string[], 198 | cacheKey: string, 199 | ): string[] | null => { 200 | let missingPermissions = missingPermissionsCache[cacheKey]; 201 | if (missingPermissions === undefined) { 202 | missingPermissions = filterMissingPermissions( 203 | grantedPermissions, 204 | requiredPermissions, 205 | ); 206 | missingPermissionsCache[cacheKey] = missingPermissions; 207 | } 208 | return missingPermissions; 209 | }; 210 | return { checkMissingPermissions }; 211 | } 212 | 213 | /* istanbul ignore next */ 214 | public getErrorMessage: GetErrorMessage = isDebug 215 | ? debugGetErrorMessage 216 | : prodGetErrorMessage; 217 | 218 | public getValidationForArgs( 219 | location: DirectiveLocation, 220 | // args: TArgs, 221 | ): ValidateFunction | undefined { 222 | const { permissions, policy } = this.args; 223 | const cacheKey = JSON.stringify(Array.from(permissions).sort()); 224 | const isUsedOnInputOrArgument = 225 | location === DirectiveLocation.INPUT_FIELD_DEFINITION || 226 | location === DirectiveLocation.INPUT_OBJECT || 227 | location === DirectiveLocation.ARGUMENT_DEFINITION; 228 | 229 | const hasPermissionsValidateFunction: ValidateFunction = ( 230 | value: unknown, 231 | _: unknown, 232 | container: GraphQLArgument | GraphQLInputObjectType | GraphQLObjectType, 233 | context: TContext, 234 | resolverInfo: Record, 235 | resolverSource: unknown, 236 | resolverArgs: Record, 237 | path?: Array, 238 | ): unknown => { 239 | if (isUsedOnInputOrArgument) { 240 | if (value === undefined) { 241 | return value; 242 | } 243 | 244 | const defaultValue = getDefaultValue(container, path); 245 | if (isEqual(value, defaultValue)) { 246 | return value; 247 | } 248 | } 249 | 250 | if (!permissions || !permissions.length) { 251 | return value; 252 | } 253 | 254 | const { checkMissingPermissions } = context; 255 | let missingPermissions = checkMissingPermissions.apply(this, [ 256 | permissions, 257 | cacheKey, 258 | resolverSource, 259 | resolverArgs, 260 | context, 261 | resolverInfo as unknown as GraphQLResolveInfo, 262 | ]); 263 | if (!(missingPermissions && missingPermissions.length > 0)) { 264 | missingPermissions = null; 265 | } 266 | 267 | if (policy === ValidateDirectivePolicy.THROW && missingPermissions) { 268 | throw new ForbiddenError(this.getErrorMessage(missingPermissions)); 269 | } 270 | 271 | /* 272 | If any missing permissions existed from other hasPermissions that 273 | were executed before it, then pass or extend that array with the new 274 | permissions 275 | */ 276 | const existingMissingPermissions = resolverInfo.missingPermissions; 277 | if (existingMissingPermissions) { 278 | if (!Array.isArray(existingMissingPermissions)) { 279 | throw new Error('The missingPermissions field is not an array!'); 280 | } 281 | if (!missingPermissions) { 282 | missingPermissions = existingMissingPermissions; 283 | } else { 284 | missingPermissions = missingPermissions.concat( 285 | existingMissingPermissions, 286 | ); 287 | } 288 | } 289 | // eslint-disable-next-line no-param-reassign 290 | resolverInfo.missingPermissions = missingPermissions; 291 | 292 | return value; 293 | }; 294 | 295 | return hasPermissionsValidateFunction; 296 | } 297 | } 298 | 299 | export default HasPermissionsDirectiveVisitor; 300 | 301 | /* 302 | graphql-tools changed the typing for SchemaDirectiveVisitor and if you define a type for TArgs and TContext, 303 | you'll get this error: "Type 'typeof Your_Directive_Class' is not assignable to type 'typeof SchemaDirectiveVisitor'.". 304 | If you are using the old graphql-tools, you can use: 305 | extends EasyDirectiveVisitor, TContext> 306 | */ 307 | export const HasPermissionsDirectiveVisitorNonTyped: typeof HasPermissionsDirectiveVisitor< 308 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 309 | any, 310 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 311 | any 312 | > = HasPermissionsDirectiveVisitor; 313 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | // helper classes 2 | export { default as EasyDirectiveVisitor } from './EasyDirectiveVisitor.js'; 3 | export { 4 | default as ValidateDirectiveVisitor, 5 | ValidateDirectiveVisitorNonTyped, 6 | } from './ValidateDirectiveVisitor.js'; 7 | 8 | // helper functions 9 | export { default as createValidateDirectiveVisitor } from './createValidateDirectiveVisitor.js'; 10 | export { default as validateArrayOrValue } from './validateArrayOrValue.js'; 11 | export { default as applyDirectivesToSchema } from './utils/applyDirectivesToSchema.js'; 12 | export { 13 | createMapper, 14 | createSchemaMapperForVisitor, 15 | } from './createSchemaMapperForVisitor.js'; 16 | 17 | // validation directives 18 | export { 19 | default as auth, 20 | AuthDirectiveVisitorNonTyped as v3Auth, 21 | } from './auth.js'; 22 | export { 23 | default as hasPermissions, 24 | HasPermissionsDirectiveVisitorNonTyped as v3HasPermissions, 25 | } from './hasPermissions.js'; 26 | export { default as listLength } from './listLength.js'; 27 | export { default as pattern } from './pattern.js'; 28 | export { default as range } from './range.js'; 29 | export { default as stringLength } from './stringLength.js'; 30 | export { default as selfNodeId } from './selfNodeId.js'; 31 | export { 32 | default as foreignNodeId, 33 | ForeignNodeIdDirectiveNonTyped as v3ForeignNodeId, 34 | } from './foreignNodeId.js'; 35 | export { default as cleanupPattern } from './cleanupPattern.js'; 36 | export { default as trim } from './trim.js'; 37 | export type { MissingPermissionsResolverInfo } from './hasPermissions.js'; 38 | -------------------------------------------------------------------------------- /lib/listLength.test.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLSchema } from 'graphql'; 2 | import { gql } from 'graphql-tag'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | 5 | import ListLength from './listLength.js'; 6 | import capitalize from './capitalize.js'; 7 | 8 | import type { 9 | CreateSchemaConfig, 10 | ExpectedTestResult, 11 | } from './test-utils.test.js'; 12 | import { 13 | testEasyDirective, 14 | validationDirectivePolicyArgs, 15 | } from './test-utils.test.js'; 16 | import ValidationError from './errors/ValidationError.js'; 17 | 18 | type RootValue = { 19 | test?: (string | null)[] | null; 20 | stringTest?: string | null; 21 | }; 22 | 23 | const name = 'listLength'; 24 | 25 | const createSchema = ({ 26 | name: directiveName, 27 | testCase: { directiveArgs }, 28 | }: CreateSchemaConfig): GraphQLSchema => 29 | new ListLength().applyToSchema( 30 | makeExecutableSchema({ 31 | typeDefs: [ 32 | ...ListLength.getTypeDefs(directiveName, undefined, true, true), 33 | gql` 34 | type Query { 35 | test: [String] @${directiveName}${directiveArgs} 36 | stringTest: String @${directiveName}${directiveArgs} 37 | } 38 | `, 39 | ], 40 | }), 41 | ); 42 | 43 | const expectedValidationError = ( 44 | message: string, 45 | key: keyof RootValue = 'test', 46 | ): ExpectedTestResult => ({ 47 | data: { [key]: null }, 48 | errors: [new ValidationError(message)], 49 | }); 50 | 51 | testEasyDirective({ 52 | createSchema, 53 | DirectiveVisitor: ListLength, 54 | expectedArgsTypeDefs: `\ 55 | ( 56 | """ 57 | The maximum list length (inclusive) to allow. If null, no upper limit is applied 58 | """ 59 | max: Float = null 60 | """ 61 | The minimum list length (inclusive) to allow. If null, no lower limit is applied 62 | """ 63 | min: Float = null 64 | ${validationDirectivePolicyArgs(capitalize(name))} 65 | )`, 66 | name, 67 | testCases: [ 68 | { 69 | directiveArgs: '(min: 1, max: 3)', 70 | operation: '{ test }', 71 | tests: [ 72 | { rootValue: { test: ['a', 'b'] } }, 73 | { rootValue: { test: ['a'] } }, 74 | { rootValue: { test: ['a', 'b', 'c'] } }, 75 | { 76 | expected: expectedValidationError('List Length is Less than 1'), 77 | rootValue: { test: [] }, 78 | }, 79 | { 80 | expected: expectedValidationError('List Length is More than 3'), 81 | rootValue: { test: ['a', 'b', 'c', 'd'] }, 82 | }, 83 | { rootValue: { test: null } }, 84 | ], 85 | }, 86 | { 87 | directiveArgs: '(min: 1)', 88 | operation: '{ test }', 89 | tests: [ 90 | { rootValue: { test: ['a', 'b'] } }, 91 | { rootValue: { test: ['a'] } }, 92 | { rootValue: { test: ['a', 'b', 'c'] } }, 93 | { 94 | expected: expectedValidationError('List Length is Less than 1'), 95 | rootValue: { test: [] }, 96 | }, 97 | { rootValue: { test: ['a', 'b', 'c', 'd'] } }, 98 | { rootValue: { test: null } }, 99 | ], 100 | }, 101 | { 102 | directiveArgs: '(max: 3)', 103 | operation: '{ test }', 104 | tests: [ 105 | { rootValue: { test: ['a', 'b'] } }, 106 | { rootValue: { test: ['a'] } }, 107 | { rootValue: { test: ['a', 'b', 'c'] } }, 108 | { rootValue: { test: [] } }, 109 | { 110 | expected: expectedValidationError('List Length is More than 3'), 111 | rootValue: { test: ['a', 'b', 'c', 'd'] }, 112 | }, 113 | { rootValue: { test: null } }, 114 | ], 115 | }, 116 | { 117 | directiveArgs: '', 118 | operation: '{ test }', 119 | tests: [ 120 | { rootValue: { test: ['a', 'b'] } }, 121 | { rootValue: { test: ['a'] } }, 122 | { rootValue: { test: ['a', 'b', 'c'] } }, 123 | { rootValue: { test: [] } }, 124 | { rootValue: { test: ['a', 'b', 'c', 'd'] } }, 125 | { rootValue: { test: null } }, 126 | ], 127 | }, 128 | { 129 | directiveArgs: '(min: 100, max: 0)', 130 | error: new RangeError('@listLength(max) must be at least equal to min'), 131 | }, 132 | { 133 | directiveArgs: '(min: -1, max: 1)', 134 | error: new RangeError('@listLength(min) must be at least 0'), 135 | }, 136 | { 137 | directiveArgs: '(min: -1)', 138 | error: new RangeError('@listLength(min) must be at least 0'), 139 | }, 140 | { 141 | directiveArgs: '(max: -1)', 142 | error: new RangeError('@listLength(max) must be at least 0'), 143 | }, 144 | { 145 | // strings are ignored 146 | directiveArgs: '(min: 1, max: 3)', 147 | operation: '{ stringTest }', 148 | tests: [ 149 | { rootValue: { stringTest: 'ab' } }, 150 | { rootValue: { stringTest: 'a' } }, 151 | { rootValue: { stringTest: 'abc' } }, 152 | { rootValue: { stringTest: '' } }, 153 | { rootValue: { stringTest: 'abcd' } }, 154 | ], 155 | }, 156 | ], 157 | }); 158 | -------------------------------------------------------------------------------- /lib/listLength.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFloat } from 'graphql'; 2 | 3 | import type { 4 | ValidateFunction, 5 | ValidationDirectiveArgs, 6 | } from './ValidateDirectiveVisitor.js'; 7 | import createValidateDirectiveVisitor from './createValidateDirectiveVisitor.js'; 8 | import ValidationError from './errors/ValidationError.js'; 9 | 10 | const createValidateMinMax = (min: number, max: number): ValidateFunction => { 11 | if (min < 0) throw new RangeError('@listLength(min) must be at least 0'); 12 | if (max < min) 13 | throw new RangeError('@listLength(max) must be at least equal to min'); 14 | const errorMessageMin = `List Length is Less than ${min}`; 15 | const errorMessageMax = `List Length is More than ${max}`; 16 | return (value: unknown): unknown => { 17 | if (Array.isArray(value)) { 18 | const { length } = value; 19 | if (length < min) throw new ValidationError(errorMessageMin); 20 | if (length > max) throw new ValidationError(errorMessageMax); 21 | } 22 | return value; 23 | }; 24 | }; 25 | 26 | const createValidateMin = (min: number): ValidateFunction => { 27 | if (min < 0) throw new RangeError('@listLength(min) must be at least 0'); 28 | const errorMessage = `List Length is Less than ${min}`; 29 | return (value: unknown): unknown => { 30 | if (Array.isArray(value)) { 31 | if (value.length < min) throw new ValidationError(errorMessage); 32 | } 33 | return value; 34 | }; 35 | }; 36 | 37 | const createValidateMax = (max: number): ValidateFunction => { 38 | if (max < 0) throw new RangeError('@listLength(max) must be at least 0'); 39 | const errorMessage = `List Length is More than ${max}`; 40 | return (value: unknown): unknown => { 41 | if (Array.isArray(value)) { 42 | if (value.length > max) throw new ValidationError(errorMessage); 43 | } 44 | return value; 45 | }; 46 | }; 47 | 48 | type ListLengthDirectiveArgs = { 49 | min: number | null; 50 | max: number | null; 51 | } & ValidationDirectiveArgs; 52 | 53 | // istanbul ignore next (args set by default to null) 54 | const createValidate = ({ 55 | min = null, 56 | max = null, 57 | }: ListLengthDirectiveArgs): ValidateFunction | undefined => { 58 | if (min !== null && max !== null) return createValidateMinMax(min, max); 59 | if (min !== null) return createValidateMin(min); 60 | if (max !== null) return createValidateMax(max); 61 | return undefined; 62 | }; 63 | 64 | const defaultName = 'listLength'; 65 | 66 | const Visitor = createValidateDirectiveVisitor({ 67 | createValidate, 68 | defaultName, 69 | directiveConfig: { 70 | args: { 71 | max: { 72 | defaultValue: null, 73 | description: 74 | 'The maximum list length (inclusive) to allow. If null, no upper limit is applied', 75 | type: GraphQLFloat, 76 | }, 77 | min: { 78 | defaultValue: null, 79 | description: 80 | 'The minimum list length (inclusive) to allow. If null, no lower limit is applied', 81 | type: GraphQLFloat, 82 | }, 83 | }, 84 | description: 'Ensures list length is within boundaries.', 85 | }, 86 | isValidateArrayOrValue: false, 87 | }); 88 | 89 | export default Visitor; 90 | -------------------------------------------------------------------------------- /lib/multipleDirectives.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql, GraphQLError, type GraphQLSchema } from 'graphql'; 2 | import { gql } from 'graphql-tag'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | import { buildSubgraphSchema } from '@apollo/subgraph'; 5 | 6 | import range from './range.js'; 7 | import trim from './trim.js'; 8 | import stringLength from './stringLength.js'; 9 | import applyDirectivesToSchema from './utils/applyDirectivesToSchema.js'; 10 | import listLength from './listLength.js'; 11 | import ValidateDirectiveVisitor from './ValidateDirectiveVisitor.js'; 12 | import print from './utils/printer.js'; 13 | 14 | interface MyType { 15 | int: number; 16 | list: number[]; 17 | } 18 | 19 | const build = (isFederated: boolean): GraphQLSchema => { 20 | const buildSchema = isFederated ? buildSubgraphSchema : makeExecutableSchema; 21 | return buildSchema({ 22 | resolvers: { 23 | Query: { 24 | myType: (): MyType => ({ int: 2, list: [2] }), 25 | }, 26 | }, 27 | typeDefs: [ 28 | ...ValidateDirectiveVisitor.getMissingCommonTypeDefs(), 29 | ...listLength.getTypeDefs(), 30 | ...range.getTypeDefs(), 31 | gql` 32 | type MyType { 33 | int: Int! @range(min: 20, policy: THROW) 34 | list: [Int!]! @listLength(min: 1, policy: THROW) 35 | } 36 | type Query { 37 | myType: MyType! 38 | } 39 | `, 40 | ], 41 | }); 42 | }; 43 | 44 | describe('Multiple Directives', () => { 45 | it('Should work with buildSubgraphSchema', () => { 46 | expect(() => build(true)).not.toThrow(); 47 | }); 48 | it('Should work with makeExecutableSchema', () => { 49 | expect(() => build(false)).not.toThrow(); 50 | }); 51 | 52 | describe('execution order over the same field', () => { 53 | it('should call directives in the order they are mapped', async () => { 54 | const baseSchema = makeExecutableSchema({ 55 | resolvers: { 56 | Query: { 57 | item: (_, { arg }): string => arg, 58 | }, 59 | }, 60 | typeDefs: [ 61 | ...trim.getTypeDefs(), 62 | ...stringLength.getTypeDefs(), 63 | gql` 64 | type Query { 65 | item(arg: String!): String! @stringLength(max: 5) @trim 66 | } 67 | `, 68 | ], 69 | }); 70 | const trimFirstSchema = applyDirectivesToSchema( 71 | // trim directive will be called first, then stringLength, does not matter the order defined in the schema 72 | [trim, stringLength], 73 | baseSchema, 74 | ); 75 | // arg value has a string length greater than allowed by stringLength directive 76 | // but, if trimmed, it will be less than 5 chars 77 | const source = print(gql` 78 | { 79 | item(arg: " 1234 ") 80 | } 81 | `); 82 | // since trim is called first, the string will be first trimmed to "1234" and then stringLength will not throw 83 | let result = await graphql({ 84 | contextValue: {}, 85 | schema: trimFirstSchema, 86 | source, 87 | }); 88 | 89 | expect(result).toEqual({ 90 | data: { 91 | item: '1234', 92 | }, 93 | }); 94 | 95 | const stringLengthFirstSchema = applyDirectivesToSchema( 96 | [stringLength, trim], 97 | baseSchema, 98 | ); 99 | 100 | // stringLength is called before trim(), so the stringLength validation should throw 101 | result = await graphql({ 102 | contextValue: {}, 103 | schema: stringLengthFirstSchema, 104 | source, 105 | }); 106 | expect(result).toEqual({ 107 | data: null, 108 | errors: [new GraphQLError('String Length is More than 5')], 109 | }); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /lib/pattern.test.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError, type GraphQLSchema } from 'graphql'; 2 | import { gql } from 'graphql-tag'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | 5 | import type { IResolvers } from '@graphql-tools/utils'; 6 | 7 | import Pattern from './pattern.js'; 8 | import capitalize from './capitalize.js'; 9 | 10 | import type { 11 | CreateSchemaConfig, 12 | ExpectedTestResult, 13 | } from './test-utils.test.js'; 14 | import { 15 | testEasyDirective, 16 | validationDirectivePolicyArgs, 17 | } from './test-utils.test.js'; 18 | import ValidationError from './errors/ValidationError.js'; 19 | 20 | type RootValue = { 21 | arrayTest?: (string | null)[] | null; 22 | test?: string | null; 23 | number?: number; 24 | bool?: boolean; 25 | obj?: { toString(): string }; 26 | directiveOnInput?: string | null; 27 | directiveOnMutationArg?: string | null; 28 | directiveOnMutationField?: string | null; 29 | }; 30 | 31 | type ResultValue = Omit & { 32 | obj?: { toString: string }; 33 | }; 34 | 35 | const createSchema = ({ 36 | name, 37 | testCase: { directiveArgs }, 38 | }: CreateSchemaConfig): GraphQLSchema => 39 | new Pattern().applyToSchema( 40 | makeExecutableSchema({ 41 | resolvers: { 42 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 43 | SomeObj: { 44 | toString: (obj: object): string => obj.toString(), 45 | } as IResolvers, 46 | }, 47 | typeDefs: [ 48 | ...Pattern.getTypeDefs(name, undefined, true, true), 49 | gql` 50 | type SomeObj { 51 | toString: String 52 | } 53 | type Query { 54 | test: String @${name}${directiveArgs} 55 | arrayTest: [String] @${name}${directiveArgs} 56 | number: Int @${name}${directiveArgs} 57 | bool: Boolean @${name}${directiveArgs} 58 | obj: SomeObj @${name}${directiveArgs} 59 | } 60 | input InputTypeWithDirective { 61 | value: String! @${name}${directiveArgs} 62 | } 63 | type Mutation { 64 | directiveOnInput(input: InputTypeWithDirective!): String 65 | directiveOnMutationArg(input: String! @${name}${directiveArgs}): String 66 | directiveOnMutationField(input: String): String @${name}${directiveArgs} 67 | } 68 | `, 69 | ], 70 | }), 71 | ); 72 | 73 | const expectedValidationError = ( 74 | message: string, 75 | key: keyof RootValue = 'test', 76 | ): ExpectedTestResult => ({ 77 | data: { [key]: null }, 78 | errors: [new ValidationError(message)], 79 | }); 80 | 81 | const name = 'pattern'; 82 | 83 | testEasyDirective({ 84 | createSchema, 85 | DirectiveVisitor: Pattern, 86 | expectedArgsTypeDefs: `\ 87 | ( 88 | flags: String 89 | regexp: String! 90 | ${validationDirectivePolicyArgs(capitalize(name))} 91 | )`, 92 | name, 93 | testCases: [ 94 | { 95 | directiveArgs: '(regexp: "[a-z]+")', 96 | operation: 97 | 'mutation TestMutation { directiveOnInput(input: { value: "987" }) }', 98 | tests: [ 99 | { 100 | expected: { 101 | data: { 102 | directiveOnInput: null, 103 | }, 104 | errors: [new GraphQLError('Does not match pattern: /[a-z]+/')], 105 | }, 106 | rootValue: { 107 | directiveOnInput: 'Value returned', 108 | }, 109 | }, 110 | ], 111 | }, 112 | { 113 | directiveArgs: '(regexp: "[a-z]+")', 114 | operation: 115 | 'mutation TestMutation { directiveOnMutationField(input: "abc 123") }', 116 | tests: [ 117 | { 118 | expected: { 119 | data: { 120 | directiveOnMutationField: null, 121 | }, 122 | errors: [new GraphQLError('Does not match pattern: /[a-z]+/')], 123 | }, 124 | rootValue: { 125 | directiveOnMutationField: '1234', 126 | }, 127 | }, 128 | ], 129 | }, 130 | { 131 | directiveArgs: '(regexp: "[a-z]+")', 132 | operation: 133 | 'mutation TestMutation { directiveOnMutationArg(input: "123456") }', 134 | tests: [ 135 | { 136 | expected: { 137 | data: { 138 | directiveOnMutationArg: null, 139 | }, 140 | errors: [new GraphQLError('Does not match pattern: /[a-z]+/')], 141 | }, 142 | rootValue: { 143 | directiveOnMutationArg: '1234', 144 | }, 145 | }, 146 | ], 147 | }, 148 | { 149 | directiveArgs: '(regexp: "[a-z]+", flags: "i")', 150 | operation: '{ test }', 151 | tests: [ 152 | { rootValue: { test: 'abc' } }, 153 | { rootValue: { test: 'a' } }, 154 | { rootValue: { test: 'A' } }, 155 | { 156 | expected: expectedValidationError( 157 | 'Does not match pattern: /[a-z]+/i', 158 | ), 159 | rootValue: { test: '' }, 160 | }, 161 | { 162 | expected: expectedValidationError( 163 | 'Does not match pattern: /[a-z]+/i', 164 | ), 165 | rootValue: { test: '0' }, 166 | }, 167 | { rootValue: { test: null } }, 168 | ], 169 | }, 170 | { 171 | directiveArgs: '(regexp: "[a-z]+")', 172 | operation: '{ test }', 173 | tests: [ 174 | { rootValue: { test: 'abc' } }, 175 | { rootValue: { test: 'a' } }, 176 | { 177 | expected: expectedValidationError('Does not match pattern: /[a-z]+/'), 178 | rootValue: { test: 'A' }, 179 | }, 180 | { 181 | expected: expectedValidationError('Does not match pattern: /[a-z]+/'), 182 | rootValue: { test: '' }, 183 | }, 184 | { 185 | expected: expectedValidationError('Does not match pattern: /[a-z]+/'), 186 | rootValue: { test: '0' }, 187 | }, 188 | { rootValue: { test: null } }, 189 | ], 190 | }, 191 | { 192 | directiveArgs: '(regexp: "")', 193 | operation: '{ test }', 194 | tests: [ 195 | { rootValue: { test: 'abc' } }, 196 | { rootValue: { test: 'a' } }, 197 | { rootValue: { test: 'A' } }, 198 | { rootValue: { test: '' } }, 199 | { rootValue: { test: '0' } }, 200 | { rootValue: { test: null } }, 201 | ], 202 | }, 203 | { 204 | directiveArgs: '(regexp: "[")', 205 | error: new SyntaxError( 206 | 'Invalid regular expression: /[/: Unterminated character class', 207 | ), 208 | }, 209 | { 210 | // arrays should work the same, just repeat for simple pattern 211 | directiveArgs: '(regexp: "[a-z]+", flags: "i")', 212 | operation: '{ arrayTest }', 213 | tests: [ 214 | { rootValue: { arrayTest: ['abc'] } }, 215 | { rootValue: { arrayTest: ['a'] } }, 216 | { rootValue: { arrayTest: ['A'] } }, 217 | { 218 | expected: expectedValidationError( 219 | 'Does not match pattern: /[a-z]+/i', 220 | 'arrayTest', 221 | ), 222 | rootValue: { arrayTest: [''] }, 223 | }, 224 | { 225 | expected: expectedValidationError( 226 | 'Does not match pattern: /[a-z]+/i', 227 | 'arrayTest', 228 | ), 229 | rootValue: { arrayTest: ['0'] }, 230 | }, 231 | { rootValue: { arrayTest: [null] } }, 232 | { rootValue: { arrayTest: null } }, 233 | ], 234 | }, 235 | { 236 | directiveArgs: '(regexp: "[0-9]+")', 237 | operation: '{ number }', 238 | tests: [ 239 | { expected: { data: { number: 12 } }, rootValue: { number: 12 } }, 240 | ], 241 | }, 242 | { 243 | directiveArgs: '(regexp: "true")', 244 | operation: '{ bool }', 245 | tests: [ 246 | { expected: { data: { bool: true } }, rootValue: { bool: true } }, 247 | ], 248 | }, 249 | { 250 | directiveArgs: '(regexp: "obj.toString result")', 251 | operation: '{ obj { toString }}', 252 | tests: [ 253 | { 254 | expected: { 255 | data: { 256 | obj: { toString: 'obj.toString result' }, 257 | }, 258 | }, 259 | rootValue: { 260 | obj: { 261 | toString: (): string => 'obj.toString result', 262 | }, 263 | }, 264 | }, 265 | ], 266 | }, 267 | ], 268 | }); 269 | -------------------------------------------------------------------------------- /lib/pattern.ts: -------------------------------------------------------------------------------- 1 | import type { ValidateFunction } from './ValidateDirectiveVisitor.js'; 2 | import createValidateDirectiveVisitor from './createValidateDirectiveVisitor.js'; 3 | import type { PatternDirectiveArgs } from './patternCommon.js'; 4 | import createPatternHandler, { defaultArgs } from './patternCommon.js'; 5 | import ValidationError from './errors/ValidationError.js'; 6 | 7 | const createValidate = ({ 8 | regexp, 9 | flags = null, 10 | }: PatternDirectiveArgs): ValidateFunction | undefined => { 11 | if (!regexp) return undefined; 12 | 13 | const re = new RegExp(regexp, flags || undefined); 14 | const errorMessage = `Does not match pattern: /${regexp}/${flags || ''}`; 15 | return createPatternHandler((strValue: string, originalValue: unknown) => { 16 | if (!re.test(strValue)) { 17 | throw new ValidationError(errorMessage); 18 | } 19 | return originalValue; 20 | }); 21 | }; 22 | 23 | export default createValidateDirectiveVisitor({ 24 | createValidate, 25 | defaultName: 'pattern', 26 | directiveConfig: { 27 | args: { 28 | ...defaultArgs, 29 | }, 30 | description: 'ensures value matches pattern', 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /lib/patternCommon.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLNonNull, GraphQLString } from 'graphql'; 2 | 3 | import ValidationError from './errors/ValidationError.js'; 4 | 5 | import type { 6 | ValidationDirectiveArgs, 7 | ValidateFunction, 8 | } from './ValidateDirectiveVisitor.js'; 9 | 10 | export type PatternDirectiveArgs = { 11 | regexp: string; 12 | flags?: string | null; 13 | } & ValidationDirectiveArgs; 14 | 15 | export type PatternHandler = ( 16 | strValue: string, 17 | orginalValue: unknown, 18 | ) => unknown; 19 | 20 | export const defaultArgs = { 21 | flags: { type: GraphQLString }, 22 | regexp: { type: new GraphQLNonNull(GraphQLString) }, 23 | }; 24 | 25 | const createPatternHandler = 26 | (handler: PatternHandler): ValidateFunction => 27 | (value: unknown): unknown => { 28 | let str = ''; 29 | // istanbul ignore else (should not reach) 30 | if (typeof value === 'string') { 31 | str = value; 32 | } else if (typeof value === 'number' || typeof value === 'boolean') { 33 | str = value.toString(); 34 | } else if (typeof value === 'object') { 35 | if (value === null) { 36 | return value; 37 | } 38 | str = value.toString(); 39 | } else if (value === undefined) { 40 | return value; 41 | } else { 42 | throw new ValidationError('could not convert value to string'); 43 | } 44 | return handler(str, value); 45 | }; 46 | 47 | export default createPatternHandler; 48 | -------------------------------------------------------------------------------- /lib/range.test.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLSchema } from 'graphql'; 2 | import { gql } from 'graphql-tag'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | 5 | import Range from './range.js'; 6 | import capitalize from './capitalize.js'; 7 | 8 | import type { 9 | CreateSchemaConfig, 10 | ExpectedTestResult, 11 | } from './test-utils.test.js'; 12 | import { 13 | testEasyDirective, 14 | validationDirectivePolicyArgs, 15 | } from './test-utils.test.js'; 16 | import ValidationError from './errors/ValidationError.js'; 17 | 18 | type RootValue = { 19 | arrayTest?: (number | null)[] | null; 20 | test?: number | null; 21 | }; 22 | 23 | const createSchema = ({ 24 | name, 25 | testCase: { directiveArgs }, 26 | }: CreateSchemaConfig): GraphQLSchema => 27 | new Range().applyToSchema( 28 | makeExecutableSchema({ 29 | // schemaDirectives: { [name]: range }, 30 | typeDefs: [ 31 | ...Range.getTypeDefs(name, undefined, true, true), 32 | gql` 33 | type Query { 34 | test: Int @${name}${directiveArgs} 35 | arrayTest: [Int] @${name}${directiveArgs} 36 | } 37 | `, 38 | ], 39 | }), 40 | ); 41 | 42 | const expectedValidationError = ( 43 | message: string, 44 | key: keyof RootValue = 'test', 45 | ): ExpectedTestResult => ({ 46 | data: { [key]: null }, 47 | errors: [new ValidationError(message)], 48 | }); 49 | 50 | const name = 'range'; 51 | 52 | testEasyDirective({ 53 | createSchema, 54 | DirectiveVisitor: Range, 55 | expectedArgsTypeDefs: `\ 56 | ( 57 | """ 58 | The maximum value (inclusive) to allow. If null, no upper limit is applied 59 | """ 60 | max: Float = null 61 | """ 62 | The minimum value (inclusive) to allow. If null, no lower limit is applied 63 | """ 64 | min: Float = null 65 | ${validationDirectivePolicyArgs(capitalize(name))} 66 | )`, 67 | name, 68 | testCases: [ 69 | { 70 | directiveArgs: '(min: 0, max: 100)', 71 | operation: '{ test }', 72 | tests: [ 73 | { rootValue: { test: 50 } }, 74 | { rootValue: { test: 0 } }, 75 | { rootValue: { test: 100 } }, 76 | { 77 | expected: expectedValidationError('Less than 0'), 78 | rootValue: { test: -1 }, 79 | }, 80 | { 81 | expected: expectedValidationError('More than 100'), 82 | rootValue: { test: 101 }, 83 | }, 84 | { rootValue: { test: null } }, 85 | ], 86 | }, 87 | { 88 | directiveArgs: '(min: 0)', 89 | operation: '{ test }', 90 | tests: [ 91 | { rootValue: { test: 50 } }, 92 | { rootValue: { test: 0 } }, 93 | { rootValue: { test: 100 } }, 94 | { 95 | expected: expectedValidationError('Less than 0'), 96 | rootValue: { test: -1 }, 97 | }, 98 | { rootValue: { test: 101 } }, 99 | { rootValue: { test: null } }, 100 | ], 101 | }, 102 | { 103 | directiveArgs: '(max: 100)', 104 | operation: '{ test }', 105 | tests: [ 106 | { rootValue: { test: 50 } }, 107 | { rootValue: { test: 0 } }, 108 | { rootValue: { test: 100 } }, 109 | { rootValue: { test: -1 } }, 110 | { 111 | expected: expectedValidationError('More than 100'), 112 | rootValue: { test: 101 }, 113 | }, 114 | { rootValue: { test: null } }, 115 | ], 116 | }, 117 | { 118 | directiveArgs: '', 119 | operation: '{ test }', 120 | tests: [ 121 | { rootValue: { test: 50 } }, 122 | { rootValue: { test: 0 } }, 123 | { rootValue: { test: 100 } }, 124 | { rootValue: { test: -1 } }, 125 | { rootValue: { test: 101 } }, 126 | { rootValue: { test: null } }, 127 | ], 128 | }, 129 | { 130 | directiveArgs: '(min: 100, max: 0)', 131 | error: new RangeError('@range(max) must be at least equal to min'), 132 | }, 133 | { 134 | // arrays should work the same, just repeat for min+max 135 | directiveArgs: '(min: 0, max: 100)', 136 | operation: '{ arrayTest }', 137 | tests: [ 138 | { rootValue: { arrayTest: [50] } }, 139 | { rootValue: { arrayTest: [0] } }, 140 | { rootValue: { arrayTest: [100] } }, 141 | { 142 | expected: expectedValidationError('Less than 0', 'arrayTest'), 143 | rootValue: { arrayTest: [-1] }, 144 | }, 145 | { 146 | expected: expectedValidationError('More than 100', 'arrayTest'), 147 | rootValue: { arrayTest: [101] }, 148 | }, 149 | { rootValue: { arrayTest: [null] } }, 150 | { rootValue: { arrayTest: null } }, 151 | ], 152 | }, 153 | ], 154 | }); 155 | -------------------------------------------------------------------------------- /lib/range.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFloat } from 'graphql'; 2 | 3 | import type { 4 | ValidateFunction, 5 | ValidationDirectiveArgs, 6 | } from './ValidateDirectiveVisitor.js'; 7 | import createValidateDirectiveVisitor from './createValidateDirectiveVisitor.js'; 8 | import ValidationError from './errors/ValidationError.js'; 9 | 10 | const createValidateMinMax = (min: number, max: number): ValidateFunction => { 11 | if (max < min) 12 | throw new RangeError('@range(max) must be at least equal to min'); 13 | const errorMessageMin = `Less than ${min}`; 14 | const errorMessageMax = `More than ${max}`; 15 | return (value: unknown): unknown => { 16 | if (typeof value === 'number') { 17 | if (value < min) throw new ValidationError(errorMessageMin); 18 | if (value > max) throw new ValidationError(errorMessageMax); 19 | } 20 | return value; 21 | }; 22 | }; 23 | 24 | const createValidateMin = (min: number): ValidateFunction => { 25 | const errorMessage = `Less than ${min}`; 26 | return (value: unknown): unknown => { 27 | if (typeof value === 'number') { 28 | if (value < min) throw new ValidationError(errorMessage); 29 | } 30 | return value; 31 | }; 32 | }; 33 | 34 | const createValidateMax = (max: number): ValidateFunction => { 35 | const errorMessage = `More than ${max}`; 36 | return (value: unknown): unknown => { 37 | if (typeof value === 'number') { 38 | if (value > max) throw new ValidationError(errorMessage); 39 | } 40 | return value; 41 | }; 42 | }; 43 | 44 | type RangeDirectiveArgs = { 45 | min: number | null; 46 | max: number | null; 47 | } & ValidationDirectiveArgs; 48 | 49 | // istanbul ignore next (args set by default to null) 50 | const createValidate = ({ 51 | min = null, 52 | max = null, 53 | }: RangeDirectiveArgs): ValidateFunction | undefined => { 54 | if (min !== null && max !== null) return createValidateMinMax(min, max); 55 | if (min !== null) return createValidateMin(min); 56 | if (max !== null) return createValidateMax(max); 57 | return undefined; 58 | }; 59 | 60 | export default createValidateDirectiveVisitor({ 61 | createValidate, 62 | defaultName: 'range', 63 | directiveConfig: { 64 | args: { 65 | max: { 66 | defaultValue: null, 67 | description: 68 | 'The maximum value (inclusive) to allow. If null, no upper limit is applied', 69 | type: GraphQLFloat, 70 | }, 71 | min: { 72 | defaultValue: null, 73 | description: 74 | 'The minimum value (inclusive) to allow. If null, no lower limit is applied', 75 | type: GraphQLFloat, 76 | }, 77 | }, 78 | description: 79 | 'Ensures value is within boundaries. If used on lists, applies to every item.', 80 | }, 81 | }); 82 | -------------------------------------------------------------------------------- /lib/selfNodeId.test.ts: -------------------------------------------------------------------------------- 1 | import { graphql } from 'graphql'; 2 | import { gql } from 'graphql-tag'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | 5 | import print from './utils/printer.js'; 6 | import { 7 | validationDirectivePolicyArgs, 8 | validationDirectionEnumTypeDefs, 9 | } from './test-utils.test.js'; 10 | import SelfNodeId from './selfNodeId.js'; 11 | import capitalize from './capitalize.js'; 12 | import ValidationError from './errors/ValidationError.js'; 13 | 14 | const toNodeId = (name: string, id: string): string => 15 | Buffer.from(`${name}:${id}`).toString('base64'); 16 | 17 | describe('@selfNodeId()', (): void => { 18 | const name = 'selfNodeId'; 19 | const directiveTypeDefs = SelfNodeId.getTypeDefs(name); 20 | const capitalizedName = capitalize(name); 21 | 22 | it('exports correct typeDefs', (): void => { 23 | expect(directiveTypeDefs.map(print)).toEqual([ 24 | `\ 25 | """ensures that the ID is converted to a global ID""" 26 | directive @${name}( 27 | ${validationDirectivePolicyArgs(capitalizedName)} 28 | ) on FIELD_DEFINITION | OBJECT 29 | `, 30 | `\ 31 | ${validationDirectionEnumTypeDefs(capitalizedName)} 32 | `, 33 | ]); 34 | }); 35 | 36 | it('defaultName is correct', (): void => { 37 | expect(directiveTypeDefs.map(print)).toEqual( 38 | SelfNodeId.getTypeDefs().map(print), 39 | ); 40 | }); 41 | 42 | describe('createDirectiveContext()', (): void => { 43 | it('supports function', (): void => { 44 | const ctx = SelfNodeId.createDirectiveContext({ 45 | toNodeId, 46 | }); 47 | expect(ctx.toNodeId).toBe(toNodeId); 48 | }); 49 | }); 50 | 51 | describe('fails on object definition', (): void => { 52 | it('ID field not provided', async (): Promise => { 53 | expect.assertions(1); 54 | const typeName = 'TypeWithNoId'; 55 | expect(() => 56 | new SelfNodeId().applyToSchema( 57 | makeExecutableSchema({ 58 | typeDefs: [ 59 | ...directiveTypeDefs, 60 | gql` 61 | type ${typeName} @selfNodeId { 62 | field: ID! 63 | anotherField: Float 64 | } 65 | type Query { 66 | test: ${typeName} 67 | } 68 | `, 69 | ], 70 | }), 71 | ), 72 | ).toThrow(new ValidationError(`id field was not found in ${typeName}`)); 73 | }); 74 | }); 75 | 76 | describe('works on field and object definitions', (): void => { 77 | const type1 = 'Type1'; 78 | const type2 = 'Type2'; 79 | const type4 = 'Type4'; 80 | const type1Id = 'ThisIsAnId'; 81 | const type2Id = 'ThisIsAnotherId'; 82 | const type4Id = 'type4Id'; 83 | const schema = new SelfNodeId().applyToSchema( 84 | makeExecutableSchema({ 85 | typeDefs: [ 86 | ...directiveTypeDefs, 87 | gql` 88 | type ${type1} { 89 | id: ID! @selfNodeId 90 | } 91 | type ${type2} { 92 | id: ID! @selfNodeId 93 | } 94 | type Type3 { 95 | id: ID! 96 | } 97 | type TypeNullable { 98 | id: ID @selfNodeId # test nullable 99 | } 100 | type ${type4} @selfNodeId { 101 | id: ID! 102 | anotherField: Float! 103 | yetAnotherField: String! 104 | } 105 | type ShouldFail { 106 | float: Float @selfNodeId 107 | array: [String] @selfNodeId 108 | } 109 | type Test { 110 | type1: ${type1} 111 | type2: ${type2} 112 | type3: Type3 113 | type4: ${type4} 114 | typeNullable: TypeNullable 115 | shouldFail: ShouldFail 116 | } 117 | type Query { 118 | test: Test 119 | } 120 | `, 121 | ], 122 | }), 123 | ); 124 | const source = print(gql` 125 | query { 126 | test { 127 | type1 { 128 | id 129 | } 130 | type2 { 131 | id 132 | } 133 | type3 { 134 | id 135 | } 136 | type4 { 137 | id 138 | anotherField 139 | yetAnotherField 140 | } 141 | typeNullable { 142 | id 143 | } 144 | shouldFail { 145 | float 146 | array 147 | } 148 | } 149 | } 150 | `); 151 | const rootValue = { 152 | test: { 153 | shouldFail: { 154 | array: ['1', '2'], 155 | float: 2.3, 156 | }, 157 | type1: { 158 | id: type1Id, 159 | }, 160 | type2: { 161 | id: type2Id, 162 | }, 163 | type3: { 164 | id: '2', 165 | }, 166 | type4: { 167 | anotherField: 5.2, 168 | id: type4Id, 169 | yetAnotherField: 'asd', 170 | }, 171 | typeNullable: { 172 | id: null, 173 | }, 174 | }, 175 | }; 176 | 177 | it('Correctly converts to node ID', async (): Promise => { 178 | const contextValue = SelfNodeId.createDirectiveContext({ 179 | toNodeId, 180 | }); 181 | const result = await graphql({ 182 | contextValue, 183 | rootValue, 184 | schema, 185 | source, 186 | }); 187 | expect(result).toEqual({ 188 | data: { 189 | test: { 190 | shouldFail: { 191 | array: null, 192 | float: null, 193 | }, 194 | type1: { 195 | id: toNodeId(type1, type1Id), 196 | }, 197 | type2: { 198 | id: toNodeId(type2, type2Id), 199 | }, 200 | type3: { 201 | id: rootValue.test.type3.id, 202 | }, 203 | type4: { 204 | anotherField: rootValue.test.type4.anotherField, 205 | id: toNodeId(type4, type4Id), 206 | yetAnotherField: rootValue.test.type4.yetAnotherField, 207 | }, 208 | typeNullable: { 209 | id: null, 210 | }, 211 | }, 212 | }, 213 | errors: [ 214 | new ValidationError('selfNodeId directive only works on strings'), 215 | new ValidationError('selfNodeId directive only works on strings'), 216 | ], 217 | }); 218 | }); 219 | 220 | it('Correctly converts to node ID', async (): Promise => { 221 | const contextValue = SelfNodeId.createDirectiveContext({ 222 | toNodeId: () => null, 223 | }); 224 | const result = await graphql({ 225 | contextValue, 226 | rootValue, 227 | schema, 228 | source, 229 | }); 230 | expect(result).toEqual({ 231 | data: { 232 | test: { 233 | shouldFail: { 234 | array: null, 235 | float: null, 236 | }, 237 | type1: null, 238 | type2: null, 239 | type3: { 240 | id: '2', 241 | }, 242 | type4: null, 243 | typeNullable: { 244 | id: null, 245 | }, 246 | }, 247 | }, 248 | errors: [ 249 | new ValidationError('selfNodeId directive only works on strings'), 250 | new ValidationError('selfNodeId directive only works on strings'), 251 | new ValidationError(`Could not encode ID to typename ${type1}`), 252 | new ValidationError(`Could not encode ID to typename ${type2}`), 253 | new ValidationError(`Could not encode ID to typename ${type4}`), 254 | ], 255 | }); 256 | }); 257 | }); 258 | }); 259 | -------------------------------------------------------------------------------- /lib/selfNodeId.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLObjectType, GraphQLInterfaceType } from 'graphql'; 2 | import { DirectiveLocation } from 'graphql'; 3 | 4 | import ValidationError from './errors/ValidationError.js'; 5 | 6 | import type { ValidateFunction } from './ValidateDirectiveVisitor.js'; 7 | import type ValidateDirectiveVisitor from './ValidateDirectiveVisitor.js'; 8 | import { 9 | setFieldResolveToApplyOriginalResolveAndThenValidateResult, 10 | ValidateDirectiveVisitorNonTyped, 11 | } from './ValidateDirectiveVisitor.js'; 12 | 13 | type ToNodeId = (entityName: string, id: string) => string | null; 14 | 15 | export type SelfNodeIdContext<_ extends object = object> = { 16 | toNodeId: ToNodeId; 17 | }; 18 | 19 | /* 20 | graphql-tools changed the typing for SchemaDirectiveVisitor and if you define a type for TArgs and TContext, 21 | you'll get this error: "Type 'typeof Your_Directive_Class' is not assignable to type 'typeof SchemaDirectiveVisitor'.". 22 | If you are using the old graphql-tools, you can use: 23 | extends ValidateDirectiveVisitor 24 | */ 25 | export default class SelfNodeIdDirective< 26 | _ extends SelfNodeIdContext, 27 | > extends ValidateDirectiveVisitorNonTyped { 28 | // eslint-disable-next-line class-methods-use-this 29 | public getValidationForArgs(): ValidateFunction { 30 | const errorMessage = `selfNodeId directive only works on strings`; 31 | return ( 32 | value: unknown, 33 | _, 34 | { name }, 35 | { toNodeId }, 36 | ): string | undefined | null => { 37 | if (typeof value !== 'string') { 38 | if (value === undefined || value === null) { 39 | return value; 40 | } 41 | throw new ValidationError(errorMessage); 42 | } 43 | const encodedId = toNodeId(name, value); 44 | if (!encodedId) { 45 | throw new ValidationError(`Could not encode ID to typename ${name}`); 46 | } 47 | return encodedId; 48 | }; 49 | } 50 | 51 | public static readonly config: (typeof ValidateDirectiveVisitor)['config'] = { 52 | description: 'ensures that the ID is converted to a global ID', 53 | locations: [DirectiveLocation.FIELD_DEFINITION, DirectiveLocation.OBJECT], 54 | }; 55 | 56 | public visitObject(object: GraphQLObjectType | GraphQLInterfaceType): void { 57 | const validate = this.getValidationForArgs(); 58 | let foundId = false; 59 | const fields = Object.values(object.getFields()); 60 | for (let i = 0; i < fields.length; i += 1) { 61 | const field = fields[i]; 62 | if (field.name === 'id') { 63 | setFieldResolveToApplyOriginalResolveAndThenValidateResult( 64 | field, 65 | validate, 66 | object as GraphQLObjectType, 67 | ); 68 | foundId = true; 69 | break; 70 | } 71 | } 72 | if (!foundId) { 73 | throw new ValidationError(`id field was not found in ${object.name}`); 74 | } 75 | } 76 | 77 | public static readonly defaultName: string = 'selfNodeId'; 78 | 79 | public static createDirectiveContext(ctx: { 80 | toNodeId: ToNodeId; 81 | }): SelfNodeIdContext { 82 | return ctx; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/stringLength.test.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLSchema } from 'graphql'; 2 | import { gql } from 'graphql-tag'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | 5 | import StringLength from './stringLength.js'; 6 | import capitalize from './capitalize.js'; 7 | 8 | import type { 9 | CreateSchemaConfig, 10 | ExpectedTestResult, 11 | } from './test-utils.test.js'; 12 | import { 13 | testEasyDirective, 14 | validationDirectivePolicyArgs, 15 | } from './test-utils.test.js'; 16 | import ValidationError from './errors/ValidationError.js'; 17 | 18 | type RootValue = { 19 | arrayTest?: (string | null)[] | null; 20 | test?: string | null; 21 | }; 22 | 23 | const createSchema = ({ 24 | name, 25 | testCase: { directiveArgs }, 26 | }: CreateSchemaConfig): GraphQLSchema => 27 | new StringLength().applyToSchema( 28 | makeExecutableSchema({ 29 | typeDefs: [ 30 | ...StringLength.getTypeDefs(name, undefined, true, true), 31 | gql` 32 | type Query { 33 | test: String @${name}${directiveArgs} 34 | arrayTest: [String] @${name}${directiveArgs} 35 | } 36 | `, 37 | ], 38 | }), 39 | ); 40 | 41 | const expectedValidationError = ( 42 | message: string, 43 | key: keyof RootValue = 'test', 44 | ): ExpectedTestResult => ({ 45 | data: { [key]: null }, 46 | errors: [new ValidationError(message)], 47 | }); 48 | 49 | const name = 'stringLength'; 50 | 51 | testEasyDirective({ 52 | createSchema, 53 | DirectiveVisitor: StringLength, 54 | expectedArgsTypeDefs: `\ 55 | ( 56 | """ 57 | The maximum string length (inclusive) to allow. If null, no upper limit is applied 58 | """ 59 | max: Float = null 60 | """ 61 | The minimum string length (inclusive) to allow. If null, no lower limit is applied 62 | """ 63 | min: Float = null 64 | ${validationDirectivePolicyArgs(capitalize(name))} 65 | )`, 66 | name, 67 | testCases: [ 68 | { 69 | directiveArgs: '(min: 1, max: 3)', 70 | operation: '{ test }', 71 | tests: [ 72 | { rootValue: { test: 'ab' } }, 73 | { rootValue: { test: 'a' } }, 74 | { rootValue: { test: 'abc' } }, 75 | { 76 | expected: expectedValidationError('String Length is Less than 1'), 77 | rootValue: { test: '' }, 78 | }, 79 | { 80 | expected: expectedValidationError('String Length is More than 3'), 81 | rootValue: { test: 'abcd' }, 82 | }, 83 | { rootValue: { test: null } }, 84 | ], 85 | }, 86 | { 87 | directiveArgs: '(min: 1)', 88 | operation: '{ test }', 89 | tests: [ 90 | { rootValue: { test: 'ab' } }, 91 | { rootValue: { test: 'a' } }, 92 | { rootValue: { test: 'abc' } }, 93 | { 94 | expected: expectedValidationError('String Length is Less than 1'), 95 | rootValue: { test: '' }, 96 | }, 97 | { rootValue: { test: 'abcd' } }, 98 | { rootValue: { test: null } }, 99 | ], 100 | }, 101 | { 102 | directiveArgs: '(max: 3)', 103 | operation: '{ test }', 104 | tests: [ 105 | { rootValue: { test: 'ab' } }, 106 | { rootValue: { test: 'a' } }, 107 | { rootValue: { test: 'abc' } }, 108 | { rootValue: { test: '' } }, 109 | { 110 | expected: expectedValidationError('String Length is More than 3'), 111 | rootValue: { test: 'abcd' }, 112 | }, 113 | { rootValue: { test: null } }, 114 | ], 115 | }, 116 | { 117 | directiveArgs: '', 118 | operation: '{ test }', 119 | tests: [ 120 | { rootValue: { test: 'ab' } }, 121 | { rootValue: { test: 'a' } }, 122 | { rootValue: { test: 'abc' } }, 123 | { rootValue: { test: '' } }, 124 | { rootValue: { test: 'abcd' } }, 125 | { rootValue: { test: null } }, 126 | ], 127 | }, 128 | { 129 | directiveArgs: '(min: 100, max: 0)', 130 | error: new RangeError('@stringLength(max) must be at least equal to min'), 131 | }, 132 | { 133 | directiveArgs: '(min: -1, max: 1)', 134 | error: new RangeError('@stringLength(min) must be at least 0'), 135 | }, 136 | { 137 | directiveArgs: '(min: -1)', 138 | error: new RangeError('@stringLength(min) must be at least 0'), 139 | }, 140 | { 141 | directiveArgs: '(max: -1)', 142 | error: new RangeError('@stringLength(max) must be at least 0'), 143 | }, 144 | { 145 | // arrays should work the same, just repeat for min+max 146 | directiveArgs: '(min: 1, max: 3)', 147 | operation: '{ arrayTest }', 148 | tests: [ 149 | { rootValue: { arrayTest: ['ab'] } }, 150 | { rootValue: { arrayTest: ['a'] } }, 151 | { rootValue: { arrayTest: ['abc'] } }, 152 | { 153 | expected: expectedValidationError( 154 | 'String Length is Less than 1', 155 | 'arrayTest', 156 | ), 157 | rootValue: { arrayTest: [''] }, 158 | }, 159 | { 160 | expected: expectedValidationError( 161 | 'String Length is More than 3', 162 | 'arrayTest', 163 | ), 164 | rootValue: { arrayTest: ['abcd'] }, 165 | }, 166 | { rootValue: { arrayTest: [null] } }, 167 | { rootValue: { arrayTest: null } }, 168 | ], 169 | }, 170 | ], 171 | }); 172 | -------------------------------------------------------------------------------- /lib/stringLength.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFloat } from 'graphql'; 2 | 3 | import type { 4 | ValidateFunction, 5 | ValidationDirectiveArgs, 6 | } from './ValidateDirectiveVisitor.js'; 7 | import createValidateDirectiveVisitor from './createValidateDirectiveVisitor.js'; 8 | import ValidationError from './errors/ValidationError.js'; 9 | 10 | const createValidateMinMax = (min: number, max: number): ValidateFunction => { 11 | if (min < 0) throw new RangeError('@stringLength(min) must be at least 0'); 12 | if (max < min) 13 | throw new RangeError('@stringLength(max) must be at least equal to min'); 14 | const errorMessageMin = `String Length is Less than ${min}`; 15 | const errorMessageMax = `String Length is More than ${max}`; 16 | return (value: unknown): unknown => { 17 | if (typeof value === 'string') { 18 | const { length } = value; 19 | if (length < min) throw new ValidationError(errorMessageMin); 20 | if (length > max) throw new ValidationError(errorMessageMax); 21 | } 22 | return value; 23 | }; 24 | }; 25 | 26 | const createValidateMin = (min: number): ValidateFunction => { 27 | if (min < 0) throw new RangeError('@stringLength(min) must be at least 0'); 28 | const errorMessage = `String Length is Less than ${min}`; 29 | return (value: unknown): unknown => { 30 | if (typeof value === 'string') { 31 | if (value.length < min) throw new ValidationError(errorMessage); 32 | } 33 | return value; 34 | }; 35 | }; 36 | 37 | const createValidateMax = (max: number): ValidateFunction => { 38 | if (max < 0) throw new RangeError('@stringLength(max) must be at least 0'); 39 | const errorMessage = `String Length is More than ${max}`; 40 | return (value: unknown): unknown => { 41 | if (typeof value === 'string') { 42 | if (value.length > max) throw new ValidationError(errorMessage); 43 | } 44 | return value; 45 | }; 46 | }; 47 | 48 | type StringLengthDirectiveArgs = { 49 | min: number | null; 50 | max: number | null; 51 | } & ValidationDirectiveArgs; 52 | 53 | // istanbul ignore next (args set by default to null) 54 | const createValidate = ({ 55 | min = null, 56 | max = null, 57 | }: StringLengthDirectiveArgs): ValidateFunction | undefined => { 58 | if (min !== null && max !== null) return createValidateMinMax(min, max); 59 | if (min !== null) return createValidateMin(min); 60 | if (max !== null) return createValidateMax(max); 61 | return undefined; 62 | }; 63 | 64 | export default createValidateDirectiveVisitor({ 65 | createValidate, 66 | defaultName: 'stringLength', 67 | directiveConfig: { 68 | args: { 69 | max: { 70 | defaultValue: null, 71 | description: 72 | 'The maximum string length (inclusive) to allow. If null, no upper limit is applied', 73 | type: GraphQLFloat, 74 | }, 75 | min: { 76 | defaultValue: null, 77 | description: 78 | 'The minimum string length (inclusive) to allow. If null, no lower limit is applied', 79 | type: GraphQLFloat, 80 | }, 81 | }, 82 | description: 83 | 'Ensures string length is within boundaries. If used on lists, applies to every item.', 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /lib/test-utils.test.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLSchema, ExecutionResult } from 'graphql'; 2 | import { graphql } from 'graphql'; 3 | import { gql } from 'graphql-tag'; 4 | 5 | import print from './utils/printer.js'; 6 | import type EasyDirectiveVisitor from './EasyDirectiveVisitor.js'; 7 | import capitalize from './capitalize.js'; 8 | 9 | export const minimalTypeDef = gql` 10 | type T { 11 | i: Int 12 | } 13 | `; 14 | 15 | export type CreateSchemaConfig = { 16 | DirectiveVisitor: typeof EasyDirectiveVisitor; 17 | name: string; 18 | testCase: TestCase; 19 | }; 20 | export type CreateSchema = ( 21 | config: CreateSchemaConfig, 22 | ) => GraphQLSchema; 23 | 24 | export type ExpectedTestResult = ExecutionResult; 25 | 26 | export type TestCase = { 27 | directiveArgs: string; 28 | // if provided then createSchema() is expected to fail 29 | error?: Error; 30 | // must be provided if tests do not provide one (used as fallback) 31 | operation?: string; 32 | // must be provided if tests do not provide one (used as fallback) 33 | rootValue?: TValue; 34 | // defaults to no test case (usually when createSchema() is expected to fail) 35 | tests?: { 36 | // if expected is undefined, then assume: { data: { rootValue } } 37 | expected?: ExpectedTestResult; 38 | // if undefined, use TestCase.operation 39 | operation?: string; 40 | rootValue: TValue; 41 | }[]; 42 | }; 43 | 44 | export const validationDirectionEnumTypeDefs = (name: string): string => `\ 45 | enum ${name}ValidateDirectivePolicy { 46 | """ 47 | Field resolver is responsible to evaluate it using \`validationErrors\` injected in GraphQLResolverInfo 48 | """ 49 | RESOLVER 50 | """ 51 | Field resolver is not called if occurs a validation error, it throws \`UserInputError\` 52 | """ 53 | THROW 54 | }`; 55 | 56 | export const validationDirectivePolicyArgs = (name: string): string => `\ 57 | """How to handle validation errors""" 58 | policy: ${name}ValidateDirectivePolicy = RESOLVER`; 59 | 60 | const buildDescription = (text: string): string => 61 | text.length > 70 ? `"""\n${text}\n"""\n` : `"""${text}"""\n`; 62 | 63 | export const testEasyDirective = ({ 64 | createSchema, 65 | name, 66 | DirectiveVisitor, 67 | expectedArgsTypeDefs = '', 68 | expectedUnknownTypeDefs = validationDirectionEnumTypeDefs(capitalize(name)), 69 | testCases, 70 | }: { 71 | createSchema: CreateSchema; 72 | name: string; 73 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 74 | DirectiveVisitor: any; 75 | expectedArgsTypeDefs?: string; 76 | expectedUnknownTypeDefs?: string; 77 | testCases: TestCase[]; 78 | }): void => { 79 | describe(`directive @${name}`, (): void => { 80 | const { locations } = DirectiveVisitor.config; 81 | const locationsStr = locations.join(' | '); 82 | const directiveTypeDefs = DirectiveVisitor.getTypeDefs(name) 83 | .map(print) 84 | .join(''); 85 | 86 | it('exports correct typeDefs', (): void => { 87 | const { description } = DirectiveVisitor.config; 88 | const expectedDescription = description 89 | ? buildDescription(description) 90 | : ''; 91 | expect(directiveTypeDefs).toBe(`\ 92 | ${expectedDescription}\ 93 | directive @${name}${expectedArgsTypeDefs} \ 94 | on ${locationsStr} 95 | ${expectedUnknownTypeDefs}\ 96 | 97 | `); 98 | }); 99 | 100 | it('defaultName is correct', (): void => { 101 | expect(directiveTypeDefs).toEqual( 102 | DirectiveVisitor.getTypeDefs().map(print).join(''), 103 | ); 104 | }); 105 | 106 | describe('validate works', (): void => { 107 | testCases.forEach((testCase: TestCase): void => { 108 | const { 109 | directiveArgs, 110 | error, 111 | tests = [], 112 | operation: fallbackOperation = '', 113 | rootValue: fallbackRootValue, 114 | } = testCase; 115 | describe(directiveArgs || '', (): void => { 116 | if (error) { 117 | expect(() => 118 | createSchema({ DirectiveVisitor, name, testCase }), 119 | ).toThrowError(error); 120 | } else { 121 | const schema = createSchema({ 122 | DirectiveVisitor, 123 | name, 124 | testCase, 125 | }); 126 | tests.forEach( 127 | ({ 128 | rootValue: itemRootValue, 129 | expected: itemExpected, 130 | operation: itemOperation, 131 | }): void => { 132 | const rootValue = itemRootValue || fallbackRootValue; 133 | const operation = itemOperation || fallbackOperation; 134 | const expected = itemExpected || { 135 | data: rootValue as unknown as TResult, 136 | }; 137 | const { errors } = expected || {}; 138 | it(`value ${JSON.stringify(rootValue)} ${ 139 | errors 140 | ? `fails with: ${errors.map(e => e.message).join(', ')}` 141 | : 'works' 142 | }`, async (): Promise => { 143 | const result = await graphql({ 144 | rootValue: itemRootValue || rootValue, 145 | schema, 146 | source: itemOperation || operation, 147 | }); 148 | expect(result).toEqual({ data: null, ...expected }); 149 | }); 150 | }, 151 | ); 152 | } 153 | }); 154 | }); 155 | }); 156 | }); 157 | }; 158 | -------------------------------------------------------------------------------- /lib/trim.test.ts: -------------------------------------------------------------------------------- 1 | import type { GraphQLSchema } from 'graphql'; 2 | import { gql } from 'graphql-tag'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | 5 | import Trim, { 6 | createValidate as createTrimDirectiveValidate, 7 | DEFAULT_TRIM_MODE, 8 | trimDirectiveSchemaEnumName, 9 | TrimMode, 10 | } from './trim.js'; 11 | import capitalize from './capitalize.js'; 12 | 13 | import type { 14 | CreateSchemaConfig, 15 | ExpectedTestResult, 16 | TestCase, 17 | } from './test-utils.test.js'; 18 | import { 19 | testEasyDirective, 20 | validationDirectionEnumTypeDefs, 21 | validationDirectivePolicyArgs, 22 | } from './test-utils.test.js'; 23 | import { ValidateDirectivePolicy } from './ValidateDirectiveVisitor.js'; 24 | 25 | type RootValue = { 26 | arrayTest?: (string | null)[] | null; 27 | test?: string | null; 28 | number?: number; 29 | bool?: boolean; 30 | obj?: { toString(): string }; 31 | }; 32 | 33 | const directiveName = 'trim'; 34 | 35 | const createSchema = ({ 36 | name, 37 | testCase: { directiveArgs }, 38 | }: CreateSchemaConfig): GraphQLSchema => 39 | new Trim().applyToSchema( 40 | makeExecutableSchema({ 41 | typeDefs: [ 42 | ...Trim.getTypeDefs(name, undefined, true, true), 43 | gql` 44 | type Query { 45 | test: String @${name}${directiveArgs} 46 | } 47 | `, 48 | ], 49 | }), 50 | ); 51 | 52 | const expectedResult = ( 53 | value: string, 54 | key: keyof RootValue = 'test', 55 | ): ExpectedTestResult => ({ 56 | data: { [key]: value }, 57 | }); 58 | 59 | const stringToTrim = 60 | ' \r\n \n \t \r string with whitespaces at both start and end \n \r \r\n \t '; 61 | 62 | const noTrimmableWhitespaces = 'No Spaces'; 63 | 64 | const whiteSpacesAtTheEnd = 'White spaces at the end \n \r \r\n \t'; 65 | 66 | const whiteSpacesAtTheStart = ' \r \r\n \t \n White spaces at the start'; 67 | 68 | const generateTestCase: ( 69 | value: string, 70 | trimFn: (stringToTrim: string) => string, 71 | ) => NonNullable['tests']>[0] = (value, trimFn) => ({ 72 | expected: expectedResult(trimFn(value)), 73 | rootValue: { test: value }, 74 | }); 75 | 76 | const trimAll = (value: { toString: () => string }): string => 77 | value.toString().trim(); 78 | 79 | const trimEnd = (value: { toString: () => string }): string => 80 | value.toString().trimRight(); 81 | 82 | const trimStart = (value: { toString: () => string }): string => 83 | value.toString().trimLeft(); 84 | 85 | describe('directive @trim error tests', () => { 86 | // this should never happen due to schema validation, but is added to achieve 100% coverage 87 | it.each([ 88 | [ValidateDirectivePolicy.RESOLVER], 89 | [ValidateDirectivePolicy.THROW], 90 | ])('should throw an error when "mode" is invalid - policy: %s', policy => { 91 | const invalidMode = 'INVALID_MODE' as TrimMode; 92 | try { 93 | createTrimDirectiveValidate({ 94 | mode: invalidMode, 95 | policy, 96 | }); 97 | expect(true).toBeFalsy(); 98 | } catch (err) { 99 | expect(err).toEqual( 100 | new TypeError( 101 | `The value ${invalidMode} is not accepted by this argument`, 102 | ), 103 | ); 104 | } 105 | }); 106 | }); 107 | 108 | testEasyDirective({ 109 | createSchema, 110 | DirectiveVisitor: Trim, 111 | expectedArgsTypeDefs: `\ 112 | ( 113 | mode: ${trimDirectiveSchemaEnumName}! = ${DEFAULT_TRIM_MODE} 114 | ${validationDirectivePolicyArgs(capitalize(directiveName))} 115 | )`, 116 | expectedUnknownTypeDefs: `enum ${trimDirectiveSchemaEnumName} { 117 | """ 118 | The value of this field will have both start and end of the string trimmed 119 | """ 120 | ${TrimMode.TRIM_ALL} 121 | """The value of this field will have only the end of the string trimmed""" 122 | ${TrimMode.TRIM_END} 123 | """The value of this field will have only the start of the string trimmed""" 124 | ${TrimMode.TRIM_START} 125 | } 126 | ${validationDirectionEnumTypeDefs(capitalize(directiveName))}`, 127 | name: directiveName, 128 | testCases: [ 129 | { 130 | directiveArgs: `(mode: ${TrimMode.TRIM_ALL} )`, 131 | operation: '{ test }', 132 | tests: [ 133 | generateTestCase(noTrimmableWhitespaces, trimAll), 134 | generateTestCase(stringToTrim, trimAll), 135 | generateTestCase(whiteSpacesAtTheEnd, trimAll), 136 | generateTestCase(whiteSpacesAtTheStart, trimAll), 137 | ], 138 | }, 139 | { 140 | directiveArgs: '', 141 | operation: '{ test }', 142 | tests: [ 143 | generateTestCase(noTrimmableWhitespaces, trimAll), 144 | generateTestCase(stringToTrim, trimAll), 145 | generateTestCase(whiteSpacesAtTheEnd, trimAll), 146 | generateTestCase(whiteSpacesAtTheStart, trimAll), 147 | ], 148 | }, 149 | { 150 | directiveArgs: `(mode: ${TrimMode.TRIM_END})`, 151 | operation: '{ test }', 152 | tests: [ 153 | generateTestCase(noTrimmableWhitespaces, trimEnd), 154 | generateTestCase(stringToTrim, trimEnd), 155 | generateTestCase(whiteSpacesAtTheEnd, trimEnd), 156 | generateTestCase(whiteSpacesAtTheStart, trimEnd), 157 | ], 158 | }, 159 | { 160 | directiveArgs: `(mode: ${TrimMode.TRIM_START})`, 161 | operation: '{ test }', 162 | tests: [ 163 | generateTestCase(noTrimmableWhitespaces, trimStart), 164 | generateTestCase(stringToTrim, trimStart), 165 | generateTestCase(whiteSpacesAtTheEnd, trimStart), 166 | generateTestCase(whiteSpacesAtTheStart, trimStart), 167 | ], 168 | }, 169 | ], 170 | }); 171 | -------------------------------------------------------------------------------- /lib/trim.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLEnumType, GraphQLNonNull } from 'graphql'; 2 | 3 | import type { 4 | ValidateFunction, 5 | ValidationDirectiveArgs, 6 | } from './ValidateDirectiveVisitor.js'; 7 | import createValidateDirectiveVisitor from './createValidateDirectiveVisitor.js'; 8 | import neverAssertion from './utils/neverAssertion.js'; 9 | import createPatternHandler from './patternCommon.js'; 10 | 11 | export enum TrimMode { 12 | TRIM_ALL = 'TRIM_ALL', 13 | TRIM_END = 'TRIM_END', 14 | TRIM_START = 'TRIM_START', 15 | } 16 | 17 | export const DEFAULT_TRIM_MODE = TrimMode.TRIM_ALL; 18 | 19 | export const trimDirectiveSchemaEnumName = 'TrimDirectiveMode'; 20 | 21 | type TrimDirectiveArgs = ValidationDirectiveArgs & { mode: TrimMode }; 22 | 23 | const trimAllHandler = createPatternHandler((value: string): string => 24 | value.trim(), 25 | ); 26 | 27 | const trimEndHandler = createPatternHandler((value: string): string => 28 | value.trimRight(), 29 | ); 30 | 31 | const trimStartHandler = createPatternHandler((value: string): string => 32 | value.trimLeft(), 33 | ); 34 | 35 | export const createValidate = ({ 36 | mode, 37 | }: TrimDirectiveArgs): ValidateFunction => { 38 | switch (mode) { 39 | case TrimMode.TRIM_ALL: 40 | return trimAllHandler; 41 | case TrimMode.TRIM_END: 42 | return trimEndHandler; 43 | case TrimMode.TRIM_START: 44 | return trimStartHandler; 45 | default: 46 | return neverAssertion(mode); 47 | } 48 | }; 49 | 50 | export default createValidateDirectiveVisitor({ 51 | createValidate, 52 | defaultName: 'trim', 53 | directiveConfig: { 54 | args: { 55 | mode: { 56 | defaultValue: DEFAULT_TRIM_MODE, 57 | type: new GraphQLNonNull( 58 | new GraphQLEnumType({ 59 | name: trimDirectiveSchemaEnumName, 60 | values: { 61 | [TrimMode.TRIM_ALL]: { 62 | description: 63 | 'The value of this field will have both start and end of the string trimmed', 64 | value: TrimMode.TRIM_ALL, 65 | }, 66 | [TrimMode.TRIM_END]: { 67 | description: 68 | 'The value of this field will have only the end of the string trimmed', 69 | value: TrimMode.TRIM_END, 70 | }, 71 | [TrimMode.TRIM_START]: { 72 | description: 73 | 'The value of this field will have only the start of the string trimmed', 74 | value: TrimMode.TRIM_START, 75 | }, 76 | }, 77 | }), 78 | ), 79 | }, 80 | }, 81 | description: 'trims a string based on the selected mode', 82 | }, 83 | }); 84 | -------------------------------------------------------------------------------- /lib/utils/applyDirectivesToSchema.ts: -------------------------------------------------------------------------------- 1 | import type { DirectiveLocation, GraphQLSchema } from 'graphql'; 2 | 3 | import type EasyDirectiveVisitor from '../EasyDirectiveVisitor.js'; 4 | 5 | interface EasyDirectiveVisitorConstructor { 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | new (): EasyDirectiveVisitor; 8 | } 9 | 10 | const applyDirectivesToSchema = ( 11 | directives: EasyDirectiveVisitorConstructor[], 12 | schema: GraphQLSchema, 13 | ): GraphQLSchema => 14 | directives.reduce( 15 | (mappedSchema, Directive) => new Directive().applyToSchema(mappedSchema), 16 | schema, 17 | ); 18 | 19 | export default applyDirectivesToSchema; 20 | -------------------------------------------------------------------------------- /lib/utils/neverAssertion.ts: -------------------------------------------------------------------------------- 1 | const neverAssertion = (value: never): never => { 2 | throw new TypeError(`The value ${value} is not accepted by this argument`); 3 | }; 4 | export default neverAssertion; 5 | -------------------------------------------------------------------------------- /lib/utils/printer.ts: -------------------------------------------------------------------------------- 1 | import type { ASTNode } from 'graphql'; 2 | import { print as defaultPrinter } from 'graphql'; 3 | 4 | const print = (ast: ASTNode): string => `${defaultPrinter(ast)}\n`; 5 | 6 | export default print; 7 | -------------------------------------------------------------------------------- /lib/validateArrayOrValue.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLInt, 3 | GraphQLList, 4 | GraphQLNonNull, 5 | GraphQLObjectType, 6 | } from 'graphql'; 7 | 8 | import validateArrayOrValue from './validateArrayOrValue.js'; 9 | 10 | describe('validateArrayOrValue', (): void => { 11 | const passThru = (x: T): T => x; 12 | const mockValidate = jest.fn(passThru); 13 | 14 | const GraphQLNonNullInt = new GraphQLNonNull(GraphQLInt); 15 | 16 | const GraphQLIntList = new GraphQLList(GraphQLInt); 17 | const GraphQLIntListNonNull = new GraphQLList(GraphQLNonNullInt); 18 | 19 | const GraphQLNonNullIntList = new GraphQLNonNull(GraphQLIntList); 20 | const GraphQLNonNullIntListNonNull = new GraphQLNonNull( 21 | GraphQLIntListNonNull, 22 | ); 23 | const GraphQLIntListList = new GraphQLList(GraphQLIntList); 24 | 25 | const value = 123; 26 | const array = [value, value * 2]; 27 | const container = new GraphQLObjectType({ 28 | fields: {}, 29 | name: 'container', 30 | }); 31 | const context = { theContext: 1234 }; 32 | const resolverInfo: Record = { aInfo: 42 }; 33 | const resolverSource: Record = { aSource: 'source' }; 34 | const resolverArguments: Record = { aArg: 'argument' }; 35 | 36 | beforeEach(() => { 37 | mockValidate.mockReset(); 38 | mockValidate.mockImplementation(passThru); 39 | }); 40 | 41 | it('works with value', (): void => { 42 | expect( 43 | validateArrayOrValue(mockValidate)( 44 | value, 45 | GraphQLInt, 46 | container, 47 | context, 48 | resolverInfo, 49 | resolverSource, 50 | resolverArguments, 51 | ), 52 | ).toBe(value); 53 | expect(mockValidate).toBeCalledTimes(1); 54 | expect(mockValidate).toBeCalledWith( 55 | value, 56 | GraphQLInt, 57 | container, 58 | context, 59 | resolverInfo, 60 | resolverSource, 61 | resolverArguments, 62 | ); 63 | }); 64 | 65 | it('works with non-null value', (): void => { 66 | expect( 67 | validateArrayOrValue(mockValidate)( 68 | value, 69 | GraphQLNonNullInt, 70 | container, 71 | context, 72 | resolverInfo, 73 | resolverSource, 74 | resolverArguments, 75 | ), 76 | ).toBe(value); 77 | expect(mockValidate).toBeCalledTimes(1); 78 | expect(mockValidate).toBeCalledWith( 79 | value, 80 | GraphQLNonNullInt, 81 | container, 82 | context, 83 | resolverInfo, 84 | resolverSource, 85 | resolverArguments, 86 | ); 87 | }); 88 | 89 | it('works with simple array and list', (): void => { 90 | // equal not be: array is `map()`, result is a new array 91 | expect( 92 | validateArrayOrValue(mockValidate)( 93 | array, 94 | GraphQLIntList, 95 | container, 96 | context, 97 | resolverInfo, 98 | resolverSource, 99 | resolverArguments, 100 | ), 101 | ).toEqual(array); 102 | expect(mockValidate).toBeCalledTimes(array.length); 103 | array.forEach(item => 104 | expect(mockValidate).toBeCalledWith( 105 | item, 106 | GraphQLInt, 107 | container, 108 | context, 109 | resolverInfo, 110 | resolverSource, 111 | resolverArguments, 112 | ), 113 | ); 114 | }); 115 | 116 | it('works with simple array and non-null list type', (): void => { 117 | expect( 118 | validateArrayOrValue(mockValidate)( 119 | array, 120 | GraphQLNonNullIntList, 121 | container, 122 | context, 123 | resolverInfo, 124 | resolverSource, 125 | resolverArguments, 126 | ), 127 | ).toEqual(array); 128 | expect(mockValidate).toBeCalledTimes(array.length); 129 | array.forEach(item => 130 | expect(mockValidate).toBeCalledWith( 131 | item, 132 | GraphQLInt, 133 | container, 134 | context, 135 | resolverInfo, 136 | resolverSource, 137 | resolverArguments, 138 | ), 139 | ); 140 | }); 141 | 142 | it('works with simple array and non-null list of non-null type', (): void => { 143 | expect( 144 | validateArrayOrValue(mockValidate)( 145 | array, 146 | GraphQLNonNullIntListNonNull, 147 | container, 148 | context, 149 | resolverInfo, 150 | resolverSource, 151 | resolverArguments, 152 | ), 153 | ).toEqual(array); 154 | expect(mockValidate).toBeCalledTimes(array.length); 155 | array.forEach(item => 156 | expect(mockValidate).toBeCalledWith( 157 | item, 158 | GraphQLNonNullInt, 159 | container, 160 | context, 161 | resolverInfo, 162 | resolverSource, 163 | resolverArguments, 164 | ), 165 | ); 166 | }); 167 | 168 | it('works array of array and list of list', (): void => { 169 | // equal not be: array is `map()`, result is a new array 170 | expect( 171 | validateArrayOrValue(mockValidate)( 172 | [array], 173 | GraphQLIntListList, 174 | container, 175 | context, 176 | resolverInfo, 177 | resolverSource, 178 | resolverArguments, 179 | ), 180 | ).toEqual([array]); 181 | expect(mockValidate).toBeCalledTimes(array.length); 182 | array.forEach(item => 183 | expect(mockValidate).toBeCalledWith( 184 | item, 185 | GraphQLInt, 186 | container, 187 | context, 188 | resolverInfo, 189 | resolverSource, 190 | resolverArguments, 191 | ), 192 | ); 193 | }); 194 | 195 | it('works with simple array and non-list type', (): void => { 196 | // this is not expected and GraphQL engine should block it, but 197 | // let's handle just in case (getListItemType 'GraphQLList' else condition) 198 | 199 | // equal not be: array is `map()`, result is a new array 200 | expect( 201 | validateArrayOrValue(mockValidate)( 202 | array, 203 | GraphQLInt, 204 | container, 205 | context, 206 | resolverInfo, 207 | resolverSource, 208 | resolverArguments, 209 | ), 210 | ).toEqual(array); 211 | expect(mockValidate).toBeCalledTimes(array.length); 212 | array.forEach(item => 213 | expect(mockValidate).toBeCalledWith( 214 | item, 215 | GraphQLInt, 216 | container, 217 | context, 218 | resolverInfo, 219 | resolverSource, 220 | resolverArguments, 221 | ), 222 | ); 223 | }); 224 | 225 | it('works without validate function', (): void => { 226 | expect(validateArrayOrValue(undefined)).toBe(undefined); 227 | }); 228 | }); 229 | -------------------------------------------------------------------------------- /lib/validateArrayOrValue.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | GraphQLInputType, 3 | GraphQLNamedType, 4 | GraphQLOutputType, 5 | } from 'graphql'; 6 | import { GraphQLList, GraphQLNonNull } from 'graphql'; 7 | 8 | import type { ValidateFunction } from './ValidateDirectiveVisitor.js'; 9 | 10 | const getListItemType = ( 11 | type: GraphQLNamedType | GraphQLInputType | GraphQLOutputType, 12 | ): GraphQLNamedType | GraphQLInputType | GraphQLOutputType => { 13 | let itemType = type; 14 | if (itemType instanceof GraphQLNonNull) itemType = itemType.ofType; 15 | if (itemType instanceof GraphQLList) itemType = itemType.ofType; 16 | return itemType; 17 | }; 18 | 19 | // regular usage: 20 | function validateArrayOrValue( 21 | valueValidator: ValidateFunction, 22 | ): ValidateFunction; 23 | // make it easy to use in cases validator is created and may be undefined 24 | function validateArrayOrValue(valueValidator: undefined): undefined; 25 | function validateArrayOrValue( 26 | valueValidator: undefined | ValidateFunction, 27 | ): undefined | ValidateFunction; 28 | 29 | // function overload cannot be done on arrow-style 30 | // eslint-disable-next-line func-style 31 | function validateArrayOrValue( 32 | valueValidator: ValidateFunction | undefined, 33 | ): ValidateFunction | undefined { 34 | if (!valueValidator) { 35 | return undefined; 36 | } 37 | 38 | const validate: ValidateFunction = ( 39 | value: unknown, 40 | type: GraphQLNamedType | GraphQLOutputType | GraphQLInputType, 41 | ...rest 42 | ): unknown => { 43 | if (Array.isArray(value)) { 44 | const itemType = getListItemType(type); 45 | return value.map(item => validate(item, itemType, ...rest)); 46 | } 47 | return valueValidator(value, type, ...rest); 48 | }; 49 | 50 | return validate; 51 | } 52 | 53 | export default validateArrayOrValue; 54 | -------------------------------------------------------------------------------- /lib/validateThrow.test.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag'; 2 | import type { GraphQLResolveInfo } from 'graphql'; 3 | import { graphql, GraphQLError } from 'graphql'; 4 | import { makeExecutableSchema } from '@graphql-tools/schema'; 5 | 6 | import print from './utils/printer.js'; 7 | import Range from './range.js'; 8 | 9 | interface ArgsTestResolverCtx { 10 | shouldCallResolver: boolean; 11 | shouldContainValidationErrors?: boolean; 12 | values?: Record; 13 | } 14 | interface ArgsOutputTestResolverCtx { 15 | shouldContainOutputValidationErrors?: boolean; 16 | isOptional?: boolean; 17 | } 18 | 19 | describe('validate THROW policy', () => { 20 | const mockResolver = jest.fn( 21 | ( 22 | _parent: unknown, 23 | _args: Record, 24 | _ctx: unknown, 25 | _info: GraphQLResolveInfo & { validationErrors?: unknown[] }, 26 | ): unknown => true, 27 | ); 28 | const outputMockResolver = ( 29 | parent: unknown, 30 | args: { arg: number | null }, 31 | ctx: unknown, 32 | info: GraphQLResolveInfo & { validationErrors?: unknown[] }, 33 | ): number | null => { 34 | mockResolver(parent, args, ctx, info); 35 | return args.arg; 36 | }; 37 | beforeEach(() => { 38 | mockResolver.mockClear(); 39 | }); 40 | const schema = new Range().applyToSchema( 41 | makeExecutableSchema({ 42 | resolvers: { 43 | Query: { 44 | argTest: mockResolver, 45 | inputTest: mockResolver, 46 | optionalOutputTest: outputMockResolver, 47 | outputTest: outputMockResolver, 48 | }, 49 | }, 50 | typeDefs: [ 51 | ...Range.getTypeDefs(), 52 | ...Range.getMissingCommonTypeDefs(), 53 | gql` 54 | input ThirdInput { 55 | n: Int @range(max: 200, policy: THROW) 56 | } 57 | input SecondInput { 58 | thirdInput: ThirdInput 59 | numbersThrow: [Int!] @range(max: 100, policy: THROW) 60 | numbers: [Int] @range(max: 200) 61 | } 62 | input FirstInput { 63 | n: Int @range(max: 0, policy: THROW) 64 | secondInput: SecondInput 65 | } 66 | type Query { 67 | argTest( 68 | n: Int @range(policy: THROW, max: 2) 69 | n2: Int @range(policy: RESOLVER, max: 10) 70 | ): Boolean 71 | inputTest(arg: FirstInput): Boolean 72 | outputTest(arg: Int!): Int! @range(max: 200, policy: THROW) 73 | optionalOutputTest(arg: Int): Int @range(max: 200, policy: THROW) 74 | } 75 | `, 76 | ], 77 | }), 78 | ); 79 | const doTest = async ( 80 | query: string, 81 | resolverName: string, 82 | variableValues: Record, 83 | { 84 | shouldCallResolver, 85 | values, 86 | shouldContainValidationErrors, 87 | }: ArgsTestResolverCtx, 88 | expectedErrors?: Error[], 89 | ): Promise => { 90 | const { data, errors } = await graphql({ 91 | schema, 92 | source: query, 93 | variableValues, 94 | }); 95 | expect(mockResolver.mock.calls.length).toBe(shouldCallResolver ? 1 : 0); 96 | if (shouldCallResolver) { 97 | const [call] = mockResolver.mock.calls; 98 | expect(call[1]).toEqual(values); 99 | if (shouldContainValidationErrors) { 100 | expect(call[3].validationErrors).toBeTruthy(); 101 | } else { 102 | expect(call[3].validationErrors).toBeFalsy(); 103 | } 104 | expect(data && data[resolverName]).toBeTruthy(); 105 | } 106 | if (!expectedErrors) { 107 | expect(errors).toBeFalsy(); 108 | } else { 109 | expect(errors).toEqual(expectedErrors); 110 | expect(data).toEqual({ [resolverName]: null }); 111 | } 112 | }; 113 | const doOutputTest = async ( 114 | query: string, 115 | resolverName: string, 116 | variableValues: Record, 117 | { 118 | isOptional, 119 | shouldContainOutputValidationErrors, 120 | }: ArgsOutputTestResolverCtx, 121 | expectedErrors?: Error[], 122 | ): Promise => { 123 | const { data, errors } = await graphql({ 124 | schema, 125 | source: query, 126 | variableValues, 127 | }); 128 | expect(mockResolver.mock.calls.length).toBe(1); 129 | if (shouldContainOutputValidationErrors) { 130 | expect(data && data[resolverName]).toBeFalsy(); 131 | } else if (!isOptional) { 132 | expect(data && data[resolverName]).toBeTruthy(); 133 | } 134 | if (!expectedErrors) { 135 | expect(errors).toBeFalsy(); 136 | } else { 137 | expect(errors).toEqual(expectedErrors); 138 | if (!isOptional) { 139 | expect(data && data[resolverName]).toEqual(null); 140 | } 141 | } 142 | }; 143 | describe('Validate throw in inputs', () => { 144 | const executeInputTests = doTest.bind( 145 | null, 146 | print(gql` 147 | query InputTest($arg: FirstInput) { 148 | inputTest(arg: $arg) 149 | } 150 | `), 151 | 'inputTest', 152 | ); 153 | it('Should throw if n on FirstInput is invalid', () => 154 | executeInputTests( 155 | { arg: { n: 2 } }, 156 | { 157 | shouldCallResolver: false, 158 | }, 159 | [new GraphQLError('More than 0')], 160 | )); 161 | it('Should throw if numbersThrow on SecondInput is invalid', () => 162 | executeInputTests( 163 | { arg: { secondInput: { numbersThrow: [1, 2, 101] } } }, 164 | { 165 | shouldCallResolver: false, 166 | }, 167 | [new GraphQLError('More than 100')], 168 | )); 169 | it('Should throw if both array inputs on SecondInput are invalid', () => 170 | executeInputTests( 171 | { 172 | arg: { secondInput: { numbers: [10000], numbersThrow: [1, 2, 101] } }, 173 | }, 174 | { 175 | shouldCallResolver: false, 176 | }, 177 | [new GraphQLError('More than 100')], 178 | )); 179 | it('Should not throw if numbers on SecondInput is valid', () => 180 | executeInputTests( 181 | { arg: { secondInput: { numbers: [0, 2, 3], numbersThrow: [1, 2] } } }, 182 | { 183 | shouldCallResolver: true, 184 | shouldContainValidationErrors: false, 185 | values: { 186 | arg: { secondInput: { numbers: [0, 2, 3], numbersThrow: [1, 2] } }, 187 | }, 188 | }, 189 | )); 190 | it('Should not throw if numbersThrow on SecondInput is null', () => 191 | executeInputTests( 192 | { 193 | arg: { 194 | secondInput: { numbers: [0, 2, 3], numbersThrow: null }, 195 | }, 196 | }, 197 | { 198 | shouldCallResolver: true, 199 | shouldContainValidationErrors: false, 200 | values: { 201 | arg: { 202 | secondInput: { numbers: [0, 2, 3], numbersThrow: null }, 203 | }, 204 | }, 205 | }, 206 | )); 207 | it('Should populate validation errors if input is out of range', () => 208 | executeInputTests( 209 | { 210 | arg: { 211 | secondInput: { 212 | numbers: [0, 2, 3, 20000], 213 | numbersThrow: [1, 2, 100], 214 | thirdInput: { 215 | n: 2, 216 | }, 217 | }, 218 | }, 219 | }, 220 | { 221 | shouldCallResolver: true, 222 | shouldContainValidationErrors: true, 223 | values: { 224 | arg: { 225 | secondInput: { 226 | numbers: null, 227 | numbersThrow: [1, 2, 100], 228 | thirdInput: { 229 | n: 2, 230 | }, 231 | }, 232 | }, 233 | }, 234 | }, 235 | )); 236 | it('Should populate validation errors if input is out of range', () => 237 | executeInputTests( 238 | { 239 | arg: { 240 | secondInput: { 241 | numbers: [0, 2, 3, 20000], 242 | numbersThrow: [1, 2, 100], 243 | thirdInput: { 244 | n: 20000, 245 | }, 246 | }, 247 | }, 248 | }, 249 | { 250 | shouldCallResolver: false, 251 | }, 252 | [new GraphQLError('More than 200')], 253 | )); 254 | }); 255 | describe('Validate throw in simple arguments', () => { 256 | const executeSimpleArgumentsTests = doTest.bind( 257 | null, 258 | print(gql` 259 | query ArgTest($n: Int, $n2: Int) { 260 | argTest(n: $n, n2: $n2) 261 | } 262 | `), 263 | 'argTest', 264 | ); 265 | it('Should if validation is ok', () => 266 | executeSimpleArgumentsTests( 267 | { n: 0, n2: 1 }, 268 | { 269 | shouldCallResolver: true, 270 | shouldContainValidationErrors: false, 271 | values: { n: 0, n2: 1 }, 272 | }, 273 | )); 274 | it('Should throw and not call resolver', () => 275 | executeSimpleArgumentsTests( 276 | { n: 200, n2: 1 }, 277 | { 278 | shouldCallResolver: false, 279 | }, 280 | [new GraphQLError('More than 2')], 281 | )); 282 | it('Should call resolver and not throw', () => 283 | executeSimpleArgumentsTests( 284 | { n: 0, n2: 400 }, 285 | { 286 | shouldCallResolver: true, 287 | shouldContainValidationErrors: true, 288 | values: { n: 0, n2: null }, 289 | }, 290 | )); 291 | it('Should throw if both validations fail', () => 292 | executeSimpleArgumentsTests( 293 | { n: 200, n2: 400 }, 294 | { 295 | shouldCallResolver: false, 296 | }, 297 | [new GraphQLError('More than 2')], 298 | )); 299 | }); 300 | describe('Validate throw in outputs', () => { 301 | const executeOutputTests = doOutputTest.bind( 302 | null, 303 | print(gql` 304 | query OutputTest($arg: Int!) { 305 | outputTest(arg: $arg) 306 | } 307 | `), 308 | 'outputTest', 309 | ); 310 | const executeOptionalOutputTests = doOutputTest.bind( 311 | null, 312 | print(gql` 313 | query OptinalOutputTest($arg: Int) { 314 | optionalOutputTest(arg: $arg) 315 | } 316 | `), 317 | 'optionalOutputTest', 318 | ); 319 | 320 | it('Should throw if output value is invalid', () => 321 | executeOutputTests( 322 | { arg: 300 }, 323 | { 324 | shouldContainOutputValidationErrors: true, 325 | }, 326 | [new GraphQLError('More than 200')], 327 | )); 328 | it('Should not throw if output value is valid', () => 329 | executeOutputTests({ arg: 200 }, {})); 330 | 331 | it('Should throw if optional output value is invalid', () => 332 | executeOptionalOutputTests( 333 | { arg: 300 }, 334 | { 335 | isOptional: true, 336 | shouldContainOutputValidationErrors: true, 337 | }, 338 | [new GraphQLError('More than 200')], 339 | )); 340 | it('Should not throw if optional output value is valid', () => 341 | executeOptionalOutputTests( 342 | { arg: 200 }, 343 | { 344 | isOptional: true, 345 | }, 346 | )); 347 | it('Should not throw if optional output value is null or undefined', () => 348 | executeOptionalOutputTests( 349 | {}, 350 | { 351 | isOptional: true, 352 | }, 353 | )); 354 | }); 355 | }); 356 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@profusion/apollo-validation-directives", 3 | "version": "4.1.3", 4 | "description": "GraphQL directives to implement field validations in Apollo Server", 5 | "author": "Gustavo Sverzut Barbieri ", 6 | "license": "MIT", 7 | "repository": "https://github.com/profusion/apollo-validation-directives", 8 | "lint-staged": { 9 | "*.{ts, js}": [ 10 | "eslint", 11 | "jest --bail --findRelatedTests" 12 | ] 13 | }, 14 | "files": [ 15 | "build" 16 | ], 17 | "type": "module", 18 | "types": "./build/types/index.d.ts", 19 | "main": "./build/cjs/index.js", 20 | "exports": { 21 | ".": { 22 | "types": "./build/types/index.d.ts", 23 | "import": "./build/esm/index.js", 24 | "require": "./build/cjs/index.js" 25 | }, 26 | "./hasPermissions": { 27 | "types": "./build/types/hasPermissions.d.ts", 28 | "import": "./build/esm/hasPermissions.js", 29 | "require": "./build/cjs/hasPermissions.js" 30 | }, 31 | "./auth": { 32 | "types": "./build/types/auth.d.ts", 33 | "import": "./build/esm/auth.js", 34 | "require": "./build/cjs/auth.js" 35 | }, 36 | "./capitalize": { 37 | "types": "./build/types/capitalize.d.ts", 38 | "import": "./build/esm/capitalize.js", 39 | "require": "./build/cjs/capitalize.js" 40 | }, 41 | "./cleanupPattern": { 42 | "types": "./build/types/cleanupPattern.d.ts", 43 | "import": "./build/esm/cleanupPattern.js", 44 | "require": "./build/cjs/cleanupPattern.js" 45 | }, 46 | "./foreignNodeId": { 47 | "types": "./build/types/foreignNodeId.d.ts", 48 | "import": "./build/esm/foreignNodeId.js", 49 | "require": "./build/cjs/foreignNodeId.js" 50 | }, 51 | "./listLength": { 52 | "types": "./build/types/listLength.d.ts", 53 | "import": "./build/esm/listLength.js", 54 | "require": "./build/cjs/listLength.js" 55 | }, 56 | "./pattern": { 57 | "types": "./build/types/pattern.d.ts", 58 | "import": "./build/esm/pattern.js", 59 | "require": "./build/cjs/pattern.js" 60 | }, 61 | "./range": { 62 | "types": "./build/types/range.d.ts", 63 | "import": "./build/esm/range.js", 64 | "require": "./build/cjs/range.js" 65 | }, 66 | "./selfNodeId": { 67 | "types": "./build/types/selfNodeId.d.ts", 68 | "import": "./build/esm/selfNodeId.js", 69 | "require": "./build/cjs/selfNodeId.js" 70 | }, 71 | "./stringLength": { 72 | "types": "./build/types/stringLength.d.ts", 73 | "import": "./build/esm/stringLength.js", 74 | "require": "./build/cjs/stringLength.js" 75 | }, 76 | "./trim": { 77 | "types": "./build/types/trim.d.ts", 78 | "import": "./build/esm/trim.js", 79 | "require": "./build/cjs/trim.js" 80 | }, 81 | "./applyDirectivesToSchema": { 82 | "types": "./build/types/utils/applyDirectivesToSchema.d.ts", 83 | "import": "./build/esm/utils/applyDirectivesToSchema.js", 84 | "require": "./build/cjs/utils/applyDirectivesToSchema.js" 85 | } 86 | }, 87 | "scripts": { 88 | "example:value-validation": "ts-node examples/value-validation-directives.ts", 89 | "example:access-control": "ts-node examples/access-control-directives.ts", 90 | "example:federation": "ts-node examples/federation.ts", 91 | "install-peers": "install-peers", 92 | "check-types": "tsc --noEmit", 93 | "run-lint": "eslint --max-warnings=0 --ext .ts lib examples", 94 | "lint": "run-s check-types run-lint", 95 | "build:cjs": "tsc -p tsconfig.cjs.json", 96 | "build:esm": "tsc", 97 | "build": "run-p build:* && sh ./.scripts/patch-cjs-package.sh", 98 | "test": "jest", 99 | "prepare": "husky install", 100 | "prepublishOnly": "yarn run build" 101 | }, 102 | "devDependencies": { 103 | "@apollo/gateway": "^2.5.1", 104 | "@apollo/server": "^4.9.0", 105 | "@apollo/subgraph": "^2.5.1", 106 | "@commitlint/cli": "^17.7.1", 107 | "@commitlint/config-angular": "^17.7.0", 108 | "@types/jest": "^29.5.4", 109 | "@types/lodash.isequal": "^4.5.6", 110 | "@types/node": "18.17.1", 111 | "@typescript-eslint/eslint-plugin": "^6.5.0", 112 | "@typescript-eslint/parser": "^6.5.0", 113 | "eslint": "^8.48.0", 114 | "eslint-config-airbnb-base": "^15.0.0", 115 | "eslint-config-prettier": "^9.0.0", 116 | "eslint-import-resolver-typescript": "^3.6.0", 117 | "eslint-plugin-import": "^2.28.1", 118 | "eslint-plugin-prettier": "^5.0.0", 119 | "graphql": "^16.7.1", 120 | "husky": "^8.0.1", 121 | "install-peers-cli": "^2.2.0", 122 | "jest": "^29.6.4", 123 | "lint-staged": "^13.0.3", 124 | "npm-run-all": "^4.1.5", 125 | "prettier": "^3.0.3", 126 | "ts-jest": "^29.1.1", 127 | "ts-node": "^10.9.1", 128 | "typescript": "^5.2.2" 129 | }, 130 | "peerDependencies": { 131 | "graphql": "^16.7.1" 132 | }, 133 | "dependencies": { 134 | "@graphql-tools/schema": "^10.0.0", 135 | "@graphql-tools/utils": "^10.0.4", 136 | "graphql-tag": "^2.12.6", 137 | "lodash.isequal": "^4.5.0" 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "declarationDir": null, 6 | "module": "commonjs", 7 | "moduleResolution": "Node", 8 | "outDir": "build/cjs" 9 | }, 10 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "moduleResolution": "Node16", 5 | "module": "Node16", 6 | "lib": ["es2017", "es7", "es6"], 7 | "allowJs": true, 8 | "strict": true, 9 | "isolatedModules": true, 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "noImplicitAny": true, 13 | "typeRoots": [ 14 | "./@types", 15 | "./node_modules/@types" 16 | ], 17 | "emitDecoratorMetadata": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false, 20 | "declaration": true, 21 | "declarationDir": "./build/types", 22 | "baseUrl": ".", 23 | "outDir": "./build/esm" 24 | }, 25 | "include": [ 26 | "lib", 27 | ], 28 | "exclude": [ 29 | "examples", 30 | "node_modules", 31 | "build" 32 | ] 33 | } 34 | --------------------------------------------------------------------------------