├── .github └── workflows │ └── quality.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── README.md ├── babel.config.js ├── dist └── index.js ├── index.spec.js ├── package.json ├── src └── index.js └── yarn.lock /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Quality 2 | 3 | on: [push] 4 | 5 | jobs: 6 | quality: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout project 10 | uses: actions/checkout@v1 11 | - name: Caching 12 | uses: actions/cache@v1 13 | with: 14 | path: ~/.cache 15 | key: cache-${{ hashFiles('**/yarn.lock') }} 16 | restore-keys: | 17 | cache-${{ hashFiles('**/yarn.lock') }} 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: '13.12.x' 22 | - name: Install dependencies 23 | run: yarn install --frozen-lockfile 24 | - name: Lint & Tests 25 | run: yarn ci 26 | env: 27 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | coverage -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | ** 2 | 3 | !dist 4 | !package.json 5 | !README.md 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": false, 4 | "singleQuote": true, 5 | "arrowParens": "always", 6 | "endOfLine": "lf" 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-directives-middlewares 2 | 3 | > GraphQL directives as middlewares 4 | 5 | [![npm bundle size](https://img.shields.io/bundlephobia/minzip/graphql-directives-middlewares)](https://bundlephobia.com/result?p=graphql-directives-middlewares) 6 | [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/unirakun/graphql-directives-middlewares/Quality)](https://github.com/unirakun/graphql-directives-middlewares/actions?query=branch%3Amaster) [![NPM Version](https://badge.fury.io/js/graphql-directives-middlewares.svg)](https://www.npmjs.com/package/graphql-directives-middlewares) [![Coveralls github](https://img.shields.io/coveralls/github/unirakun/graphql-directives-middlewares.svg)](https://coveralls.io/github/unirakun/graphql-directives-middlewares) 7 | 8 | ## install 9 | 10 | `yarn add graphql-directives-middlewares` 11 | 12 | > You need to have `graphql` and `graphql-tools` installed on your project. 13 | 14 | ## why 15 | 16 | We create this library because we needed our directives to be sorted at runtime. 17 | We were on the case we needed to execute our `@auth` directive BEFORE our `@api` directive take place. 18 | 19 | With `graphql-directives-middlewares` you just have to declare, in your schema, the directives in order you want them to take place. 20 | 21 | With this declaration: 22 | 23 | ```gql 24 | type Query { 25 | users: [User] @auth(requires: ADMIN) @api(name: "users") 26 | } 27 | ``` 28 | 29 | Your `@auth` directive will be called BEFORE the `@api` one. 30 | 31 | If you invert `@auth` and `@api`, then `@api` directive will be called BEFORE the `@auth`, without changing your implementation! 32 | 33 | ## API 34 | 35 | ### createVisitFieldDefinition 36 | 37 | `createVisitFieldDefinition(name: string, impl: (params, next) -> (...args))` 38 | 39 | - `name`: the directive name you use in your `gql` schema 40 | - `impl`: is a function that take `params` and `next` and return a function that is your graphql custom resolver 41 | - `params` are your directives arguments 42 | - `next` is the next resolver to call, like in a middleware engine 43 | 44 | ### createVisitObject 45 | 46 | `createVisitObject(name: string, impl: (params, next) -> (...args))` 47 | 48 | - `name`: the directive name you use in your `gql` schema 49 | - `impl`: is a function that take `params` and `next` and return a function that is your graphql custom resolver 50 | - `params` are your directives arguments 51 | - `next` is the next resolver to call, like in a middleware engine 52 | 53 | ## usage 54 | 55 | Example with a `@auth` directive 56 | 57 | 1. define your directive in the schema 58 | 59 | ```js 60 | export default ` 61 | enum Role { 62 | ADMIN 63 | USER 64 | VIEWER 65 | } 66 | 67 | directive @auth(requires: Role = ADMIN) on FIELD_DEFINITION 68 | ` 69 | ``` 70 | 71 | 2. create your directive implementation 72 | 73 | ```js 74 | import { GraphQLList } from 'graphql' 75 | import { createVisitFieldDefinition } from 'graphql-directives-middlewares' 76 | 77 | export default createVisitFieldDefinition( 78 | 'auth', 79 | (params, next) => async (...args) => { 80 | const { requires: requiredRole } = params 81 | 82 | if (!requiredRole) { 83 | return next() 84 | } 85 | 86 | const [, , context, definition] = args 87 | const { role } = context 88 | 89 | if (!role.includes(requiredRole)) { 90 | if (definition.returnType instanceof GraphQLList) return [] 91 | throw new Error('Unauthorized') 92 | } 93 | 94 | return next() 95 | }, 96 | ) 97 | ``` 98 | 99 | 3. bind your directives implementation to your schema 100 | 101 | ```js 102 | import { makeExecutableSchema } from 'graphql-tools' 103 | import typeDefs from './types' 104 | import auth from './auth' 105 | 106 | export default () => { 107 | const resolvers = { 108 | Query: { 109 | users: () => [ 110 | { 111 | id: 'fabien-juif-1', 112 | fullName: 'Fabien JUIF', 113 | }, 114 | ], 115 | }, 116 | } 117 | 118 | return makeExecutableSchema({ 119 | typeDefs, 120 | resolvers, 121 | schemaDirectives: { 122 | auth, 123 | }, 124 | }) 125 | } 126 | ``` 127 | 128 | 4. use your directives 129 | 130 | ```js 131 | import { gql } from 'apollo-server' 132 | import user from './user' 133 | 134 | export default gql` 135 | ${user} 136 | 137 | type Query { 138 | users: [User] @auth(requires: ADMIN) 139 | } 140 | ` 141 | ``` 142 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: '10', 8 | }, 9 | }, 10 | ], 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.createVisitObject = exports.createVisitFieldDefinition = void 0; 7 | 8 | var _graphql = require("graphql"); 9 | 10 | var _graphqlTools = require("graphql-tools"); 11 | 12 | /* eslint-disable import/no-extraneous-dependencies, no-underscore-dangle */ 13 | // we can modify graphql inner object with symbol making sure we don't override fields 14 | // right now or in futur releases 15 | const metaKey = Symbol('metaKey'); 16 | 17 | const getResolve = ({ 18 | directiveName, 19 | params, 20 | field, 21 | middleware 22 | }) => { 23 | if (!field[metaKey]) { 24 | field[metaKey] = { 25 | baseResolver: field.resolve || _graphql.defaultFieldResolver, 26 | middlewares: [] 27 | }; 28 | } 29 | 30 | field[metaKey].middlewares.push({ 31 | name: directiveName, 32 | impl: middleware, 33 | params 34 | }); 35 | return (...args) => { 36 | let calls = -1; // next function is recursive, it give the resolve args to each middleware 37 | 38 | const next = () => { 39 | calls += 1; // at the end we call the real resolver 40 | 41 | if (calls === field[metaKey].middlewares.length) { 42 | return field[metaKey].baseResolver(...args); 43 | } // take the next middleware and try to call it 44 | 45 | 46 | const nextMiddleware = field[metaKey].middlewares[calls]; 47 | 48 | if (!nextMiddleware) { 49 | throw new Error('No more middleware but no base resolver found!'); 50 | } 51 | 52 | return nextMiddleware.impl(nextMiddleware.params, next)(...args); 53 | }; 54 | 55 | return next(); 56 | }; 57 | }; 58 | 59 | const createVisitFieldDefinition = (directiveName, middleware) => class extends _graphqlTools.SchemaDirectiveVisitor { 60 | /* eslint-disable class-methods-use-this */ 61 | visitFieldDefinition(field) { 62 | const { 63 | args: params 64 | } = this; 65 | field.resolve = getResolve({ 66 | field, 67 | directiveName, 68 | params, 69 | middleware 70 | }); 71 | } 72 | 73 | }; 74 | 75 | exports.createVisitFieldDefinition = createVisitFieldDefinition; 76 | 77 | const createVisitObject = (directiveName, middleware) => class extends _graphqlTools.SchemaDirectiveVisitor { 78 | visitObject(type) { 79 | const fields = type.getFields(); 80 | const { 81 | args: params 82 | } = this; 83 | Object.values(fields).forEach(field => { 84 | field.resolve = getResolve({ 85 | field, 86 | directiveName, 87 | params, 88 | middleware 89 | }); 90 | }); 91 | } 92 | 93 | }; 94 | 95 | exports.createVisitObject = createVisitObject; -------------------------------------------------------------------------------- /index.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import gql from 'graphql-tag' 3 | import { makeExecutableSchema } from 'graphql-tools' 4 | import { graphql, GraphQLError } from 'graphql' 5 | import { createVisitObject, createVisitFieldDefinition } from './dist/index' 6 | 7 | describe('graphql-directives-middlewares', () => { 8 | it('should add a "visite field" directive', async () => { 9 | const impl = jest.fn() 10 | const middleware = jest.fn((params, next) => (...args) => { 11 | impl(...args) 12 | return next() 13 | }) 14 | 15 | // directive definition & implementation 16 | const firstImpl = createVisitFieldDefinition('first', middleware) 17 | 18 | // create gql schema 19 | const schema = makeExecutableSchema({ 20 | typeDefs: gql` 21 | directive @first on FIELD_DEFINITION 22 | 23 | type Query { 24 | list: [String] @first 25 | } 26 | `, 27 | resolvers: { 28 | Query: { 29 | list: () => ['john', 'smith'], 30 | }, 31 | }, 32 | schemaDirectives: { 33 | first: firstImpl, 34 | }, 35 | }) 36 | 37 | // run & asserts 38 | const res = await graphql( 39 | schema, 40 | ` 41 | { 42 | list 43 | } 44 | `, 45 | ) 46 | 47 | expect(impl).toHaveBeenCalledTimes(1) 48 | expect(impl.mock.calls[0][3]).toEqual( 49 | expect.objectContaining({ 50 | fieldName: 'list', 51 | }), 52 | ) 53 | expect(res.data).toEqual({ list: ['john', 'smith'] }) 54 | }) 55 | 56 | it('should pass variables', async () => { 57 | const impl = jest.fn() 58 | const middleware = jest.fn((params, next) => (...args) => { 59 | impl(...args) 60 | return next() 61 | }) 62 | 63 | // directive definition & implementation 64 | const firstImpl = createVisitFieldDefinition('first', middleware) 65 | 66 | // create gql schema 67 | const schema = makeExecutableSchema({ 68 | typeDefs: gql` 69 | directive @first(name: String) on FIELD_DEFINITION 70 | 71 | type Query { 72 | list: [String] @first(name: "a param") 73 | } 74 | `, 75 | resolvers: { 76 | Query: { 77 | list: () => ['john', 'smith'], 78 | }, 79 | }, 80 | schemaDirectives: { 81 | first: firstImpl, 82 | }, 83 | }) 84 | 85 | // run & asserts 86 | const res = await graphql( 87 | schema, 88 | ` 89 | { 90 | list 91 | } 92 | `, 93 | ) 94 | 95 | expect(middleware).toHaveBeenCalledTimes(1) 96 | expect(middleware.mock.calls[0][0]).toEqual({ name: 'a param' }) 97 | expect(impl).toHaveBeenCalledTimes(1) 98 | expect(impl.mock.calls[0][3]).toEqual( 99 | expect.objectContaining({ 100 | fieldName: 'list', 101 | }), 102 | ) 103 | expect(res.data).toEqual({ list: ['john', 'smith'] }) 104 | }) 105 | 106 | it('should call middleware in definition order (first -> second)', async () => { 107 | // directive definition & implementation 108 | const called = [] 109 | // - first 110 | const impl = jest.fn(() => { 111 | called.push('first') 112 | }) 113 | const middleware = jest.fn((params, next) => (...args) => { 114 | impl(...args) 115 | return next() 116 | }) 117 | const firstImpl = createVisitFieldDefinition('first', middleware) 118 | // - second 119 | const impl2 = jest.fn(() => { 120 | called.push('second') 121 | }) 122 | const middleware2 = jest.fn((params, next) => (...args) => { 123 | impl2(...args) 124 | return next() 125 | }) 126 | const secondImpl = createVisitFieldDefinition('second', middleware2) 127 | 128 | // create gql schema 129 | const schema = makeExecutableSchema({ 130 | typeDefs: gql` 131 | directive @first(name: String) on FIELD_DEFINITION 132 | directive @second(name: String) on FIELD_DEFINITION 133 | 134 | type Query { 135 | list: [String] @first(name: "a param") @second(name: "an other param") 136 | } 137 | `, 138 | resolvers: { 139 | Query: { 140 | list: () => ['john', 'smith'], 141 | }, 142 | }, 143 | schemaDirectives: { 144 | first: firstImpl, 145 | second: secondImpl, 146 | }, 147 | }) 148 | 149 | // run & asserts 150 | const res = await graphql( 151 | schema, 152 | ` 153 | { 154 | list 155 | } 156 | `, 157 | ) 158 | 159 | expect(middleware).toHaveBeenCalledTimes(1) 160 | expect(middleware2).toHaveBeenCalledTimes(1) 161 | expect(middleware.mock.calls[0][0]).toEqual({ name: 'a param' }) 162 | expect(middleware2.mock.calls[0][0]).toEqual({ name: 'an other param' }) 163 | expect(impl).toHaveBeenCalledTimes(1) 164 | expect(impl2).toHaveBeenCalledTimes(1) 165 | expect(impl.mock.calls[0][3]).toEqual( 166 | expect.objectContaining({ 167 | fieldName: 'list', 168 | }), 169 | ) 170 | expect(impl2.mock.calls[0][3]).toEqual( 171 | expect.objectContaining({ 172 | fieldName: 'list', 173 | }), 174 | ) 175 | expect(res.data).toEqual({ list: ['john', 'smith'] }) 176 | expect(called).toEqual(['first', 'second']) 177 | }) 178 | 179 | it('should call middleware in definition order (second -> first)', async () => { 180 | // directive definition & implementation 181 | const called = [] 182 | // - first 183 | const impl = jest.fn(() => { 184 | called.push('first') 185 | }) 186 | const middleware = jest.fn((params, next) => (...args) => { 187 | impl(...args) 188 | return next() 189 | }) 190 | const firstImpl = createVisitFieldDefinition('first', middleware) 191 | // - second 192 | const impl2 = jest.fn(() => { 193 | called.push('second') 194 | }) 195 | const middleware2 = jest.fn((params, next) => (...args) => { 196 | impl2(...args) 197 | return next() 198 | }) 199 | const secondImpl = createVisitFieldDefinition('second', middleware2) 200 | 201 | // create gql schema 202 | const schema = makeExecutableSchema({ 203 | typeDefs: gql` 204 | directive @first(name: String) on FIELD_DEFINITION 205 | directive @second(name: String) on FIELD_DEFINITION 206 | 207 | type Query { 208 | list: [String] @second(name: "an other param") @first(name: "a param") 209 | } 210 | `, 211 | resolvers: { 212 | Query: { 213 | list: () => ['john', 'smith'], 214 | }, 215 | }, 216 | schemaDirectives: { 217 | first: firstImpl, 218 | second: secondImpl, 219 | }, 220 | }) 221 | 222 | // run & asserts 223 | const res = await graphql( 224 | schema, 225 | ` 226 | { 227 | list 228 | } 229 | `, 230 | ) 231 | 232 | expect(middleware).toHaveBeenCalledTimes(1) 233 | expect(middleware2).toHaveBeenCalledTimes(1) 234 | expect(middleware.mock.calls[0][0]).toEqual({ name: 'a param' }) 235 | expect(middleware2.mock.calls[0][0]).toEqual({ name: 'an other param' }) 236 | expect(impl).toHaveBeenCalledTimes(1) 237 | expect(impl2).toHaveBeenCalledTimes(1) 238 | expect(impl.mock.calls[0][3]).toEqual( 239 | expect.objectContaining({ 240 | fieldName: 'list', 241 | }), 242 | ) 243 | expect(impl2.mock.calls[0][3]).toEqual( 244 | expect.objectContaining({ 245 | fieldName: 'list', 246 | }), 247 | ) 248 | expect(res.data).toEqual({ list: ['john', 'smith'] }) 249 | expect(called).toEqual(['second', 'first']) 250 | }) 251 | 252 | it('should create a visit object directive', async () => { 253 | const impl = jest.fn() 254 | const middleware = jest.fn((params, next) => (...args) => { 255 | impl(...args) 256 | return next() 257 | }) 258 | 259 | // directive definition & implementation 260 | const firstImpl = createVisitObject('first', middleware) 261 | 262 | // create gql schema 263 | const schema = makeExecutableSchema({ 264 | typeDefs: gql` 265 | directive @first on OBJECT 266 | 267 | type Query @first { 268 | list: [String] 269 | } 270 | `, 271 | resolvers: { 272 | Query: { 273 | list: () => ['john', 'smith'], 274 | }, 275 | }, 276 | schemaDirectives: { 277 | first: firstImpl, 278 | }, 279 | }) 280 | 281 | // run & asserts 282 | const res = await graphql( 283 | schema, 284 | ` 285 | { 286 | list 287 | } 288 | `, 289 | {}, 290 | { context: true }, 291 | ) 292 | 293 | expect(impl).toHaveBeenCalledTimes(1) 294 | expect(impl.mock.calls[0][3]).toEqual( 295 | expect.objectContaining({ 296 | fieldName: 'list', 297 | }), 298 | ) 299 | expect(res.data).toEqual({ list: ['john', 'smith'] }) 300 | }) 301 | 302 | it('should calls visit object directive in order (first -> second)', async () => { 303 | // directive definition & implementation 304 | const called = [] 305 | // - first 306 | const impl = jest.fn() 307 | const middleware = jest.fn((params, next) => (...args) => { 308 | called.push('first') 309 | impl(...args) 310 | return next() 311 | }) 312 | const firstImpl = createVisitObject('first', middleware) 313 | // -- second 314 | const impl2 = jest.fn() 315 | const middleware2 = jest.fn((params, next) => (...args) => { 316 | called.push('second') 317 | impl2(...args) 318 | return next() 319 | }) 320 | const secondImpl = createVisitObject('second', middleware2) 321 | 322 | // create gql schema 323 | const schema = makeExecutableSchema({ 324 | typeDefs: gql` 325 | directive @first on OBJECT 326 | directive @second on OBJECT 327 | 328 | type Query @first @second { 329 | list: [String] 330 | } 331 | `, 332 | resolvers: { 333 | Query: { 334 | list: () => ['john', 'smith'], 335 | }, 336 | }, 337 | schemaDirectives: { 338 | first: firstImpl, 339 | second: secondImpl, 340 | }, 341 | }) 342 | 343 | // run & asserts 344 | const res = await graphql( 345 | schema, 346 | ` 347 | { 348 | list 349 | } 350 | `, 351 | {}, 352 | { context: true }, 353 | ) 354 | 355 | expect(called).toEqual(['first', 'second']) 356 | expect(impl).toHaveBeenCalledTimes(1) 357 | expect(impl.mock.calls[0][3]).toEqual( 358 | expect.objectContaining({ 359 | fieldName: 'list', 360 | }), 361 | ) 362 | expect(impl2).toHaveBeenCalledTimes(1) 363 | expect(impl2.mock.calls[0][3]).toEqual( 364 | expect.objectContaining({ 365 | fieldName: 'list', 366 | }), 367 | ) 368 | expect(res.data).toEqual({ list: ['john', 'smith'] }) 369 | }) 370 | 371 | it('should calls visit object directive in order (second -> first)', async () => { 372 | // directive definition & implementation 373 | const called = [] 374 | // - first 375 | const impl = jest.fn() 376 | const middleware = jest.fn((params, next) => (...args) => { 377 | called.push('first') 378 | impl(...args) 379 | return next() 380 | }) 381 | const firstImpl = createVisitObject('first', middleware) 382 | // -- second 383 | const impl2 = jest.fn() 384 | const middleware2 = jest.fn((params, next) => (...args) => { 385 | called.push('second') 386 | impl2(...args) 387 | return next() 388 | }) 389 | const secondImpl = createVisitObject('second', middleware2) 390 | 391 | // create gql schema 392 | const schema = makeExecutableSchema({ 393 | typeDefs: gql` 394 | directive @first on OBJECT 395 | directive @second on OBJECT 396 | 397 | type Query @second @first { 398 | list: [String] 399 | } 400 | `, 401 | resolvers: { 402 | Query: { 403 | list: () => ['john', 'smith'], 404 | }, 405 | }, 406 | schemaDirectives: { 407 | first: firstImpl, 408 | second: secondImpl, 409 | }, 410 | }) 411 | 412 | // run & asserts 413 | const res = await graphql( 414 | schema, 415 | ` 416 | { 417 | list 418 | } 419 | `, 420 | {}, 421 | { context: true }, 422 | ) 423 | 424 | expect(called).toEqual(['second', 'first']) 425 | expect(impl).toHaveBeenCalledTimes(1) 426 | expect(impl.mock.calls[0][3]).toEqual( 427 | expect.objectContaining({ 428 | fieldName: 'list', 429 | }), 430 | ) 431 | expect(impl2).toHaveBeenCalledTimes(1) 432 | expect(impl2.mock.calls[0][3]).toEqual( 433 | expect.objectContaining({ 434 | fieldName: 'list', 435 | }), 436 | ) 437 | expect(res.data).toEqual({ list: ['john', 'smith'] }) 438 | }) 439 | 440 | it('should call visit object before visit field', async () => { 441 | // directive definition & implementation 442 | const called = [] 443 | // - first 444 | const impl = jest.fn() 445 | const middleware = jest.fn((params, next) => (...args) => { 446 | called.push('first') 447 | impl(...args) 448 | return next() 449 | }) 450 | const firstImpl = createVisitObject('first', middleware) 451 | // -- second 452 | const impl2 = jest.fn() 453 | const middleware2 = jest.fn((params, next) => (...args) => { 454 | called.push('second') 455 | impl2(...args) 456 | return next() 457 | }) 458 | const secondImpl = createVisitFieldDefinition('second', middleware2) 459 | 460 | // create gql schema 461 | const schema = makeExecutableSchema({ 462 | typeDefs: gql` 463 | directive @first on OBJECT 464 | directive @second on FIELD_DEFINITION 465 | 466 | type Query @first { 467 | list: [String] @second 468 | } 469 | `, 470 | resolvers: { 471 | Query: { 472 | list: () => ['john', 'smith'], 473 | }, 474 | }, 475 | schemaDirectives: { 476 | first: firstImpl, 477 | second: secondImpl, 478 | }, 479 | }) 480 | 481 | // run & asserts 482 | const res = await graphql( 483 | schema, 484 | ` 485 | { 486 | list 487 | } 488 | `, 489 | {}, 490 | { context: true }, 491 | ) 492 | 493 | expect(called).toEqual(['first', 'second']) 494 | expect(impl).toHaveBeenCalledTimes(1) 495 | expect(impl.mock.calls[0][3]).toEqual( 496 | expect.objectContaining({ 497 | fieldName: 'list', 498 | }), 499 | ) 500 | expect(impl2).toHaveBeenCalledTimes(1) 501 | expect(impl2.mock.calls[0][3]).toEqual( 502 | expect.objectContaining({ 503 | fieldName: 'list', 504 | }), 505 | ) 506 | expect(res.data).toEqual({ list: ['john', 'smith'] }) 507 | }) 508 | 509 | it('should not visit field if object does not call next', async () => { 510 | // directive definition & implementation 511 | const called = [] 512 | // - first 513 | const impl = jest.fn(() => ['fake']) 514 | const middleware = jest.fn(() => (...args) => { 515 | called.push('first') 516 | return impl(...args) 517 | }) 518 | const firstImpl = createVisitObject('first', middleware) 519 | // -- second 520 | const impl2 = jest.fn() 521 | const middleware2 = jest.fn((params, next) => (...args) => { 522 | called.push('second') 523 | impl2(...args) 524 | return next() 525 | }) 526 | const secondImpl = createVisitFieldDefinition('second', middleware2) 527 | 528 | // create gql schema 529 | const schema = makeExecutableSchema({ 530 | typeDefs: gql` 531 | directive @first on OBJECT 532 | directive @second on FIELD_DEFINITION 533 | 534 | type Query @first { 535 | list: [String] @second 536 | } 537 | `, 538 | resolvers: { 539 | Query: { 540 | list: () => ['john', 'smith'], 541 | }, 542 | }, 543 | schemaDirectives: { 544 | first: firstImpl, 545 | second: secondImpl, 546 | }, 547 | }) 548 | 549 | // run & asserts 550 | const res = await graphql( 551 | schema, 552 | ` 553 | { 554 | list 555 | } 556 | `, 557 | {}, 558 | { context: true }, 559 | ) 560 | 561 | expect(called).toEqual(['first']) 562 | expect(impl).toHaveBeenCalledTimes(1) 563 | expect(impl.mock.calls[0][3]).toEqual( 564 | expect.objectContaining({ 565 | fieldName: 'list', 566 | }), 567 | ) 568 | expect(impl2).toHaveBeenCalledTimes(0) 569 | expect(res.data).toEqual({ list: ['fake'] }) 570 | }) 571 | 572 | it('should process the whole middlewares chain after a previous request is in error caused by a middleware', async () => { 573 | const impl = jest.fn() 574 | const middleware = jest.fn((params, next) => (...args) => { 575 | impl(...args) 576 | return next() 577 | }) 578 | 579 | // directive definition & implementation 580 | const firstImpl = createVisitFieldDefinition('first', middleware) 581 | 582 | // create gql schema 583 | const schema = makeExecutableSchema({ 584 | typeDefs: gql` 585 | directive @first on FIELD_DEFINITION 586 | 587 | type Query { 588 | list: [String] @first 589 | } 590 | `, 591 | resolvers: { 592 | Query: { 593 | list: () => ['john', 'smith'], 594 | }, 595 | }, 596 | schemaDirectives: { 597 | first: firstImpl, 598 | }, 599 | }) 600 | 601 | // run & asserts 602 | // - first run on error (from middleware) 603 | impl.mockImplementationOnce(() => { 604 | throw new Error('an error') 605 | }) 606 | const res1 = await graphql( 607 | schema, 608 | ` 609 | { 610 | list 611 | } 612 | `, 613 | ) 614 | expect(impl).toHaveBeenCalledTimes(1) 615 | expect(res1).toEqual({ 616 | errors: [new GraphQLError('an error')], 617 | data: { 618 | list: null, 619 | }, 620 | }) 621 | 622 | // - second run ok (no error) 623 | const res2 = await graphql( 624 | schema, 625 | ` 626 | { 627 | list 628 | } 629 | `, 630 | ) 631 | expect(impl).toHaveBeenCalledTimes(2) 632 | expect(res2).toEqual({ 633 | data: { 634 | list: ['john', 'smith'], 635 | }, 636 | }) 637 | }) 638 | 639 | it('should process the whole middlewares chain after a previous request is in error caused by the resolver', async () => { 640 | const impl = jest.fn() 641 | const middleware = jest.fn((params, next) => (...args) => { 642 | impl(...args) 643 | return next() 644 | }) 645 | 646 | // directive definition & implementation 647 | const firstImpl = createVisitFieldDefinition('first', middleware) 648 | 649 | // create gql schema 650 | const resolver = jest.fn(() => ['john', 'smith']) 651 | const schema = makeExecutableSchema({ 652 | typeDefs: gql` 653 | directive @first on FIELD_DEFINITION 654 | 655 | type Query { 656 | list: [String] @first 657 | } 658 | `, 659 | resolvers: { 660 | Query: { 661 | list: resolver, 662 | }, 663 | }, 664 | schemaDirectives: { 665 | first: firstImpl, 666 | }, 667 | }) 668 | 669 | // run & asserts 670 | // - first run with leaf resolver error 671 | resolver.mockImplementationOnce(() => { 672 | throw new GraphQLError('resolver error') 673 | }) 674 | const res1 = await graphql( 675 | schema, 676 | ` 677 | { 678 | list 679 | } 680 | `, 681 | ) 682 | expect(impl).toHaveBeenCalledTimes(1) 683 | expect(resolver).toHaveBeenCalledTimes(1) 684 | expect(res1).toEqual({ 685 | errors: [new GraphQLError('resolver error')], 686 | data: { 687 | list: null, 688 | }, 689 | }) 690 | 691 | // - second run ok (no error) 692 | const res2 = await graphql( 693 | schema, 694 | ` 695 | { 696 | list 697 | } 698 | `, 699 | ) 700 | expect(impl).toHaveBeenCalledTimes(2) 701 | expect(resolver).toHaveBeenCalledTimes(2) 702 | expect(res2).toEqual({ 703 | data: { 704 | list: ['john', 'smith'], 705 | }, 706 | }) 707 | }) 708 | }) 709 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-directives-middlewares", 3 | "version": "0.2.2", 4 | "description": "GraphQL directives as middlewares", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "repository": "https://github.com/unirakun/graphql-directives-middlewares", 8 | "author": "Fabien JUIF ", 9 | "license": "MIT", 10 | "private": false, 11 | "engines": { 12 | "node": ">=10" 13 | }, 14 | "scripts": { 15 | "test": "jest", 16 | "lint": "eslint src", 17 | "build": "babel src --source-type module --out-dir dist", 18 | "coverage": "jest --coverage && cat ./coverage/lcov.info | yarn coveralls", 19 | "ci": "run-p lint coverage build" 20 | }, 21 | "devDependencies": { 22 | "@babel/cli": "7.8.4", 23 | "@babel/core": "7.9.6", 24 | "@babel/preset-env": "7.9.6", 25 | "babel-jest": "^26.0.1", 26 | "coveralls": "^3.1.0", 27 | "eslint": "7.0.0", 28 | "eslint-config-airbnb-base": "14.1.0", 29 | "eslint-plugin-import": "2.20.2", 30 | "graphql": "15.0.0", 31 | "graphql-tag": "^2.10.3", 32 | "graphql-tools": "5.0.0", 33 | "jest": "^26.0.1", 34 | "npm-run-all": "4.1.5" 35 | }, 36 | "keywords": [ 37 | "graphql", 38 | "directive", 39 | "directives", 40 | "middleware", 41 | "middlewares", 42 | "preserve", 43 | "order" 44 | ], 45 | "eslintConfig": { 46 | "extends": "airbnb-base", 47 | "ignorePatterns": [ 48 | "dist/**" 49 | ], 50 | "rules": { 51 | "semi": [ 52 | "error", 53 | "never" 54 | ], 55 | "import/prefer-default-export": "off", 56 | "no-param-reassign": "off", 57 | "max-classes-per-file": "off", 58 | "object-curly-newline": "off", 59 | "implicit-arrow-linebreak": "off" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies, no-underscore-dangle */ 2 | import { defaultFieldResolver } from 'graphql' 3 | import { SchemaDirectiveVisitor } from 'graphql-tools' 4 | 5 | // we can modify graphql inner object with symbol making sure we don't override fields 6 | // right now or in futur releases 7 | const metaKey = Symbol('metaKey') 8 | 9 | const getResolve = ({ directiveName, params, field, middleware }) => { 10 | if (!field[metaKey]) { 11 | field[metaKey] = { 12 | baseResolver: field.resolve || defaultFieldResolver, 13 | middlewares: [], 14 | } 15 | } 16 | 17 | field[metaKey].middlewares.push({ 18 | name: directiveName, 19 | impl: middleware, 20 | params, 21 | }) 22 | 23 | return (...args) => { 24 | let calls = -1 25 | 26 | // next function is recursive, it give the resolve args to each middleware 27 | const next = () => { 28 | calls += 1 29 | // at the end we call the real resolver 30 | if (calls === field[metaKey].middlewares.length) { 31 | return field[metaKey].baseResolver(...args) 32 | } 33 | 34 | // take the next middleware and try to call it 35 | const nextMiddleware = field[metaKey].middlewares[calls] 36 | if (!nextMiddleware) { 37 | throw new Error('No more middleware but no base resolver found!') 38 | } 39 | return nextMiddleware.impl(nextMiddleware.params, next)(...args) 40 | } 41 | 42 | return next() 43 | } 44 | } 45 | 46 | export const createVisitFieldDefinition = (directiveName, middleware) => 47 | class extends SchemaDirectiveVisitor { 48 | /* eslint-disable class-methods-use-this */ 49 | visitFieldDefinition(field) { 50 | const { args: params } = this 51 | 52 | field.resolve = getResolve({ 53 | field, 54 | directiveName, 55 | params, 56 | middleware, 57 | }) 58 | } 59 | } 60 | 61 | export const createVisitObject = (directiveName, middleware) => 62 | class extends SchemaDirectiveVisitor { 63 | visitObject(type) { 64 | const fields = type.getFields() 65 | const { args: params } = this 66 | 67 | Object.values(fields).forEach((field) => { 68 | field.resolve = getResolve({ 69 | field, 70 | directiveName, 71 | params, 72 | middleware, 73 | }) 74 | }) 75 | } 76 | } 77 | --------------------------------------------------------------------------------