├── .codeclimate.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── full │ ├── generated-schema.gql │ ├── index.js │ ├── posts.service.js │ └── users.service.js ├── health │ └── index.js ├── index.js ├── simple │ └── index.js └── upload │ └── index.js ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json ├── src ├── ApolloServer.js ├── gql.js ├── moleculerApollo.js └── service.js └── test ├── integration └── greeter.spec.js └── unit ├── ApolloServer.spec.js ├── __snapshots__ └── service.spec.js.snap ├── gql.spec.js ├── index.spec.js ├── moleculerApollo.spec.js └── service.spec.js /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | checks: 4 | argument-count: 5 | enabled: false 6 | complex-logic: 7 | enabled: false 8 | file-lines: 9 | enabled: false 10 | method-complexity: 11 | enabled: false 12 | method-count: 13 | enabled: false 14 | method-lines: 15 | enabled: false 16 | nested-control-flow: 17 | enabled: false 18 | return-statements: 19 | enabled: false 20 | similar-code: 21 | enabled: false 22 | identical-code: 23 | enabled: false 24 | 25 | plugins: 26 | duplication: 27 | enabled: false 28 | config: 29 | languages: 30 | - javascript 31 | eslint: 32 | enabled: true 33 | channel: "eslint-4" 34 | fixme: 35 | enabled: true 36 | 37 | exclude_paths: 38 | - test/ 39 | - benchmark/ 40 | - examples/ 41 | - typings/ 42 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = tab 11 | indent_size = 4 12 | space_after_anon_function = true 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | indent_style = space 23 | indent_size = 4 24 | 25 | [{package,bower}.json] 26 | indent_style = space 27 | indent_size = 2 28 | 29 | [*.js] 30 | quote_type = "double" 31 | indent_size = unset 32 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | ### file include overrides 2 | # root configuration files (e.g. .eslintrc.js) 3 | !.*.js 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | commonjs: true, 5 | es6: true, 6 | jquery: false, 7 | jest: true, 8 | jasmine: true, 9 | }, 10 | extends: ["eslint:recommended", "plugin:security/recommended", "plugin:prettier/recommended"], 11 | parserOptions: { 12 | sourceType: "module", 13 | ecmaVersion: 2018, 14 | }, 15 | plugins: ["node", "promise", "security"], 16 | rules: { 17 | semi: ["error", "always"], 18 | "no-var": ["error"], 19 | "no-console": ["error"], 20 | "no-unused-vars": ["warn"], 21 | "no-trailing-spaces": ["error"], 22 | "no-alert": 0, 23 | "no-shadow": 0, 24 | "security/detect-object-injection": ["off"], 25 | "security/detect-non-literal-require": ["off"], 26 | "security/detect-non-literal-fs-filename": ["off"], 27 | "no-process-exit": ["off"], 28 | "node/no-unpublished-require": 0, 29 | "require-atomic-updates": 0, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI test 2 | 3 | on: 4 | push: {} 5 | pull_request: {} 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | # node-version: [10.x, 12.x, 14.x, 15.x] 13 | node-version: [10.x, 12.x, 14.x, 16.x, 18.x] 14 | fail-fast: false 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Cache node modules 25 | uses: actions/cache@v2 26 | env: 27 | cache-name: cache-node-modules 28 | with: 29 | # npm cache files are stored in `~/.npm` on Linux/macOS 30 | path: ~/.npm 31 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 32 | restore-keys: | 33 | ${{ runner.os }}-build-${{ env.cache-name }}- 34 | ${{ runner.os }}-build- 35 | ${{ runner.os }}- 36 | 37 | - name: Install dependencies 38 | run: npm ci 39 | 40 | - name: Execute unit tests 41 | run: npm run test 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | coverage/ 4 | npm-debug.log 5 | stats.json 6 | yarn-error.log 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .github/ 3 | coverage/ 4 | dev/ 5 | docs/ 6 | examples/ 7 | typings 8 | test/ 9 | CHANGELOG.md 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: true, 3 | printWidth: 100, 4 | tabWidth: 4, 5 | trailingComma: "es5", 6 | bracketSpacing: true, 7 | arrowParens: "avoid", 8 | semi: true, 9 | singleQuote: false, 10 | }; 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch demo", 11 | "program": "${workspaceRoot}\\examples\\index.js", 12 | "cwd": "${workspaceRoot}", 13 | "args": [ 14 | "full" 15 | ] 16 | }, 17 | { 18 | "type": "node", 19 | "request": "launch", 20 | "name": "Jest", 21 | "program": "${workspaceRoot}\\node_modules\\jest-cli\\bin\\jest.js", 22 | "args": ["--runInBand"], 23 | "cwd": "${workspaceRoot}", 24 | "runtimeArgs": [ 25 | "--nolazy" 26 | ] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # 0.3.8 (2023-04-23) 3 | 4 | ## Changes 5 | - add `graphql.invalidate` event, to invalidate GraphQL Schema manually. [#122](https://github.com/moleculerjs/moleculer-apollo-server/pull/122) 6 | 7 | -------------------------------------------------- 8 | 9 | # 0.3.7 (2022-10-04) 10 | 11 | ## Changes 12 | - update dependencies 13 | - fix CORS methods type definition. [#115](https://github.com/moleculerjs/moleculer-apollo-server/pull/115) 14 | - add `skipNullKeys` resolver option. [#116](https://github.com/moleculerjs/moleculer-apollo-server/pull/116) 15 | - add `checkActionVisibility` option. [#117](https://github.com/moleculerjs/moleculer-apollo-server/pull/117) 16 | 17 | -------------------------------------------------- 18 | 19 | # 0.3.6 (2022-01-17) 20 | 21 | ## Changes 22 | - custom `onConnect` issue fixed. [#105](https://github.com/moleculerjs/moleculer-apollo-server/pull/105) 23 | - update dependencies 24 | 25 | -------------------------------------------------- 26 | 27 | # 0.3.5 (2021-11-30) 28 | 29 | ## Changes 30 | - Prepare params before action calling. [#98](https://github.com/moleculerjs/moleculer-apollo-server/pull/98) 31 | - update dependencies 32 | 33 | -------------------------------------------------- 34 | 35 | # 0.3.4 (2021-04-09) 36 | 37 | ## Changes 38 | - disable timeout for `ws`. 39 | - gracefully stop Apollo Server. 40 | - add `onAfterCall` support. 41 | 42 | -------------------------------------------------- 43 | 44 | # 0.3.3 (2020-09-08) 45 | 46 | ## Changes 47 | - add `ctx.meta.$args` to store additional arguments in case of file uploading. 48 | 49 | -------------------------------------------------- 50 | 51 | # 0.3.2 (2020-08-30) 52 | 53 | ## Changes 54 | - update dependencies 55 | - new `createPubSub` & `makeExecutableSchema` methods 56 | - fix context in WS by [@Hugome](https://github.com/Hugome). [#73](https://github.com/moleculerjs/moleculer-apollo-server/pull/73) 57 | 58 | -------------------------------------------------- 59 | 60 | # 0.3.1 (2020-06-03) 61 | 62 | ## Changes 63 | - update dependencies 64 | - No longer installing subscription handlers when disabled by [@Kauabunga](https://github.com/Kauabunga). [#64](https://github.com/moleculerjs/moleculer-apollo-server/pull/64) 65 | 66 | -------------------------------------------------- 67 | 68 | # 0.3.0 (2020-04-04) 69 | 70 | ## Breaking changes 71 | - transform Uploads to `Stream`s before calling action by [@dylanwulf](https://github.com/dylanwulf). [#71](https://github.com/moleculerjs/moleculer-apollo-server/pull/71) 72 | 73 | ## Changes 74 | - update dependencies 75 | 76 | -------------------------------------------------- 77 | 78 | # 0.2.2 (2020-03-04) 79 | 80 | ## Changes 81 | - update dependencies 82 | 83 | -------------------------------------------------- 84 | 85 | # 0.2.1 (2020-03-03) 86 | 87 | ## Changes 88 | - add `autoUpdateSchema` option. [#63](https://github.com/moleculerjs/moleculer-apollo-server/pull/63) 89 | - Allow multiple rootParams to be used with Dataloader child resolution. [#65](https://github.com/moleculerjs/moleculer-apollo-server/pull/65) 90 | 91 | -------------------------------------------------- 92 | 93 | # 0.2.0 (2020-02-12) 94 | 95 | ## Breaking changes 96 | - minimum required Node version is 10.x 97 | - update dependencies and some require Node 10.x 98 | 99 | ## Changes 100 | - Typescript definition files added. 101 | - update dependencies 102 | - integration & unit tests added. 103 | - fix graphql undefined of issue when have others RESTful API node 104 | - Avoid mutating in defaultsDeep calls and use proper key in called action params 105 | 106 | -------------------------------------------------- 107 | 108 | # 0.1.3 (2019-10-16) 109 | 110 | First initial version on NPM. UNTESTED. 111 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 MoleculerJS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Moleculer logo](http://moleculer.services/images/banner.png) 2 | 3 | [![Build Status](https://travis-ci.org/moleculerjs/moleculer-apollo-server.svg?branch=master)](https://travis-ci.org/moleculerjs/moleculer-apollo-server) 4 | [![Coverage Status](https://coveralls.io/repos/github/moleculerjs/moleculer-apollo-server/badge.svg?branch=master)](https://coveralls.io/github/moleculerjs/moleculer-apollo-server?branch=master) 5 | [![David](https://img.shields.io/david/moleculerjs/moleculer-apollo-server.svg)](https://david-dm.org/moleculerjs/moleculer-apollo-server) 6 | [![Known Vulnerabilities](https://snyk.io/test/github/moleculerjs/moleculer-apollo-server/badge.svg)](https://snyk.io/test/github/moleculerjs/moleculer-apollo-server) 7 | 8 | 9 | # moleculer-apollo-server [![NPM version](https://img.shields.io/npm/v/moleculer-apollo-server.svg)](https://www.npmjs.com/package/moleculer-apollo-server) 10 | 11 | [Apollo GraphQL server](https://www.apollographql.com/docs/apollo-server/) mixin for [Moleculer API Gateway](https://github.com/moleculerjs/moleculer-web) 12 | 13 | ## Features 14 | 15 | ## Install 16 | ``` 17 | npm i moleculer-apollo-server moleculer-web graphql 18 | ``` 19 | 20 | ## Usage 21 | This example demonstrates how to setup a Moleculer API Gateway with GraphQL mixin in order to handle incoming GraphQL requests via the default `/graphql` endpoint. 22 | 23 | ```js 24 | "use strict"; 25 | 26 | const ApiGateway = require("moleculer-web"); 27 | const { ApolloService } = require("moleculer-apollo-server"); 28 | 29 | module.exports = { 30 | name: "api", 31 | 32 | mixins: [ 33 | // Gateway 34 | ApiGateway, 35 | 36 | // GraphQL Apollo Server 37 | ApolloService({ 38 | 39 | // Global GraphQL typeDefs 40 | typeDefs: ``, 41 | 42 | // Global resolvers 43 | resolvers: {}, 44 | 45 | // API Gateway route options 46 | routeOptions: { 47 | path: "/graphql", 48 | cors: true, 49 | mappingPolicy: "restrict" 50 | }, 51 | 52 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 53 | serverOptions: { 54 | tracing: true, 55 | 56 | engine: { 57 | apiKey: process.env.APOLLO_ENGINE_KEY 58 | } 59 | } 60 | }) 61 | ] 62 | }; 63 | 64 | ``` 65 | 66 | Start your Moleculer project, open http://localhost:3000/graphql in your browser to run queries using [graphql-playground](https://github.com/prismagraphql/graphql-playground), or send GraphQL requests directly to the same URL. 67 | 68 | 69 | **Define queries & mutations in service action definitions** 70 | 71 | ```js 72 | module.exports = { 73 | name: "greeter", 74 | 75 | actions: { 76 | hello: { 77 | graphql: { 78 | query: "hello: String" 79 | }, 80 | handler(ctx) { 81 | return "Hello Moleculer!" 82 | } 83 | }, 84 | welcome: { 85 | params: { 86 | name: "string" 87 | }, 88 | graphql: { 89 | mutation: "welcome(name: String!): String" 90 | }, 91 | handler(ctx) { 92 | return `Hello ${ctx.params.name}`; 93 | } 94 | } 95 | } 96 | }; 97 | ``` 98 | 99 | **Generated schema** 100 | ```gql 101 | type Mutation { 102 | welcome(name: String!): String 103 | } 104 | 105 | type Query { 106 | hello: String 107 | } 108 | ``` 109 | 110 | ### Resolvers between services 111 | 112 | **posts.service.js** 113 | ```js 114 | module.exports = { 115 | name: "posts", 116 | settings: { 117 | graphql: { 118 | type: ` 119 | """ 120 | This type describes a post entity. 121 | """ 122 | type Post { 123 | id: Int! 124 | title: String! 125 | author: User! 126 | votes: Int! 127 | voters: [User] 128 | createdAt: Timestamp 129 | } 130 | `, 131 | resolvers: { 132 | Post: { 133 | author: { 134 | // Call the `users.resolve` action with `id` params 135 | action: "users.resolve", 136 | rootParams: { 137 | "author": "id" 138 | } 139 | }, 140 | voters: { 141 | // Call the `users.resolve` action with `id` params 142 | action: "users.resolve", 143 | rootParams: { 144 | "voters": "id" 145 | } 146 | } 147 | } 148 | } 149 | } 150 | }, 151 | actions: { 152 | find: { 153 | //cache: true, 154 | params: { 155 | limit: { type: "number", optional: true } 156 | }, 157 | graphql: { 158 | query: `posts(limit: Int): [Post]` 159 | }, 160 | handler(ctx) { 161 | let result = _.cloneDeep(posts); 162 | if (ctx.params.limit) 163 | result = posts.slice(0, ctx.params.limit); 164 | else 165 | result = posts; 166 | 167 | return _.cloneDeep(result); 168 | } 169 | }, 170 | 171 | findByUser: { 172 | params: { 173 | userID: "number" 174 | }, 175 | handler(ctx) { 176 | return _.cloneDeep(posts.filter(post => post.author == ctx.params.userID)); 177 | } 178 | }, 179 | } 180 | }; 181 | ``` 182 | 183 | **users.service.js** 184 | ```js 185 | module.exports = { 186 | name: "users", 187 | settings: { 188 | graphql: { 189 | type: ` 190 | """ 191 | This type describes a user entity. 192 | """ 193 | type User { 194 | id: Int! 195 | name: String! 196 | birthday: Date 197 | posts(limit: Int): [Post] 198 | postCount: Int 199 | } 200 | `, 201 | resolvers: { 202 | User: { 203 | posts: { 204 | // Call the `posts.findByUser` action with `userID` param. 205 | action: "posts.findByUser", 206 | rootParams: { 207 | "id": "userID" 208 | } 209 | }, 210 | postCount: { 211 | // Call the "posts.count" action 212 | action: "posts.count", 213 | // Get `id` value from `root` and put it into `ctx.params.query.author` 214 | rootParams: { 215 | "id": "query.author" 216 | } 217 | } 218 | } 219 | } 220 | } 221 | }, 222 | actions: { 223 | find: { 224 | //cache: true, 225 | params: { 226 | limit: { type: "number", optional: true } 227 | }, 228 | graphql: { 229 | query: "users(limit: Int): [User]" 230 | }, 231 | handler(ctx) { 232 | let result = _.cloneDeep(users); 233 | if (ctx.params.limit) 234 | result = users.slice(0, ctx.params.limit); 235 | else 236 | result = users; 237 | 238 | return _.cloneDeep(result); 239 | } 240 | }, 241 | 242 | resolve: { 243 | params: { 244 | id: [ 245 | { type: "number" }, 246 | { type: "array", items: "number" } 247 | ] 248 | }, 249 | handler(ctx) { 250 | if (Array.isArray(ctx.params.id)) { 251 | return _.cloneDeep(ctx.params.id.map(id => this.findByID(id))); 252 | } else { 253 | return _.cloneDeep(this.findByID(ctx.params.id)); 254 | } 255 | } 256 | } 257 | } 258 | }; 259 | ``` 260 | 261 | ### File Uploads 262 | moleculer-apollo-server supports file uploads through the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). 263 | 264 | To enable uploads, the Upload scalar must be added to the Gateway: 265 | 266 | ```js 267 | "use strict"; 268 | 269 | const ApiGateway = require("moleculer-web"); 270 | const { ApolloService, GraphQLUpload } = require("moleculer-apollo-server"); 271 | 272 | module.exports = { 273 | name: "api", 274 | 275 | mixins: [ 276 | // Gateway 277 | ApiGateway, 278 | 279 | // GraphQL Apollo Server 280 | ApolloService({ 281 | 282 | // Global GraphQL typeDefs 283 | typeDefs: ["scalar Upload"], 284 | 285 | // Global resolvers 286 | resolvers: { 287 | Upload: GraphQLUpload 288 | }, 289 | 290 | // API Gateway route options 291 | routeOptions: { 292 | path: "/graphql", 293 | cors: true, 294 | mappingPolicy: "restrict" 295 | }, 296 | 297 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 298 | serverOptions: { 299 | tracing: true, 300 | 301 | engine: { 302 | apiKey: process.env.APOLLO_ENGINE_KEY 303 | } 304 | } 305 | }) 306 | ] 307 | }; 308 | 309 | ``` 310 | 311 | Then a mutation can be created which accepts an Upload argument. The `fileUploadArg` property must be set to the mutation's argument name so that moleculer-apollo-server knows where to expect a file upload. When the mutation's action handler is called, `ctx.params` will be a [Readable Stream](https://nodejs.org/api/stream.html#stream_readable_streams) which can be used to read the contents of the uploaded file (or pipe the contents into a Writable Stream). File metadata will be made available in `ctx.meta.$fileInfo`. 312 | 313 | **files.service.js** 314 | ```js 315 | module.exports = { 316 | name: "files", 317 | settings: { 318 | graphql: { 319 | type: ` 320 | """ 321 | This type describes a File entity. 322 | """ 323 | type File { 324 | filename: String! 325 | encoding: String! 326 | mimetype: String! 327 | } 328 | ` 329 | } 330 | }, 331 | actions: { 332 | uploadFile: { 333 | graphql: { 334 | mutation: "uploadFile(file: Upload!): File!", 335 | fileUploadArg: "file", 336 | }, 337 | async handler(ctx) { 338 | const fileChunks = []; 339 | for await (const chunk of ctx.params) { 340 | fileChunks.push(chunk); 341 | } 342 | const fileContents = Buffer.concat(fileChunks); 343 | // Do something with file contents 344 | 345 | // Additional arguments: 346 | this.logger.info("Additional arguments:", ctx.meta.$args); 347 | 348 | return ctx.meta.$fileInfo; 349 | } 350 | } 351 | } 352 | }; 353 | ``` 354 | 355 | To accept multiple uploaded files in a single request, the mutation can be changed to accept an array of `Upload`s and return an array of results. The action handler will then be called once for each uploaded file, and the results will be combined into an array automatically with results in the same order as the provided files. 356 | 357 | ```js 358 | ... 359 | graphql: { 360 | mutation: "upload(file: [Upload!]!): [File!]!", 361 | fileUploadArg: "file" 362 | } 363 | ... 364 | ``` 365 | 366 | ### Dataloader 367 | moleculer-apollo-server supports [DataLoader](https://github.com/graphql/dataloader) via configuration in the resolver definition. 368 | The called action must be compatible with DataLoader semantics -- that is, it must accept params with an array property and return an array of the same size, 369 | with the results in the same order as they were provided. 370 | 371 | To activate DataLoader for a resolver, simply add `dataLoader: true` to the resolver's property object in the `resolvers` property of the service's `graphql` property: 372 | 373 | ```js 374 | settings: { 375 | graphql: { 376 | resolvers: { 377 | Post: { 378 | author: { 379 | action: "users.resolve", 380 | dataLoader: true, 381 | rootParams: { 382 | author: "id", 383 | }, 384 | }, 385 | voters: { 386 | action: "users.resolve", 387 | dataLoader: true, 388 | rootParams: { 389 | voters: "id", 390 | }, 391 | }, 392 | ... 393 | ``` 394 | Since DataLoader only expects a single value to be loaded at a time, only one `rootParams` key/value pairing will be utilized, but `params` and GraphQL child arguments work properly. 395 | 396 | You can also specify [options](https://github.com/graphql/dataloader#api) for construction of the DataLoader in the called action definition's `graphql` property. This is useful for setting things like `maxBatchSize'. 397 | 398 | ```js 399 | resolve: { 400 | params: { 401 | id: [{ type: "number" }, { type: "array", items: "number" }], 402 | graphql: { dataLoaderOptions: { maxBatchSize: 100 } }, 403 | }, 404 | handler(ctx) { 405 | this.logger.debug("resolve action called.", { params: ctx.params }); 406 | if (Array.isArray(ctx.params.id)) { 407 | return _.cloneDeep(ctx.params.id.map(id => this.findByID(id))); 408 | } else { 409 | return _.cloneDeep(this.findByID(ctx.params.id)); 410 | } 411 | }, 412 | }, 413 | ``` 414 | It is unlikely that setting any of the options which accept a function will work properly unless you are running moleculer in a single-node environment. This is because the functions will not serialize and be run by the moleculer-web Api Gateway. 415 | 416 | ## Examples 417 | 418 | - [Simple](examples/simple/index.js) 419 | - `npm run dev` 420 | - [File Upload](examples/upload/index.js) 421 | - `npm run dev upload` 422 | - See [here](https://github.com/jaydenseric/graphql-multipart-request-spec#curl-request) for information about how to create a file upload request 423 | - [Full](examples/full/index.js) 424 | - `npm run dev full` 425 | - [Full With Dataloader](examples/full/index.js) 426 | - set `DATALOADER` environment variable to `"true"` 427 | - `npm run dev full` 428 | # Test 429 | ``` 430 | $ npm test 431 | ``` 432 | 433 | In development with watching 434 | 435 | ``` 436 | $ npm run ci 437 | ``` 438 | 439 | # Contribution 440 | Please send pull requests improving the usage and fixing bugs, improving documentation and providing better examples, or providing some testing, because these things are important. 441 | 442 | # License 443 | The project is available under the [MIT license](https://tldrlegal.com/license/mit-license). 444 | 445 | # Contact 446 | Copyright (c) 2020 MoleculerJS 447 | 448 | [![@moleculerjs](https://img.shields.io/badge/github-moleculerjs-green.svg)](https://github.com/moleculerjs) [![@MoleculerJS](https://img.shields.io/badge/twitter-MoleculerJS-blue.svg)](https://twitter.com/MoleculerJS) 449 | -------------------------------------------------------------------------------- /examples/full/generated-schema.gql: -------------------------------------------------------------------------------- 1 | scalar Date 2 | 3 | scalar Timestamp 4 | 5 | type Query { 6 | posts(limit: Int): [Post] 7 | users(limit: Int): [User] 8 | } 9 | 10 | type Mutation { 11 | upvote(id: Int!, userID: Int!): Post 12 | downvote(id: Int!, userID: Int!): Post 13 | } 14 | 15 | type Subscription { 16 | vote(userID: Int!): String! 17 | } 18 | 19 | """This type describes a post entity.""" 20 | type Post { 21 | id: Int! 22 | title: String! 23 | author: User! 24 | votes: Int! 25 | voters: [User] 26 | createdAt: Timestamp 27 | error: String 28 | } 29 | 30 | """This type describes a user entity.""" 31 | type User { 32 | id: Int! 33 | name: String! 34 | birthday: Date 35 | posts(limit: Int): [Post] 36 | postCount: Int 37 | type: UserType 38 | } 39 | 40 | """Enumerations for user types""" 41 | enum UserType { 42 | ADMIN 43 | PUBLISHER 44 | READER 45 | } 46 | -------------------------------------------------------------------------------- /examples/full/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | const { Kind } = require("graphql"); 5 | const { ServiceBroker } = require("moleculer"); 6 | const ApiGateway = require("moleculer-web"); 7 | const { ApolloService } = require("../../index"); 8 | 9 | const broker = new ServiceBroker({ 10 | logLevel: process.env.LOGLEVEL || "info" /*, transporter: "NATS"*/, 11 | }); 12 | 13 | broker.createService({ 14 | name: "api", 15 | 16 | mixins: [ 17 | // Gateway 18 | ApiGateway, 19 | 20 | // GraphQL Apollo Server 21 | ApolloService({ 22 | // Global GraphQL typeDefs 23 | typeDefs: ["scalar Date", "scalar Timestamp"], 24 | 25 | // Global resolvers 26 | resolvers: { 27 | Date: { 28 | __parseValue(value) { 29 | return new Date(value); // value from the client 30 | }, 31 | __serialize(value) { 32 | return value.toISOString().split("T")[0]; // value sent to the client 33 | }, 34 | __parseLiteral(ast) { 35 | if (ast.kind === Kind.INT) { 36 | return parseInt(ast.value, 10); // ast value is always in string format 37 | } 38 | 39 | return null; 40 | }, 41 | }, 42 | Timestamp: { 43 | __parseValue(value) { 44 | return new Date(value); // value from the client 45 | }, 46 | __serialize(value) { 47 | return value.toISOString(); // value sent to the client 48 | }, 49 | __parseLiteral(ast) { 50 | if (ast.kind === Kind.INT) { 51 | return parseInt(ast.value, 10); // ast value is always in string format 52 | } 53 | 54 | return null; 55 | }, 56 | }, 57 | }, 58 | 59 | // API Gateway route options 60 | routeOptions: { 61 | path: "/graphql", 62 | cors: true, 63 | mappingPolicy: "restrict", 64 | }, 65 | 66 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 67 | serverOptions: { 68 | tracing: false, 69 | 70 | engine: { 71 | apiKey: process.env.APOLLO_ENGINE_KEY, 72 | }, 73 | }, 74 | }), 75 | ], 76 | 77 | events: { 78 | "graphql.schema.updated"({ schema }) { 79 | fs.writeFileSync(__dirname + "/generated-schema.gql", schema, "utf8"); 80 | this.logger.info("Generated GraphQL schema:\n\n" + schema); 81 | }, 82 | }, 83 | }); 84 | 85 | broker.loadServices(__dirname); 86 | 87 | broker.start().then(async () => { 88 | broker.repl(); 89 | 90 | broker.logger.info("----------------------------------------------------------"); 91 | broker.logger.info("Open the http://localhost:3000/graphql URL in your browser"); 92 | broker.logger.info("----------------------------------------------------------"); 93 | }); 94 | -------------------------------------------------------------------------------- /examples/full/posts.service.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const { MoleculerClientError } = require("moleculer").Errors; 5 | const { moleculerGql: gql } = require("../../index"); 6 | 7 | const posts = [ 8 | { 9 | id: 1, 10 | title: "First post", 11 | author: 3, 12 | votes: 2, 13 | voters: [2, 5], 14 | createdAt: new Date("2018-08-23T08:10:25"), 15 | }, 16 | { 17 | id: 2, 18 | title: "Second post", 19 | author: 1, 20 | votes: 0, 21 | voters: [], 22 | createdAt: new Date("2018-11-23T12:59:30"), 23 | }, 24 | { 25 | id: 3, 26 | title: "Third post", 27 | author: 2, 28 | votes: 1, 29 | voters: [5], 30 | createdAt: new Date("2018-02-23T22:24:28"), 31 | }, 32 | { 33 | id: 4, 34 | title: "4th post", 35 | author: 3, 36 | votes: 3, 37 | voters: [4, 1, 2], 38 | createdAt: new Date("2018-10-23T10:33:00"), 39 | }, 40 | { 41 | id: 5, 42 | title: "5th post", 43 | author: 5, 44 | votes: 1, 45 | voters: [4], 46 | createdAt: new Date("2018-11-24T21:15:30"), 47 | }, 48 | ]; 49 | 50 | module.exports = { 51 | name: "posts", 52 | settings: { 53 | graphql: { 54 | type: gql` 55 | """ 56 | This type describes a post entity. 57 | """ 58 | type Post { 59 | id: Int! 60 | title: String! 61 | author: User! 62 | votes: Int! 63 | voters: [User] 64 | createdAt: Timestamp 65 | error: String 66 | } 67 | `, 68 | resolvers: { 69 | Post: { 70 | author: { 71 | action: "users.resolve", 72 | dataLoader: process.env.DATALOADER === "true", 73 | rootParams: { 74 | author: "id", 75 | }, 76 | }, 77 | voters: { 78 | action: "users.resolve", 79 | dataLoader: process.env.DATALOADER === "true", 80 | rootParams: { 81 | voters: "id", 82 | }, 83 | }, 84 | error: { 85 | action: "posts.error", 86 | nullIfError: true, 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | actions: { 93 | find: { 94 | //cache: true, 95 | params: { 96 | limit: { type: "number", optional: true }, 97 | }, 98 | graphql: { 99 | query: gql` 100 | type Query { 101 | posts(limit: Int): [Post] 102 | } 103 | `, 104 | }, 105 | handler(ctx) { 106 | let result = _.cloneDeep(posts); 107 | if (ctx.params.limit) { 108 | result = posts.slice(0, ctx.params.limit); 109 | } else { 110 | result = posts; 111 | } 112 | 113 | return _.cloneDeep(result); 114 | }, 115 | }, 116 | 117 | count: { 118 | params: { 119 | query: { type: "object", optional: true }, 120 | }, 121 | handler(ctx) { 122 | if (!ctx.params.query) { 123 | return posts.length; 124 | } 125 | 126 | return posts.filter(post => post.author == ctx.params.query.author).length; 127 | }, 128 | }, 129 | 130 | findByUser: { 131 | params: { 132 | userID: "number", 133 | }, 134 | handler(ctx) { 135 | return _.cloneDeep(posts.filter(post => post.author == ctx.params.userID)); 136 | }, 137 | }, 138 | 139 | upvote: { 140 | params: { 141 | id: "number", 142 | userID: "number", 143 | }, 144 | graphql: { 145 | mutation: gql` 146 | type Mutation { 147 | upvote(id: Int!, userID: Int!): Post 148 | } 149 | `, 150 | }, 151 | async handler(ctx) { 152 | const post = this.findByID(ctx.params.id); 153 | if (!post) { 154 | throw new MoleculerClientError("Post is not found"); 155 | } 156 | 157 | const has = post.voters.find(voter => voter == ctx.params.userID); 158 | if (has) { 159 | throw new MoleculerClientError("User has already voted this post"); 160 | } 161 | 162 | post.voters.push(ctx.params.userID); 163 | post.votes = post.voters.length; 164 | 165 | await ctx.broadcast("graphql.publish", { 166 | tag: "VOTE", 167 | payload: { type: "up", userID: ctx.params.userID }, 168 | }); 169 | 170 | return _.cloneDeep(post); 171 | }, 172 | }, 173 | 174 | downvote: { 175 | params: { 176 | id: "number", 177 | userID: "number", 178 | }, 179 | graphql: { 180 | mutation: gql` 181 | type Mutation { 182 | downvote(id: Int!, userID: Int!): Post 183 | } 184 | `, 185 | }, 186 | async handler(ctx) { 187 | const post = this.findByID(ctx.params.id); 188 | if (!post) { 189 | throw new MoleculerClientError("Post is not found"); 190 | } 191 | 192 | const has = post.voters.find(voter => voter == ctx.params.userID); 193 | if (!has) { 194 | throw new MoleculerClientError("User has not voted this post yet"); 195 | } 196 | 197 | post.voters = post.voters.filter(voter => voter != ctx.params.userID); 198 | post.votes = post.voters.length; 199 | 200 | await ctx.broadcast("graphql.publish", { 201 | tag: "VOTE", 202 | payload: { type: "down", userID: ctx.params.userID }, 203 | }); 204 | 205 | return _.cloneDeep(post); 206 | }, 207 | }, 208 | vote: { 209 | params: { payload: "object" }, 210 | graphql: { 211 | subscription: gql` 212 | type Subscription { 213 | vote(userID: Int!): String! 214 | } 215 | `, 216 | tags: ["VOTE"], 217 | filter: "posts.vote.filter", 218 | }, 219 | handler(ctx) { 220 | return ctx.params.payload.type; 221 | }, 222 | }, 223 | "vote.filter": { 224 | params: { userID: "number", payload: "object" }, 225 | handler(ctx) { 226 | return ctx.params.payload.userID === ctx.params.userID; 227 | }, 228 | }, 229 | error: { 230 | handler() { 231 | throw new Error("Oh look an error !"); 232 | }, 233 | }, 234 | }, 235 | 236 | methods: { 237 | findByID(id) { 238 | return posts.find(post => post.id == id); 239 | }, 240 | }, 241 | }; 242 | -------------------------------------------------------------------------------- /examples/full/users.service.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const { moleculerGql: gql } = require("../../index"); 5 | 6 | const users = [ 7 | { id: 1, name: "Genaro Krueger", birthday: new Date("1975-12-17"), type: "1" }, 8 | { id: 2, name: "Nicholas Paris", birthday: new Date("1981-01-27"), type: "2" }, 9 | { id: 3, name: "Quinton Loden", birthday: new Date("1995-03-22"), type: "3" }, 10 | { id: 4, name: "Bradford Knauer", birthday: new Date("2008-11-01"), type: "2" }, 11 | { id: 5, name: "Damien Accetta", birthday: new Date("1959-08-07"), type: "1" }, 12 | ]; 13 | 14 | module.exports = { 15 | name: "users", 16 | settings: { 17 | graphql: { 18 | type: gql` 19 | """ 20 | This type describes a user entity. 21 | """ 22 | type User { 23 | id: Int! 24 | name: String! 25 | birthday: Date 26 | posts(limit: Int): [Post] 27 | postCount: Int 28 | type: UserType 29 | } 30 | `, 31 | enum: gql` 32 | """ 33 | Enumerations for user types 34 | """ 35 | enum UserType { 36 | ADMIN 37 | PUBLISHER 38 | READER 39 | } 40 | `, 41 | resolvers: { 42 | User: { 43 | posts: { 44 | action: "posts.findByUser", 45 | rootParams: { 46 | id: "userID", 47 | }, 48 | }, 49 | postCount: { 50 | // Call the "posts.count" action 51 | action: "posts.count", 52 | // Get `id` value from `root` and put it into `ctx.params.query.author` 53 | rootParams: { 54 | id: "query.author", 55 | }, 56 | }, 57 | }, 58 | UserType: { 59 | ADMIN: "1", 60 | PUBLISHER: "2", 61 | READER: "3", 62 | }, 63 | }, 64 | }, 65 | }, 66 | actions: { 67 | find: { 68 | //cache: true, 69 | params: { 70 | limit: { type: "number", optional: true }, 71 | }, 72 | graphql: { 73 | query: gql` 74 | type Query { 75 | users(limit: Int): [User] 76 | } 77 | `, 78 | }, 79 | handler(ctx) { 80 | let result = _.cloneDeep(users); 81 | if (ctx.params.limit) { 82 | result = users.slice(0, ctx.params.limit); 83 | } else { 84 | result = users; 85 | } 86 | 87 | return _.cloneDeep(result); 88 | }, 89 | }, 90 | 91 | resolve: { 92 | params: { 93 | id: [{ type: "number" }, { type: "array", items: "number" }], 94 | }, 95 | handler(ctx) { 96 | this.logger.debug("resolve action called.", { params: ctx.params }); 97 | if (Array.isArray(ctx.params.id)) { 98 | return _.cloneDeep(ctx.params.id.map(id => this.findByID(id))); 99 | } else { 100 | return _.cloneDeep(this.findByID(ctx.params.id)); 101 | } 102 | }, 103 | }, 104 | }, 105 | 106 | methods: { 107 | findByID(id) { 108 | return users.find(user => user.id == id); 109 | }, 110 | }, 111 | }; 112 | -------------------------------------------------------------------------------- /examples/health/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | let { ServiceBroker } = require("moleculer"); 4 | 5 | const ApiGateway = require("moleculer-web"); 6 | const { ApolloService } = require("../../index"); 7 | 8 | const brokerWithRandomHealthy = new ServiceBroker({ 9 | logLevel: "info", 10 | hotReload: true, 11 | namespace: "randomhealthy", 12 | }); 13 | 14 | const brokerWithWrongSchema = new ServiceBroker({ 15 | logLevel: "info", 16 | hotReload: true, 17 | namespace: "wrongschema", 18 | }); 19 | 20 | const greeterService = { 21 | name: "greeter", 22 | 23 | actions: { 24 | hello: { 25 | graphql: { 26 | query: "hello: String!", 27 | }, 28 | handler() { 29 | return "Hello Moleculer!"; 30 | }, 31 | }, 32 | }, 33 | }; 34 | 35 | // =================================== 36 | // Random healthy 37 | // =================================== 38 | 39 | brokerWithRandomHealthy.createService({ 40 | name: "api", 41 | settings: { port: 3000 }, 42 | mixins: [ 43 | // Gateway 44 | ApiGateway, 45 | 46 | // GraphQL Apollo Server 47 | ApolloService({ 48 | // API Gateway route options 49 | routeOptions: { 50 | path: "/graphql", 51 | cors: true, 52 | mappingPolicy: "restrict", 53 | }, 54 | 55 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 56 | serverOptions: { 57 | async onHealthCheck() { 58 | if (Math.random() >= 0.5) { 59 | throw new Error("Database not connected"); 60 | } 61 | return { database: true, storage: true }; 62 | }, 63 | }, 64 | }), 65 | ], 66 | }); 67 | brokerWithRandomHealthy.createService(greeterService); 68 | 69 | // =================================== 70 | // Schema non healthy 71 | // =================================== 72 | 73 | brokerWithWrongSchema.createService({ 74 | name: "api", 75 | settings: { port: 3001 }, 76 | mixins: [ 77 | // Gateway 78 | ApiGateway, 79 | 80 | // GraphQL Apollo Server 81 | ApolloService({ 82 | typeDefs: "ThisIsSoWrongInMySchema", 83 | // API Gateway route options 84 | routeOptions: { 85 | path: "/graphql", 86 | cors: true, 87 | mappingPolicy: "restrict", 88 | }, 89 | 90 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 91 | serverOptions: {}, 92 | }), 93 | ], 94 | }); 95 | brokerWithWrongSchema.createService(greeterService); 96 | 97 | // =================================== 98 | 99 | Promise.all([brokerWithRandomHealthy.start(), brokerWithWrongSchema.start()]).then(() => { 100 | brokerWithWrongSchema.logger.info("API With wrong schema started ----------------------------"); 101 | brokerWithWrongSchema.logger.info("Open the http://localhost:3001/graphql URL in your browser"); 102 | brokerWithWrongSchema.logger.info("----------------------------------------------------------"); 103 | 104 | brokerWithRandomHealthy.logger.info("API With random health result (1/2) ----------------------"); 105 | brokerWithRandomHealthy.logger.info("Open the http://localhost:3000/graphql URL in your browser"); 106 | brokerWithRandomHealthy.logger.info("----------------------------------------------------------"); 107 | 108 | brokerWithWrongSchema.repl(); 109 | }); 110 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const moduleName = process.argv[2] || "simple"; 4 | process.argv.splice(2, 1); 5 | 6 | require("./" + moduleName); 7 | -------------------------------------------------------------------------------- /examples/simple/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { ServiceBroker } = require("moleculer"); 4 | const { MoleculerClientError } = require("moleculer").Errors; 5 | 6 | const ApiGateway = require("moleculer-web"); 7 | const { ApolloService } = require("../../index"); 8 | 9 | const broker = new ServiceBroker({ logLevel: "info", hotReload: true }); 10 | 11 | broker.createService({ 12 | name: "api", 13 | 14 | mixins: [ 15 | // Gateway 16 | ApiGateway, 17 | 18 | // GraphQL Apollo Server 19 | ApolloService({ 20 | // API Gateway route options 21 | routeOptions: { 22 | path: "/graphql", 23 | cors: true, 24 | mappingPolicy: "restrict", 25 | }, 26 | 27 | checkActionVisibility: true, 28 | 29 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 30 | serverOptions: {}, 31 | }), 32 | ], 33 | 34 | events: { 35 | "graphql.schema.updated"({ schema }) { 36 | this.logger.info("Generated GraphQL schema:\n\n" + schema); 37 | }, 38 | }, 39 | }); 40 | 41 | broker.createService({ 42 | name: "greeter", 43 | 44 | actions: { 45 | hello: { 46 | graphql: { 47 | query: "hello: String!", 48 | }, 49 | handler() { 50 | return "Hello Moleculer!"; 51 | }, 52 | }, 53 | welcome: { 54 | graphql: { 55 | mutation: ` 56 | welcome( 57 | name: String! 58 | ): String! 59 | `, 60 | }, 61 | handler(ctx) { 62 | return `Hello ${ctx.params.name}`; 63 | }, 64 | }, 65 | update: { 66 | graphql: { 67 | subscription: "update: String!", 68 | tags: ["TEST"], 69 | }, 70 | handler(ctx) { 71 | return ctx.params.payload; 72 | }, 73 | }, 74 | 75 | danger: { 76 | graphql: { 77 | query: "danger: String!", 78 | }, 79 | async handler() { 80 | throw new MoleculerClientError("I've said it's a danger action!", 422, "DANGER"); 81 | }, 82 | }, 83 | 84 | secret: { 85 | visibility: "protected", 86 | graphql: { 87 | query: "secret: String!", 88 | }, 89 | async handler() { 90 | return "! TOP SECRET !"; 91 | }, 92 | }, 93 | 94 | visible: { 95 | visibility: "published", 96 | graphql: { 97 | query: "visible: String!", 98 | }, 99 | async handler() { 100 | return "Not secret"; 101 | }, 102 | }, 103 | }, 104 | }); 105 | 106 | broker.start().then(async () => { 107 | broker.repl(); 108 | 109 | const res = await broker.call("api.graphql", { 110 | query: "query { hello }", 111 | }); 112 | 113 | let counter = 1; 114 | setInterval( 115 | async () => 116 | broker.broadcast("graphql.publish", { tag: "TEST", payload: `test ${counter++}` }), 117 | 5000 118 | ); 119 | 120 | if (res.errors && res.errors.length > 0) return res.errors.forEach(broker.logger.error); 121 | 122 | broker.logger.info(res.data); 123 | 124 | broker.logger.info("----------------------------------------------------------"); 125 | broker.logger.info("Open the http://localhost:3000/graphql URL in your browser"); 126 | broker.logger.info("----------------------------------------------------------"); 127 | }); 128 | -------------------------------------------------------------------------------- /examples/upload/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { ServiceBroker } = require("moleculer"); 4 | 5 | const ApiGateway = require("moleculer-web"); 6 | const { ApolloService, GraphQLUpload } = require("../../index"); 7 | 8 | const broker = new ServiceBroker({ logLevel: "info", hotReload: true }); 9 | 10 | broker.createService({ 11 | name: "api", 12 | 13 | mixins: [ 14 | // Gateway 15 | ApiGateway, 16 | 17 | // GraphQL Apollo Server 18 | ApolloService({ 19 | typeDefs: ["scalar Upload"], 20 | resolvers: { 21 | Upload: GraphQLUpload, 22 | }, 23 | // API Gateway route options 24 | routeOptions: { 25 | path: "/graphql", 26 | cors: true, 27 | mappingPolicy: "restrict", 28 | }, 29 | 30 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 31 | serverOptions: {}, 32 | }), 33 | ], 34 | 35 | events: { 36 | "graphql.schema.updated"({ schema }) { 37 | this.logger.info("Generated GraphQL schema:\n\n" + schema); 38 | }, 39 | }, 40 | }); 41 | 42 | broker.createService({ 43 | name: "files", 44 | settings: { 45 | graphql: { 46 | type: ` 47 | """ 48 | This type describes a File entity. 49 | """ 50 | type File { 51 | filename: String! 52 | encoding: String! 53 | mimetype: String! 54 | } 55 | `, 56 | }, 57 | }, 58 | actions: { 59 | hello: { 60 | graphql: { 61 | query: "hello: String!", 62 | }, 63 | handler() { 64 | return "Hello Moleculer!"; 65 | }, 66 | }, 67 | singleUpload: { 68 | graphql: { 69 | mutation: "singleUpload(file: Upload!, other: String): File!", 70 | fileUploadArg: "file", 71 | }, 72 | async handler(ctx) { 73 | const fileChunks = []; 74 | for await (const chunk of ctx.params) { 75 | fileChunks.push(chunk); 76 | } 77 | const fileContents = Buffer.concat(fileChunks); 78 | ctx.broker.logger.info("Uploaded File Contents:", fileContents.toString()); 79 | ctx.broker.logger.info("Additional arguments:", ctx.meta.$args); 80 | return ctx.meta.$fileInfo; 81 | }, 82 | }, 83 | }, 84 | }); 85 | 86 | broker.start().then(async () => { 87 | broker.repl(); 88 | 89 | broker.logger.info("----------------------------------------------------------"); 90 | broker.logger.info("For information about creating a file upload request,"); 91 | broker.logger.info( 92 | "see https://github.com/jaydenseric/graphql-multipart-request-spec#curl-request" 93 | ); 94 | broker.logger.info("----------------------------------------------------------"); 95 | }); 96 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "moleculer-apollo-server" { 2 | import { ServiceSchema, Context } from "moleculer"; 3 | import { Config } from "apollo-server-core"; 4 | import { OptionsUrlencoded } from "body-parser"; 5 | import { SchemaDirectiveVisitor, IResolvers } from "graphql-tools"; 6 | 7 | export { 8 | GraphQLExtension, 9 | gql, 10 | ApolloError, 11 | toApolloError, 12 | SyntaxError, 13 | ValidationError, 14 | AuthenticationError, 15 | ForbiddenError, 16 | UserInputError, 17 | defaultPlaygroundOptions, 18 | } from "apollo-server-core"; 19 | 20 | export { GraphQLUpload } from "graphql-upload"; 21 | 22 | export * from "graphql-tools"; 23 | 24 | export interface ApolloServerOptions { 25 | path: string; 26 | disableHealthCheck: boolean; 27 | onHealthCheck: () => {}; 28 | } 29 | 30 | export class ApolloServer { 31 | createGraphQLServerOptions(req: any, res: any): Promise; 32 | createHandler(options: ApolloServerOptions): void; 33 | supportsUploads(): boolean; 34 | supportsSubscriptions(): boolean; 35 | } 36 | 37 | export interface ActionResolverSchema { 38 | action: string; 39 | rootParams?: { 40 | [key: string]: string; 41 | }; 42 | dataLoader?: boolean; 43 | nullIfError?: boolean; 44 | skipNullKeys?: boolean; 45 | params?: { [key: string]: any }; 46 | } 47 | 48 | export interface ServiceResolverSchema { 49 | [key: string]: { 50 | [key: string]: ActionResolverSchema; 51 | }; 52 | } 53 | 54 | type CorsMethods = "GET" | "POST" | "PUT" | "DELETE" | "OPTIONS"; 55 | 56 | export interface ServiceRouteCorsOptions { 57 | origin?: string | string[]; 58 | methods?: CorsMethods | CorsMethods[]; 59 | allowedHeaders?: string[]; 60 | exposedHeaders?: string[]; 61 | credentials?: boolean; 62 | maxAge?: number; 63 | } 64 | 65 | export interface ServiceRouteOptions { 66 | path?: string; 67 | use?: any[]; 68 | etag?: boolean; 69 | whitelist?: string[]; 70 | authorization?: boolean; 71 | camelCaseNames?: boolean; 72 | aliases?: { 73 | [key: string]: any; // Should discuss more on this. string | AliasSchema, ... 74 | }; 75 | bodyParsers?: { 76 | json: boolean; 77 | urlencoded: OptionsUrlencoded; 78 | }; 79 | cors?: boolean | ServiceRouteCorsOptions; 80 | mappingPolicy?: "all" | "restrict"; 81 | authentication?: boolean; 82 | callOptions?: { 83 | timeout: number; 84 | fallbackResponse?: any; 85 | }; 86 | onBeforeCall?: (ctx: Context, route: any, req: any, res: any) => Promise; 87 | onAfterCall?: (ctx: Context, route: any, req: any, res: any, data: any) => Promise; 88 | } 89 | 90 | export interface ApolloServiceOptions { 91 | typeDefs?: string | string[]; 92 | resolvers?: ServiceResolverSchema | IResolvers | Array; 93 | schemaDirectives?: { 94 | [name: string]: typeof SchemaDirectiveVisitor; 95 | }; 96 | routeOptions?: ServiceRouteOptions; 97 | serverOptions?: Config; 98 | checkActionVisibility?: boolean; 99 | autoUpdateSchema?: boolean; 100 | } 101 | 102 | export function ApolloService(options: ApolloServiceOptions): ServiceSchema; 103 | 104 | export function moleculerGql( 105 | typeString: TemplateStringsArray | string, 106 | ...placeholders: T[] 107 | ): string; 108 | } 109 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * moleculer-apollo-server 3 | * 4 | * Apollo Server for Moleculer API Gateway. 5 | * 6 | * Based on "apollo-server-micro" 7 | * 8 | * https://github.com/apollographql/apollo-server/blob/master/packages/apollo-server-micro/ 9 | * 10 | * 11 | * Copyright (c) 2020 MoleculerJS (https://github.com/moleculerjs/moleculer-apollo-server) 12 | * MIT Licensed 13 | */ 14 | 15 | "use strict"; 16 | 17 | const core = require("apollo-server-core"); 18 | const { GraphQLUpload } = require("graphql-upload"); 19 | const { ApolloServer } = require("./src/ApolloServer"); 20 | const ApolloService = require("./src/service"); 21 | const gql = require("./src/gql"); 22 | 23 | module.exports = { 24 | // Core 25 | GraphQLExtension: core.GraphQLExtension, 26 | gql: core.gql, 27 | ApolloError: core.ApolloError, 28 | toApolloError: core.toApolloError, 29 | SyntaxError: core.SyntaxError, 30 | ValidationError: core.ValidationError, 31 | AuthenticationError: core.AuthenticationError, 32 | ForbiddenError: core.ForbiddenError, 33 | UserInputError: core.UserInputError, 34 | defaultPlaygroundOptions: core.defaultPlaygroundOptions, 35 | 36 | // GraphQL tools 37 | ...require("graphql-tools"), 38 | 39 | // GraphQL Upload 40 | GraphQLUpload, 41 | 42 | // Apollo Server 43 | ApolloServer, 44 | 45 | // Apollo Moleculer Service 46 | ApolloService, 47 | 48 | // Moleculer gql formatter 49 | moleculerGql: gql, 50 | }; 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moleculer-apollo-server", 3 | "version": "0.3.8", 4 | "description": "Apollo GraphQL server for Moleculer API Gateway", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "nodemon examples/index.js", 8 | "ci": "jest --watch", 9 | "test": "jest --coverage", 10 | "ci:integration": "jest \"**/integration/**spec.js\" --watch", 11 | "lint": "eslint --ext=.js src test", 12 | "lint:fix": "eslint --fix --ext=.js src test", 13 | "deps": "npm-check -u", 14 | "postdeps": "npm test", 15 | "coverall": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 16 | }, 17 | "keywords": [ 18 | "graphql", 19 | "apollo-server", 20 | "apollo", 21 | "moleculer", 22 | "microservice", 23 | "gateway" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/moleculerjs/moleculer-apollo-server.git" 28 | }, 29 | "author": "MoleculerJS", 30 | "license": "MIT", 31 | "peerDependencies": { 32 | "graphql": "^14.0.0 || ^15.0.0", 33 | "moleculer": "^0.13.0 || ^0.14.0" 34 | }, 35 | "devDependencies": { 36 | "benchmarkify": "^3.0.0", 37 | "coveralls": "^3.1.1", 38 | "eslint": "^8.24.0", 39 | "eslint-config-prettier": "^8.5.0", 40 | "eslint-plugin-node": "^11.1.0", 41 | "eslint-plugin-prettier": "^4.2.1", 42 | "eslint-plugin-promise": "^6.0.1", 43 | "eslint-plugin-security": "^1.5.0", 44 | "graphql": "^15.5.1", 45 | "jest": "^27.4.7", 46 | "jest-cli": "^27.4.7", 47 | "moleculer": "^0.14.23", 48 | "moleculer-repl": "^0.7.2", 49 | "moleculer-web": "^0.10.4", 50 | "node-fetch": "^2.6.1", 51 | "nodemon": "^2.0.20", 52 | "prettier": "^2.7.1" 53 | }, 54 | "jest": { 55 | "coverageDirectory": "../coverage", 56 | "testEnvironment": "node", 57 | "rootDir": "./src", 58 | "roots": [ 59 | "../test" 60 | ], 61 | "coveragePathIgnorePatterns": [ 62 | "/node_modules/", 63 | "/test/services/" 64 | ] 65 | }, 66 | "engines": { 67 | "node": ">= 10.x.x" 68 | }, 69 | "dependencies": { 70 | "@apollographql/graphql-playground-html": "^1.6.29", 71 | "@hapi/accept": "^3.2.4", 72 | "@types/graphql-upload": "^8.0.11", 73 | "apollo-server-core": "^2.22.2", 74 | "dataloader": "^2.1.0", 75 | "graphql-subscriptions": "^1.2.1", 76 | "graphql-tools": "^7.0.5", 77 | "graphql-upload": "^11.0.0", 78 | "lodash": "^4.17.21", 79 | "object-hash": "^2.2.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/ApolloServer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { ApolloServerBase } = require("apollo-server-core"); 4 | const { processRequest } = require("graphql-upload"); 5 | const { renderPlaygroundPage } = require("@apollographql/graphql-playground-html"); 6 | const accept = require("@hapi/accept"); 7 | const moleculerApollo = require("./moleculerApollo"); 8 | 9 | async function send(req, res, statusCode, data, responseType = "application/json") { 10 | res.statusCode = statusCode; 11 | 12 | const ctx = res.$ctx; 13 | if (!ctx.meta.$responseType) { 14 | ctx.meta.$responseType = responseType; 15 | } 16 | 17 | const route = res.$route; 18 | if (route.onAfterCall) { 19 | data = await route.onAfterCall.call(this, ctx, route, req, res, data); 20 | } 21 | 22 | const service = res.$service; 23 | service.sendResponse(req, res, data); 24 | } 25 | 26 | class ApolloServer extends ApolloServerBase { 27 | // Extract Apollo Server options from the request. 28 | createGraphQLServerOptions(req, res) { 29 | return super.graphQLServerOptions({ req, res }); 30 | } 31 | 32 | // Prepares and returns an async function that can be used to handle 33 | // GraphQL requests. 34 | createHandler({ path, disableHealthCheck, onHealthCheck } = {}) { 35 | const promiseWillStart = this.willStart(); 36 | 37 | return async (req, res) => { 38 | this.graphqlPath = path || "/graphql"; 39 | 40 | await promiseWillStart; 41 | 42 | // If file uploads are detected, prepare them for easier handling with 43 | // the help of `graphql-upload`. 44 | if (this.uploadsConfig) { 45 | const contentType = req.headers["content-type"]; 46 | if (contentType && contentType.startsWith("multipart/form-data")) { 47 | req.filePayload = await processRequest(req, res, this.uploadsConfig); 48 | } 49 | } 50 | 51 | // If health checking is enabled, trigger the `onHealthCheck` 52 | // function when the health check URL is requested. 53 | if (!disableHealthCheck && req.url === "/.well-known/apollo/server-health") 54 | return await this.handleHealthCheck({ req, res, onHealthCheck }); 55 | 56 | // If the `playgroundOptions` are set, register a `graphql-playground` instance 57 | // (not available in production) that is then used to handle all 58 | // incoming GraphQL requests. 59 | if (this.playgroundOptions && req.method === "GET") { 60 | const { mediaTypes } = accept.parseAll(req.headers); 61 | const prefersHTML = 62 | mediaTypes.find(x => x === "text/html" || x === "application/json") === 63 | "text/html"; 64 | 65 | if (prefersHTML) { 66 | const middlewareOptions = Object.assign( 67 | { 68 | endpoint: this.graphqlPath, 69 | subscriptionEndpoint: this.subscriptionsPath, 70 | }, 71 | this.playgroundOptions 72 | ); 73 | return send( 74 | req, 75 | res, 76 | 200, 77 | renderPlaygroundPage(middlewareOptions), 78 | "text/html" 79 | ); 80 | } 81 | } 82 | 83 | // Handle incoming GraphQL requests using Apollo Server. 84 | const graphqlHandler = moleculerApollo(() => this.createGraphQLServerOptions(req, res)); 85 | const responseData = await graphqlHandler(req, res); 86 | return send(req, res, 200, responseData); 87 | }; 88 | } 89 | 90 | // This integration supports file uploads. 91 | supportsUploads() { 92 | return true; 93 | } 94 | 95 | // This integration supports subscriptions. 96 | supportsSubscriptions() { 97 | return true; 98 | } 99 | 100 | async handleHealthCheck({ req, res, onHealthCheck }) { 101 | onHealthCheck = onHealthCheck || (() => undefined); 102 | try { 103 | const result = await onHealthCheck(req); 104 | return send(req, res, 200, { status: "pass", result }, "application/health+json"); 105 | } catch (error) { 106 | const result = error instanceof Error ? error.toString() : error; 107 | return send(req, res, 503, { status: "fail", result }, "application/health+json"); 108 | } 109 | } 110 | } 111 | module.exports = { 112 | ApolloServer, 113 | }; 114 | -------------------------------------------------------------------------------- /src/gql.js: -------------------------------------------------------------------------------- 1 | const { zip } = require("lodash"); 2 | 3 | /** 4 | * @function gql Format graphql strings for usage in moleculer-apollo-server 5 | * @param {TemplateStringsArray} typeString - Template string array for formatting 6 | * @param {...string} placeholders - Placeholder expressions 7 | */ 8 | const gql = (typeString, ...placeholders) => { 9 | // combine template string array and placeholders into a single string 10 | const zipped = zip(typeString, placeholders); 11 | const combinedString = zipped.reduce( 12 | (prev, [next, placeholder]) => `${prev}${next}${placeholder || ""}`, 13 | "", 14 | ); 15 | const re = /type\s+(Query|Mutation|Subscription)\s+{(.*?)}/s; 16 | 17 | const result = re.exec(combinedString); 18 | // eliminate Query/Mutation/Subscription wrapper if present as moleculer-apollo-server will stitch them together 19 | return Array.isArray(result) ? result[2] : combinedString; 20 | }; 21 | 22 | module.exports = gql; 23 | -------------------------------------------------------------------------------- /src/moleculerApollo.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { runHttpQuery, convertNodeHttpToRequest } = require("apollo-server-core"); 4 | const url = require("url"); 5 | 6 | // Utility function used to set multiple headers on a response object. 7 | function setHeaders(res, headers) { 8 | Object.keys(headers).forEach(header => res.setHeader(header, headers[header])); 9 | } 10 | 11 | module.exports = function graphqlMoleculer(options) { 12 | if (!options) { 13 | throw new Error("Apollo Server requires options."); 14 | } 15 | 16 | if (arguments.length > 1) { 17 | throw new Error(`Apollo Server expects exactly one argument, got ${arguments.length}`); 18 | } 19 | 20 | return async function graphqlHandler(req, res) { 21 | let query; 22 | try { 23 | if (req.method === "POST") { 24 | query = req.filePayload || req.body; 25 | } else { 26 | query = url.parse(req.url, true).query; 27 | } 28 | } catch (error) { 29 | // Do nothing; `query` stays `undefined` 30 | } 31 | 32 | try { 33 | const { graphqlResponse, responseInit } = await runHttpQuery([req, res], { 34 | method: req.method, 35 | options, 36 | query, 37 | request: convertNodeHttpToRequest(req), 38 | }); 39 | 40 | setHeaders(res, responseInit.headers); 41 | 42 | return graphqlResponse; 43 | } catch (error) { 44 | if ("HttpQueryError" === error.name && error.headers) { 45 | setHeaders(res, error.headers); 46 | } 47 | 48 | if (!error.statusCode) { 49 | error.statusCode = 500; 50 | } 51 | 52 | res.statusCode = error.statusCode || error.code || 500; 53 | res.end(error.message); 54 | 55 | return undefined; 56 | } 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/service.js: -------------------------------------------------------------------------------- 1 | /* 2 | * moleculer-apollo-server 3 | * Copyright (c) 2020 MoleculerJS (https://github.com/moleculerjs/moleculer-apollo-server) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const _ = require("lodash"); 10 | const { MoleculerServerError } = require("moleculer").Errors; 11 | const { ApolloServer } = require("./ApolloServer"); 12 | const DataLoader = require("dataloader"); 13 | const { makeExecutableSchema } = require("graphql-tools"); 14 | const GraphQL = require("graphql"); 15 | const { PubSub, withFilter } = require("graphql-subscriptions"); 16 | const hash = require("object-hash"); 17 | 18 | module.exports = function (mixinOptions) { 19 | mixinOptions = _.defaultsDeep(mixinOptions, { 20 | routeOptions: { 21 | path: "/graphql", 22 | }, 23 | schema: null, 24 | serverOptions: {}, 25 | createAction: true, 26 | subscriptionEventName: "graphql.publish", 27 | invalidateEventName: "graphql.invalidate", 28 | autoUpdateSchema: true, 29 | checkActionVisibility: false, 30 | }); 31 | 32 | const serviceSchema = { 33 | actions: { 34 | ws: { 35 | timeout: 0, 36 | visibility: "private", 37 | tracing: { 38 | tags: { 39 | params: ["socket.upgradeReq.url"], 40 | }, 41 | spanName: ctx => `UPGRADE ${ctx.params.socket.upgradeReq.url}`, 42 | }, 43 | handler(ctx) { 44 | const { socket, connectionParams } = ctx.params; 45 | return { 46 | $ctx: ctx, 47 | $socket: socket, 48 | $service: this, 49 | $params: { body: connectionParams, query: socket.upgradeReq.query }, 50 | }; 51 | }, 52 | }, 53 | }, 54 | 55 | events: { 56 | [mixinOptions.invalidateEventName]() { 57 | this.invalidateGraphQLSchema(); 58 | }, 59 | "$services.changed"() { 60 | if (mixinOptions.autoUpdateSchema) { 61 | this.invalidateGraphQLSchema(); 62 | } 63 | }, 64 | [mixinOptions.subscriptionEventName](event) { 65 | if (this.pubsub) { 66 | this.pubsub.publish(event.tag, event.payload); 67 | } 68 | }, 69 | }, 70 | 71 | methods: { 72 | /** 73 | * Invalidate the generated GraphQL schema 74 | */ 75 | invalidateGraphQLSchema() { 76 | this.shouldUpdateGraphqlSchema = true; 77 | }, 78 | 79 | /** 80 | * Return the field name in a GraphQL Mutation, Query, or Subscription declaration 81 | * @param {String} declaration - Mutation, Query, or Subscription declaration 82 | * @returns {String} Field name of declaration 83 | */ 84 | getFieldName(declaration) { 85 | // Remove all multi-line/single-line descriptions and comments 86 | const cleanedDeclaration = declaration 87 | .replace(/"([\s\S]*?)"/g, "") 88 | .replace(/^[\s]*?#.*\n?/gm, "") 89 | .trim(); 90 | return cleanedDeclaration.split(/[(:]/g)[0]; 91 | }, 92 | 93 | /** 94 | * Get the full name of a service including version spec. 95 | * 96 | * @param {Service} service - Service object 97 | * @returns {String} Name of service including version spec 98 | */ 99 | getServiceName(service) { 100 | if (service.fullName) return service.fullName; 101 | 102 | if (service.version != null) 103 | return ( 104 | (typeof service.version == "number" 105 | ? "v" + service.version 106 | : service.version) + 107 | "." + 108 | service.name 109 | ); 110 | 111 | return service.name; 112 | }, 113 | 114 | /** 115 | * Get action name for resolver 116 | * 117 | * @param {String} service 118 | * @param {String} action 119 | */ 120 | getResolverActionName(service, action) { 121 | if (action.indexOf(".") === -1) { 122 | return `${service}.${action}`; 123 | } else { 124 | return action; 125 | } 126 | }, 127 | 128 | /** 129 | * Create resolvers from service settings 130 | * 131 | * @param {String} serviceName 132 | * @param {Object} resolvers 133 | */ 134 | createServiceResolvers(serviceName, resolvers) { 135 | return Object.entries(resolvers).reduce((acc, [name, r]) => { 136 | if (_.isPlainObject(r) && r.action != null) { 137 | // matches signature for remote action resolver 138 | acc[name] = this.createActionResolver( 139 | this.getResolverActionName(serviceName, r.action), 140 | r 141 | ); 142 | } else { 143 | // something else (enum, etc.) 144 | acc[name] = r; 145 | } 146 | 147 | return acc; 148 | }, {}); 149 | }, 150 | 151 | /** 152 | * Create resolver for action 153 | * 154 | * @param {String} actionName 155 | * @param {Object?} def 156 | */ 157 | createActionResolver(actionName, def = {}) { 158 | const { 159 | dataLoader: useDataLoader = false, 160 | nullIfError = false, 161 | params: staticParams = {}, 162 | rootParams = {}, 163 | fileUploadArg = null, 164 | } = def; 165 | const rootKeys = Object.keys(rootParams); 166 | 167 | return async (root, args, context) => { 168 | try { 169 | if (useDataLoader) { 170 | const dataLoaderMapKey = this.getDataLoaderMapKey( 171 | actionName, 172 | staticParams, 173 | args 174 | ); 175 | // if a dataLoader batching parameter is specified, then all root params can be data loaded; 176 | // otherwise use only the primary rootParam 177 | const primaryDataLoaderRootKey = rootKeys[0]; // for dataloader, use the first root key only 178 | const dataLoaderBatchParam = this.dataLoaderBatchParams.get(actionName); 179 | const dataLoaderUseAllRootKeys = dataLoaderBatchParam != null; 180 | 181 | // check to see if the DataLoader has already been added to the GraphQL context; if not then add it for subsequent use 182 | let dataLoader; 183 | if (context.dataLoaders.has(dataLoaderMapKey)) { 184 | dataLoader = context.dataLoaders.get(dataLoaderMapKey); 185 | } else { 186 | const batchedParamKey = 187 | dataLoaderBatchParam || rootParams[primaryDataLoaderRootKey]; 188 | 189 | dataLoader = this.buildDataLoader( 190 | context.ctx, 191 | actionName, 192 | batchedParamKey, 193 | staticParams, 194 | args, 195 | { hashCacheKey: dataLoaderUseAllRootKeys } // must hash the cache key if not loading scalar 196 | ); 197 | context.dataLoaders.set(dataLoaderMapKey, dataLoader); 198 | } 199 | 200 | let dataLoaderKey; 201 | if (dataLoaderUseAllRootKeys) { 202 | if (root && rootKeys) { 203 | dataLoaderKey = {}; 204 | 205 | rootKeys.forEach(key => { 206 | _.set(dataLoaderKey, rootParams[key], _.get(root, key)); 207 | }); 208 | } 209 | } else { 210 | dataLoaderKey = root && _.get(root, primaryDataLoaderRootKey); 211 | } 212 | 213 | if (dataLoaderKey == null) { 214 | return null; 215 | } 216 | 217 | return Array.isArray(dataLoaderKey) 218 | ? await dataLoader.loadMany(dataLoaderKey) 219 | : await dataLoader.load(dataLoaderKey); 220 | } else if (fileUploadArg != null && args[fileUploadArg] != null) { 221 | const additionalArgs = _.omit(args, [fileUploadArg]); 222 | 223 | if (Array.isArray(args[fileUploadArg])) { 224 | return await Promise.all( 225 | args[fileUploadArg].map(async uploadPromise => { 226 | const { createReadStream, ...$fileInfo } = 227 | await uploadPromise; 228 | const stream = createReadStream(); 229 | return context.ctx.call(actionName, stream, { 230 | meta: { $fileInfo, $args: additionalArgs }, 231 | }); 232 | }) 233 | ); 234 | } 235 | 236 | const { createReadStream, ...$fileInfo } = await args[fileUploadArg]; 237 | const stream = createReadStream(); 238 | return await context.ctx.call(actionName, stream, { 239 | meta: { $fileInfo, $args: additionalArgs }, 240 | }); 241 | } else { 242 | const params = {}; 243 | let hasRootKeyValue = false; 244 | if (root && rootKeys) { 245 | rootKeys.forEach(key => { 246 | const v = _.get(root, key); 247 | _.set(params, rootParams[key], v); 248 | if (v != null) hasRootKeyValue = true; 249 | }); 250 | 251 | if (def.skipNullKeys && !hasRootKeyValue) { 252 | return null; 253 | } 254 | } 255 | 256 | let mergedParams = _.defaultsDeep({}, args, params, staticParams); 257 | 258 | if (this.prepareContextParams) { 259 | mergedParams = await this.prepareContextParams( 260 | mergedParams, 261 | actionName, 262 | context 263 | ); 264 | } 265 | 266 | return await context.ctx.call(actionName, mergedParams); 267 | } 268 | } catch (err) { 269 | if (nullIfError) { 270 | return null; 271 | } 272 | /* istanbul ignore next */ 273 | if (err && err.ctx) { 274 | err.ctx = null; // Avoid circular JSON in Moleculer <= 0.13 275 | } 276 | throw err; 277 | } 278 | }; 279 | }, 280 | 281 | /** 282 | * Get the unique key assigned to the DataLoader map 283 | * @param {string} actionName - Fully qualified action name to bind to dataloader 284 | * @param {Object.} staticParams - Static parameters to use in dataloader 285 | * @param {Object.} args - Arguments passed to GraphQL child resolver 286 | * @returns {string} Key to the dataloader instance 287 | */ 288 | getDataLoaderMapKey(actionName, staticParams, args) { 289 | if (Object.keys(staticParams).length > 0 || Object.keys(args).length > 0) { 290 | // create a unique hash of the static params and the arguments to ensure a unique DataLoader instance 291 | const actionParams = _.defaultsDeep({}, args, staticParams); 292 | const paramsHash = hash(actionParams); 293 | return `${actionName}:${paramsHash}`; 294 | } 295 | 296 | // if no static params or arguments are present then the action name can serve as the key 297 | return actionName; 298 | }, 299 | 300 | /** 301 | * Build a DataLoader instance 302 | * 303 | * @param {Object} ctx - Moleculer context 304 | * @param {string} actionName - Fully qualified action name to bind to dataloader 305 | * @param {string} batchedParamKey - Parameter key to use for loaded values 306 | * @param {Object} staticParams - Static parameters to use in dataloader 307 | * @param {Object} args - Arguments passed to GraphQL child resolver 308 | * @param {Object} [options={}] - Optional arguments 309 | * @param {Boolean} [options.hashCacheKey=false] - Use a hash for the cacheKeyFn 310 | * @returns {DataLoader} Dataloader instance 311 | */ 312 | buildDataLoader( 313 | ctx, 314 | actionName, 315 | batchedParamKey, 316 | staticParams, 317 | args, 318 | { hashCacheKey = false } = {} 319 | ) { 320 | const batchLoadFn = keys => { 321 | const rootParams = { [batchedParamKey]: keys }; 322 | return ctx.call(actionName, _.defaultsDeep({}, args, rootParams, staticParams)); 323 | }; 324 | 325 | const dataLoaderOptions = this.dataLoaderOptions.get(actionName) || {}; 326 | const cacheKeyFn = hashCacheKey && (key => hash(key)); 327 | const options = { 328 | ...(cacheKeyFn && { cacheKeyFn }), 329 | ...dataLoaderOptions, 330 | }; 331 | 332 | return new DataLoader(batchLoadFn, options); 333 | }, 334 | 335 | /** 336 | * Create resolver for subscription 337 | * 338 | * @param {String} actionName 339 | * @param {Array?} tags 340 | * @param {String?} filter 341 | */ 342 | createAsyncIteratorResolver(actionName, tags = [], filter) { 343 | return { 344 | subscribe: filter 345 | ? withFilter( 346 | () => this.pubsub.asyncIterator(tags), 347 | async (payload, params, { ctx }) => 348 | payload !== undefined 349 | ? ctx.call(filter, { ...params, payload }) 350 | : false 351 | ) 352 | : () => this.pubsub.asyncIterator(tags), 353 | resolve: (payload, params, { ctx }) => 354 | ctx.call(actionName, { ...params, payload }), 355 | }; 356 | }, 357 | 358 | /** 359 | * Generate GraphQL Schema 360 | * 361 | * @param {Object[]} services 362 | * @returns {Object} Generated schema 363 | */ 364 | generateGraphQLSchema(services) { 365 | let str; 366 | try { 367 | let typeDefs = []; 368 | let resolvers = {}; 369 | let schemaDirectives = null; 370 | 371 | if (mixinOptions.typeDefs) { 372 | typeDefs = typeDefs.concat(mixinOptions.typeDefs); 373 | } 374 | 375 | if (mixinOptions.resolvers) { 376 | resolvers = _.cloneDeep(mixinOptions.resolvers); 377 | } 378 | 379 | if (mixinOptions.schemaDirectives) { 380 | schemaDirectives = _.cloneDeep(mixinOptions.schemaDirectives); 381 | } 382 | 383 | let queries = []; 384 | let mutations = []; 385 | let subscriptions = []; 386 | let types = []; 387 | let interfaces = []; 388 | let unions = []; 389 | let enums = []; 390 | let inputs = []; 391 | 392 | const processedServices = new Set(); 393 | 394 | services.forEach(service => { 395 | const serviceName = this.getServiceName(service); 396 | 397 | // Skip multiple instances of services 398 | if (processedServices.has(serviceName)) return; 399 | processedServices.add(serviceName); 400 | 401 | if (service.settings && service.settings.graphql) { 402 | // --- COMPILE SERVICE-LEVEL DEFINITIONS --- 403 | if (_.isObject(service.settings.graphql)) { 404 | const globalDef = service.settings.graphql; 405 | 406 | if (globalDef.query) { 407 | queries = queries.concat(globalDef.query); 408 | } 409 | 410 | if (globalDef.mutation) { 411 | mutations = mutations.concat(globalDef.mutation); 412 | } 413 | 414 | if (globalDef.subscription) { 415 | subscriptions = subscriptions.concat(globalDef.subscription); 416 | } 417 | 418 | if (globalDef.type) { 419 | types = types.concat(globalDef.type); 420 | } 421 | 422 | if (globalDef.interface) { 423 | interfaces = interfaces.concat(globalDef.interface); 424 | } 425 | 426 | if (globalDef.union) { 427 | unions = unions.concat(globalDef.union); 428 | } 429 | 430 | if (globalDef.enum) { 431 | enums = enums.concat(globalDef.enum); 432 | } 433 | 434 | if (globalDef.input) { 435 | inputs = inputs.concat(globalDef.input); 436 | } 437 | 438 | if (globalDef.resolvers) { 439 | resolvers = Object.entries(globalDef.resolvers).reduce( 440 | (acc, [name, resolver]) => { 441 | acc[name] = _.merge( 442 | acc[name] || {}, 443 | this.createServiceResolvers(serviceName, resolver) 444 | ); 445 | return acc; 446 | }, 447 | resolvers 448 | ); 449 | } 450 | } 451 | } 452 | 453 | // --- COMPILE ACTION-LEVEL DEFINITIONS --- 454 | const resolver = {}; 455 | 456 | Object.values(service.actions).forEach(action => { 457 | const { graphql: def } = action; 458 | if ( 459 | mixinOptions.checkActionVisibility && 460 | action.visibility != null && 461 | action.visibility != "published" 462 | ) 463 | return; 464 | 465 | if (def && _.isObject(def)) { 466 | if (def.query) { 467 | if (!resolver["Query"]) resolver.Query = {}; 468 | 469 | _.castArray(def.query).forEach(query => { 470 | const name = this.getFieldName(query); 471 | queries.push(query); 472 | resolver.Query[name] = this.createActionResolver( 473 | action.name 474 | ); 475 | }); 476 | } 477 | 478 | if (def.mutation) { 479 | if (!resolver["Mutation"]) resolver.Mutation = {}; 480 | 481 | _.castArray(def.mutation).forEach(mutation => { 482 | const name = this.getFieldName(mutation); 483 | mutations.push(mutation); 484 | resolver.Mutation[name] = this.createActionResolver( 485 | action.name, 486 | { 487 | fileUploadArg: def.fileUploadArg, 488 | } 489 | ); 490 | }); 491 | } 492 | 493 | if (def.subscription) { 494 | if (!resolver["Subscription"]) resolver.Subscription = {}; 495 | 496 | _.castArray(def.subscription).forEach(subscription => { 497 | const name = this.getFieldName(subscription); 498 | subscriptions.push(subscription); 499 | resolver.Subscription[name] = 500 | this.createAsyncIteratorResolver( 501 | action.name, 502 | def.tags, 503 | def.filter 504 | ); 505 | }); 506 | } 507 | 508 | if (def.type) { 509 | types = types.concat(def.type); 510 | } 511 | 512 | if (def.interface) { 513 | interfaces = interfaces.concat(def.interface); 514 | } 515 | 516 | if (def.union) { 517 | unions = unions.concat(def.union); 518 | } 519 | 520 | if (def.enum) { 521 | enums = enums.concat(def.enum); 522 | } 523 | 524 | if (def.input) { 525 | inputs = inputs.concat(def.input); 526 | } 527 | } 528 | }); 529 | 530 | if (Object.keys(resolver).length > 0) { 531 | resolvers = _.merge(resolvers, resolver); 532 | } 533 | }); 534 | 535 | if ( 536 | queries.length > 0 || 537 | types.length > 0 || 538 | mutations.length > 0 || 539 | subscriptions.length > 0 || 540 | interfaces.length > 0 || 541 | unions.length > 0 || 542 | enums.length > 0 || 543 | inputs.length > 0 544 | ) { 545 | str = ""; 546 | if (queries.length > 0) { 547 | str += ` 548 | type Query { 549 | ${queries.join("\n")} 550 | } 551 | `; 552 | } 553 | 554 | if (mutations.length > 0) { 555 | str += ` 556 | type Mutation { 557 | ${mutations.join("\n")} 558 | } 559 | `; 560 | } 561 | 562 | if (subscriptions.length > 0) { 563 | str += ` 564 | type Subscription { 565 | ${subscriptions.join("\n")} 566 | } 567 | `; 568 | } 569 | 570 | if (types.length > 0) { 571 | str += ` 572 | ${types.join("\n")} 573 | `; 574 | } 575 | 576 | if (interfaces.length > 0) { 577 | str += ` 578 | ${interfaces.join("\n")} 579 | `; 580 | } 581 | 582 | if (unions.length > 0) { 583 | str += ` 584 | ${unions.join("\n")} 585 | `; 586 | } 587 | 588 | if (enums.length > 0) { 589 | str += ` 590 | ${enums.join("\n")} 591 | `; 592 | } 593 | 594 | if (inputs.length > 0) { 595 | str += ` 596 | ${inputs.join("\n")} 597 | `; 598 | } 599 | 600 | typeDefs.push(str); 601 | } 602 | 603 | return this.makeExecutableSchema({ typeDefs, resolvers, schemaDirectives }); 604 | } catch (err) { 605 | throw new MoleculerServerError( 606 | "Unable to compile GraphQL schema", 607 | 500, 608 | "UNABLE_COMPILE_GRAPHQL_SCHEMA", 609 | { err, str } 610 | ); 611 | } 612 | }, 613 | 614 | /** 615 | * Call the `makeExecutableSchema`. If you would like 616 | * to manipulate the concatenated typeDefs, or the generated schema, 617 | * just overwrite it in your service file. 618 | * @param {Object} schemaDef 619 | */ 620 | makeExecutableSchema(schemaDef) { 621 | return makeExecutableSchema(schemaDef); 622 | }, 623 | 624 | /** 625 | * Create PubSub instance. 626 | */ 627 | createPubSub() { 628 | return new PubSub(); 629 | }, 630 | 631 | /** 632 | * Prepare GraphQL schemas based on Moleculer services. 633 | */ 634 | async prepareGraphQLSchema() { 635 | // Schema is up-to-date 636 | if (!this.shouldUpdateGraphqlSchema && this.graphqlHandler) { 637 | return; 638 | } 639 | 640 | if (this.apolloServer) { 641 | await this.apolloServer.stop(); 642 | } 643 | 644 | // Create new server & regenerate GraphQL schema 645 | this.logger.info( 646 | "♻ Recreate Apollo GraphQL server and regenerate GraphQL schema..." 647 | ); 648 | 649 | try { 650 | this.pubsub = this.createPubSub(); 651 | const services = this.broker.registry.getServiceList({ withActions: true }); 652 | const schema = this.generateGraphQLSchema(services); 653 | 654 | this.logger.debug( 655 | "Generated GraphQL schema:\n\n" + GraphQL.printSchema(schema) 656 | ); 657 | 658 | this.apolloServer = new ApolloServer({ 659 | schema, 660 | ..._.defaultsDeep({}, mixinOptions.serverOptions, { 661 | context: ({ req, connection }) => ({ 662 | ...(req 663 | ? { 664 | ctx: req.$ctx, 665 | service: req.$service, 666 | params: req.$params, 667 | } 668 | : { 669 | ctx: connection.context.$ctx, 670 | service: connection.context.$service, 671 | params: connection.context.$params, 672 | }), 673 | dataLoaders: new Map(), // create an empty map to load DataLoader instances into 674 | }), 675 | subscriptions: { 676 | onConnect: (connectionParams, socket) => 677 | this.actions.ws({ connectionParams, socket }), 678 | }, 679 | }), 680 | }); 681 | 682 | this.graphqlHandler = this.apolloServer.createHandler( 683 | mixinOptions.serverOptions 684 | ); 685 | 686 | if (mixinOptions.serverOptions.subscriptions !== false) { 687 | // Avoid installing the subscription handlers if they have been disabled 688 | this.apolloServer.installSubscriptionHandlers(this.server); 689 | } 690 | 691 | this.graphqlSchema = schema; 692 | 693 | this.buildLoaderOptionMap(services); // rebuild the options for DataLoaders 694 | 695 | this.shouldUpdateGraphqlSchema = false; 696 | 697 | this.broker.broadcast("graphql.schema.updated", { 698 | schema: GraphQL.printSchema(schema), 699 | }); 700 | } catch (err) { 701 | this.logger.error(err); 702 | throw err; 703 | } 704 | }, 705 | 706 | /** 707 | * Build a map of options to use with DataLoader 708 | * 709 | * @param {Object[]} services 710 | * @modifies {this.dataLoaderOptions} 711 | * @modifies {this.dataLoaderBatchParams} 712 | */ 713 | buildLoaderOptionMap(services) { 714 | this.dataLoaderOptions.clear(); // clear map before rebuilding 715 | this.dataLoaderBatchParams.clear(); // clear map before rebuilding 716 | 717 | services.forEach(service => { 718 | Object.values(service.actions).forEach(action => { 719 | const { graphql: graphqlDefinition, name: actionName } = action; 720 | if ( 721 | graphqlDefinition && 722 | (graphqlDefinition.dataLoaderOptions || 723 | graphqlDefinition.dataLoaderBatchParam) 724 | ) { 725 | const serviceName = this.getServiceName(service); 726 | const fullActionName = this.getResolverActionName( 727 | serviceName, 728 | actionName 729 | ); 730 | 731 | if (graphqlDefinition.dataLoaderOptions) { 732 | this.dataLoaderOptions.set( 733 | fullActionName, 734 | graphqlDefinition.dataLoaderOptions 735 | ); 736 | } 737 | 738 | if (graphqlDefinition.dataLoaderBatchParam) { 739 | this.dataLoaderBatchParams.set( 740 | fullActionName, 741 | graphqlDefinition.dataLoaderBatchParam 742 | ); 743 | } 744 | } 745 | }); 746 | }); 747 | }, 748 | }, 749 | 750 | created() { 751 | this.apolloServer = null; 752 | this.graphqlHandler = null; 753 | this.graphqlSchema = null; 754 | this.pubsub = null; 755 | this.shouldUpdateGraphqlSchema = true; 756 | this.dataLoaderOptions = new Map(); 757 | this.dataLoaderBatchParams = new Map(); 758 | 759 | // Bind service to onConnect method 760 | if ( 761 | mixinOptions.serverOptions.subscriptions && 762 | _.isFunction(mixinOptions.serverOptions.subscriptions.onConnect) 763 | ) { 764 | mixinOptions.serverOptions.subscriptions.onConnect = 765 | mixinOptions.serverOptions.subscriptions.onConnect.bind(this); 766 | } 767 | 768 | const route = _.defaultsDeep(mixinOptions.routeOptions, { 769 | aliases: { 770 | async "/"(req, res) { 771 | try { 772 | await this.prepareGraphQLSchema(); 773 | return await this.graphqlHandler(req, res); 774 | } catch (err) { 775 | this.sendError(req, res, err); 776 | } 777 | }, 778 | async "GET /.well-known/apollo/server-health"(req, res) { 779 | try { 780 | await this.prepareGraphQLSchema(); 781 | return await this.graphqlHandler(req, res); 782 | } catch (err) { 783 | res.statusCode = 503; 784 | return this.sendResponse( 785 | req, 786 | res, 787 | { status: "fail", schema: false }, 788 | { responseType: "application/health+json" } 789 | ); 790 | } 791 | }, 792 | }, 793 | 794 | mappingPolicy: "restrict", 795 | 796 | bodyParsers: { 797 | json: true, 798 | urlencoded: { extended: true }, 799 | }, 800 | }); 801 | 802 | // Add route 803 | this.settings.routes.unshift(route); 804 | }, 805 | 806 | started() { 807 | this.logger.info(`🚀 GraphQL server is available at ${mixinOptions.routeOptions.path}`); 808 | }, 809 | }; 810 | 811 | if (mixinOptions.createAction) { 812 | serviceSchema.actions = { 813 | ...serviceSchema.actions, 814 | graphql: { 815 | params: { 816 | query: { type: "string" }, 817 | variables: { type: "object", optional: true }, 818 | }, 819 | async handler(ctx) { 820 | await this.prepareGraphQLSchema(); 821 | return GraphQL.graphql( 822 | this.graphqlSchema, 823 | ctx.params.query, 824 | null, 825 | { ctx }, 826 | ctx.params.variables 827 | ); 828 | }, 829 | }, 830 | }; 831 | } 832 | 833 | return serviceSchema; 834 | }; 835 | -------------------------------------------------------------------------------- /test/integration/greeter.spec.js: -------------------------------------------------------------------------------- 1 | const { ServiceBroker } = require("moleculer"); 2 | const { MoleculerClientError } = require("moleculer").Errors; 3 | 4 | const ApiGateway = require("moleculer-web"); 5 | const { ApolloService } = require("../../index"); 6 | 7 | const fetch = require("node-fetch"); 8 | 9 | describe("Integration test for greeter service", () => { 10 | const broker = new ServiceBroker({ logger: false }); 11 | 12 | let port; 13 | const apiSvc = broker.createService({ 14 | name: "api", 15 | 16 | mixins: [ 17 | // Gateway 18 | ApiGateway, 19 | 20 | // GraphQL Apollo Server 21 | ApolloService({ 22 | // API Gateway route options 23 | routeOptions: { 24 | path: "/graphql", 25 | cors: true, 26 | mappingPolicy: "restrict", 27 | }, 28 | 29 | checkActionVisibility: true, 30 | 31 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 32 | serverOptions: {}, 33 | }), 34 | ], 35 | 36 | settings: { 37 | ip: "0.0.0.0", 38 | port: 0, // Random 39 | }, 40 | 41 | methods: { 42 | prepareContextParams(params, actionName) { 43 | if (actionName === "greeter.replace" && params.input) { 44 | return params.input; 45 | } 46 | return params; 47 | }, 48 | }, 49 | }); 50 | 51 | broker.createService({ 52 | name: "greeter", 53 | 54 | actions: { 55 | hello: { 56 | graphql: { 57 | query: "hello: String!", 58 | }, 59 | handler() { 60 | return "Hello Moleculer!"; 61 | }, 62 | }, 63 | welcome: { 64 | graphql: { 65 | query: ` 66 | welcome(name: String!): String! 67 | `, 68 | }, 69 | handler(ctx) { 70 | return `Hello ${ctx.params.name}`; 71 | }, 72 | }, 73 | /*update: { 74 | graphql: { 75 | subscription: "update: String!", 76 | tags: ["TEST"], 77 | }, 78 | handler(ctx) { 79 | return ctx.params.payload; 80 | }, 81 | },*/ 82 | 83 | replace: { 84 | graphql: { 85 | input: `input GreeterInput { 86 | name: String! 87 | }`, 88 | type: `type GreeterOutput { 89 | name: String 90 | }`, 91 | mutation: "replace(input: GreeterInput!): GreeterOutput", 92 | }, 93 | handler(ctx) { 94 | return ctx.params; 95 | }, 96 | }, 97 | 98 | danger: { 99 | graphql: { 100 | query: "danger: String!", 101 | }, 102 | async handler() { 103 | throw new MoleculerClientError( 104 | "I've said it's a danger action!", 105 | 422, 106 | "DANGER" 107 | ); 108 | }, 109 | }, 110 | 111 | secret: { 112 | visibility: "protected", 113 | graphql: { 114 | query: "secret: String!", 115 | }, 116 | async handler() { 117 | return "! TOP SECRET !"; 118 | }, 119 | }, 120 | }, 121 | }); 122 | 123 | beforeAll(async () => { 124 | await broker.start(); 125 | port = apiSvc.server.address().port; 126 | }); 127 | afterAll(() => broker.stop()); 128 | 129 | it("should call the greeter.hello action", async () => { 130 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 131 | method: "post", 132 | body: JSON.stringify({ 133 | operationName: null, 134 | variables: {}, 135 | query: "{ hello }", 136 | }), 137 | headers: { "Content-Type": "application/json" }, 138 | }); 139 | 140 | expect(res.status).toBe(200); 141 | expect(await res.json()).toStrictEqual({ 142 | data: { 143 | hello: "Hello Moleculer!", 144 | }, 145 | }); 146 | }); 147 | 148 | it("should call the greeter.welcome action with parameter", async () => { 149 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 150 | method: "post", 151 | body: JSON.stringify({ 152 | operationName: null, 153 | variables: {}, 154 | query: 'query { welcome(name: "GraphQL") }', 155 | }), 156 | headers: { "Content-Type": "application/json" }, 157 | }); 158 | 159 | expect(res.status).toBe(200); 160 | expect(await res.json()).toStrictEqual({ 161 | data: { 162 | welcome: "Hello GraphQL", 163 | }, 164 | }); 165 | }); 166 | 167 | it("should call the greeter.welcome action with query variable", async () => { 168 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 169 | method: "post", 170 | body: JSON.stringify({ 171 | operationName: null, 172 | variables: { name: "Moleculer GraphQL" }, 173 | query: "query ($name: String!) { welcome(name: $name) }", 174 | }), 175 | headers: { "Content-Type": "application/json" }, 176 | }); 177 | 178 | expect(res.status).toBe(200); 179 | expect(await res.json()).toStrictEqual({ 180 | data: { 181 | welcome: "Hello Moleculer GraphQL", 182 | }, 183 | }); 184 | }); 185 | 186 | it("should call the greeter.welcome action with wrapped input params", async () => { 187 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 188 | method: "post", 189 | body: JSON.stringify({ 190 | operationName: null, 191 | variables: { name: "Moleculer GraphQL" }, 192 | query: "mutation ($name: String!) { replace(input: { name: $name }) { name } }", 193 | }), 194 | headers: { "Content-Type": "application/json" }, 195 | }); 196 | 197 | expect(res.status).toBe(200); 198 | expect(await res.json()).toStrictEqual({ 199 | data: { 200 | replace: { 201 | name: "Moleculer GraphQL", 202 | }, 203 | }, 204 | }); 205 | }); 206 | 207 | it("should call the greeter.danger and receives an error", async () => { 208 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 209 | method: "post", 210 | body: JSON.stringify({ 211 | operationName: null, 212 | variables: {}, 213 | query: "query { danger }", 214 | }), 215 | headers: { "Content-Type": "application/json" }, 216 | }); 217 | 218 | expect(res.status).toBe(200); 219 | expect(await res.json()).toStrictEqual({ 220 | data: null, 221 | errors: [ 222 | { 223 | extensions: { 224 | code: "INTERNAL_SERVER_ERROR", 225 | exception: { 226 | code: 422, 227 | retryable: false, 228 | type: "DANGER", 229 | }, 230 | }, 231 | locations: [ 232 | { 233 | column: 9, 234 | line: 1, 235 | }, 236 | ], 237 | message: "I've said it's a danger action!", 238 | path: ["danger"], 239 | }, 240 | ], 241 | }); 242 | }); 243 | 244 | it("should not call the greeter.secret", async () => { 245 | const res = await fetch(`http://127.0.0.1:${port}/graphql`, { 246 | method: "post", 247 | body: JSON.stringify({ 248 | operationName: null, 249 | variables: {}, 250 | query: "query { danger }", 251 | }), 252 | headers: { "Content-Type": "application/json" }, 253 | }); 254 | 255 | expect(res.status).toBe(200); 256 | expect(await res.json()).toStrictEqual({ 257 | data: null, 258 | errors: [ 259 | { 260 | extensions: { 261 | code: "INTERNAL_SERVER_ERROR", 262 | exception: { 263 | code: 422, 264 | retryable: false, 265 | type: "DANGER", 266 | }, 267 | }, 268 | locations: [ 269 | { 270 | column: 9, 271 | line: 1, 272 | }, 273 | ], 274 | message: "I've said it's a danger action!", 275 | path: ["danger"], 276 | }, 277 | ], 278 | }); 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /test/unit/ApolloServer.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | jest.mock("apollo-server-core"); 4 | const { ApolloServerBase } = require("apollo-server-core"); 5 | 6 | jest.mock("graphql-upload"); 7 | const GraphqlUpload = require("graphql-upload"); 8 | 9 | jest.mock("@apollographql/graphql-playground-html"); 10 | const Playground = require("@apollographql/graphql-playground-html"); 11 | 12 | jest.mock("../../src/moleculerApollo"); 13 | const moleculerApollo = require("../../src/moleculerApollo"); 14 | 15 | const ApolloServer = require("../../src/ApolloServer").ApolloServer; 16 | 17 | //ApolloServerCore.convertNodeHttpToRequest.mockImplementation(() => "convertedRequest"); 18 | 19 | describe("Test ApolloServer", () => { 20 | test("should support Uploads", () => { 21 | const apolloServer = new ApolloServer({}); 22 | expect(apolloServer.supportsUploads()).toBe(true); 23 | }); 24 | 25 | test("should support subscriptions", () => { 26 | const apolloServer = new ApolloServer({}); 27 | expect(apolloServer.supportsSubscriptions()).toBe(true); 28 | }); 29 | 30 | test("should call super graphQLServerOptions", () => { 31 | const apolloServer = new ApolloServer({}); 32 | ApolloServerBase.prototype.graphQLServerOptions = jest.fn(); 33 | 34 | apolloServer.createGraphQLServerOptions("req", "res"); 35 | 36 | expect(ApolloServerBase.prototype.graphQLServerOptions).toBeCalledTimes(1); 37 | expect(ApolloServerBase.prototype.graphQLServerOptions).toBeCalledWith({ 38 | req: "req", 39 | res: "res", 40 | }); 41 | }); 42 | 43 | describe("Test healthcheck handler", () => { 44 | const apolloServer = new ApolloServer({}); 45 | 46 | const fakeCtx = { 47 | meta: {}, 48 | }; 49 | const fakeService = { 50 | sendResponse: jest.fn(), 51 | }; 52 | 53 | const fakeReq = {}; 54 | const fakeRes = { 55 | $ctx: fakeCtx, 56 | $service: fakeService, 57 | $route: {}, 58 | }; 59 | 60 | test("should return 200 'pass'", async () => { 61 | const onHealthCheck = jest.fn(() => "Everything OK"); 62 | 63 | await apolloServer.handleHealthCheck({ req: fakeReq, res: fakeRes, onHealthCheck }); 64 | 65 | expect(onHealthCheck).toBeCalledTimes(1); 66 | expect(onHealthCheck).toBeCalledWith(fakeReq); 67 | 68 | expect(fakeRes.statusCode).toBe(200); 69 | 70 | expect(fakeService.sendResponse).toBeCalledTimes(1); 71 | expect(fakeService.sendResponse).toBeCalledWith(fakeReq, fakeRes, { 72 | result: "Everything OK", 73 | status: "pass", 74 | }); 75 | 76 | expect(fakeCtx.meta.$responseType).toBe("application/health+json"); 77 | }); 78 | 79 | test("should return 503 'fail'", async () => { 80 | fakeService.sendResponse.mockClear(); 81 | 82 | const onHealthCheck = jest.fn(() => Promise.reject(new Error("Something wrong"))); 83 | 84 | await apolloServer.handleHealthCheck({ req: fakeReq, res: fakeRes, onHealthCheck }); 85 | 86 | expect(onHealthCheck).toBeCalledTimes(1); 87 | expect(onHealthCheck).toBeCalledWith(fakeReq); 88 | 89 | expect(fakeRes.statusCode).toBe(503); 90 | 91 | expect(fakeService.sendResponse).toBeCalledTimes(1); 92 | expect(fakeService.sendResponse).toBeCalledWith(fakeReq, fakeRes, { 93 | result: "Error: Something wrong", 94 | status: "fail", 95 | }); 96 | 97 | expect(fakeCtx.meta.$responseType).toBe("application/health+json"); 98 | }); 99 | 100 | test("should call an empty healthcheck function", async () => { 101 | fakeService.sendResponse.mockClear(); 102 | fakeCtx.meta.$responseType = "application/json"; 103 | 104 | const onHealthCheck = null; 105 | 106 | await apolloServer.handleHealthCheck({ req: fakeReq, res: fakeRes, onHealthCheck }); 107 | 108 | expect(fakeRes.statusCode).toBe(200); 109 | 110 | expect(fakeService.sendResponse).toBeCalledTimes(1); 111 | expect(fakeService.sendResponse).toBeCalledWith(fakeReq, fakeRes, { 112 | result: undefined, 113 | status: "pass", 114 | }); 115 | 116 | expect(fakeCtx.meta.$responseType).toBe("application/json"); 117 | }); 118 | }); 119 | 120 | describe("Test createHandler", () => { 121 | const apolloServer = new ApolloServer({}); 122 | apolloServer.createGraphQLServerOptions = jest.fn(); 123 | apolloServer.willStart = jest.fn(() => Promise.resolve()); 124 | const fakeGraphqlHandler = jest.fn(() => Promise.resolve("GraphQL Response Data")); 125 | moleculerApollo.mockImplementation(() => fakeGraphqlHandler); 126 | 127 | const fakeCtx = { 128 | meta: {}, 129 | }; 130 | const fakeService = { 131 | sendResponse: jest.fn(), 132 | }; 133 | 134 | let fakeReq; 135 | let fakeRes; 136 | 137 | beforeEach(() => { 138 | fakeReq = { 139 | headers: {}, 140 | }; 141 | fakeRes = { 142 | $ctx: fakeCtx, 143 | $service: fakeService, 144 | $route: {}, 145 | }; 146 | }); 147 | 148 | test("should handle as a request", async () => { 149 | const handler = apolloServer.createHandler(); 150 | 151 | await handler(fakeReq, fakeRes); 152 | 153 | expect(moleculerApollo).toBeCalledTimes(1); 154 | expect(moleculerApollo).toBeCalledWith(expect.any(Function)); 155 | 156 | expect(fakeGraphqlHandler).toBeCalledTimes(1); 157 | expect(fakeGraphqlHandler).toBeCalledWith(fakeReq, fakeRes); 158 | 159 | expect(fakeRes.statusCode).toBe(200); 160 | 161 | expect(fakeService.sendResponse).toBeCalledTimes(1); 162 | expect(fakeService.sendResponse).toBeCalledWith( 163 | fakeReq, 164 | fakeRes, 165 | "GraphQL Response Data" 166 | ); 167 | 168 | expect(fakeCtx.meta.$responseType).toBe("application/json"); 169 | 170 | // Call "moleculerApollo" first argument 171 | moleculerApollo.mock.calls[0][0](); 172 | expect(apolloServer.createGraphQLServerOptions).toBeCalledTimes(1); 173 | expect(apolloServer.createGraphQLServerOptions).toBeCalledWith(fakeReq, fakeRes); 174 | }); 175 | 176 | test("should call onAfterCall function", async () => { 177 | const handler = apolloServer.createHandler(); 178 | const onAfterCall = jest.fn(); 179 | const $route = { onAfterCall }; 180 | fakeRes.$route = $route; 181 | 182 | await handler(fakeReq, fakeRes); 183 | 184 | expect(onAfterCall).toHaveBeenCalledTimes(1); 185 | expect(onAfterCall).toHaveBeenCalledWith( 186 | fakeCtx, 187 | $route, 188 | fakeReq, 189 | fakeRes, 190 | "GraphQL Response Data" 191 | ); 192 | }); 193 | 194 | test("should handle as a file upload request", async () => { 195 | // Clear mocks 196 | moleculerApollo.mockClear(); 197 | fakeGraphqlHandler.mockClear(); 198 | fakeService.sendResponse.mockClear(); 199 | apolloServer.createGraphQLServerOptions.mockClear(); 200 | GraphqlUpload.processRequest.mockImplementation(() => Promise.resolve("file upload")); 201 | 202 | // Init mocks 203 | apolloServer.uploadsConfig = { a: 5 }; 204 | fakeReq.headers["content-type"] = "multipart/form-data"; 205 | 206 | // Create handler 207 | const handler = apolloServer.createHandler(); 208 | 209 | // Call handler 210 | await handler(fakeReq, fakeRes); 211 | 212 | // Assertions 213 | expect(fakeReq.filePayload).toBe("file upload"); 214 | expect(GraphqlUpload.processRequest).toBeCalledTimes(1); 215 | expect(GraphqlUpload.processRequest).toBeCalledWith( 216 | fakeReq, 217 | fakeRes, 218 | apolloServer.uploadsConfig 219 | ); 220 | 221 | expect(moleculerApollo).toBeCalledTimes(1); 222 | expect(moleculerApollo).toBeCalledWith(expect.any(Function)); 223 | 224 | expect(fakeGraphqlHandler).toBeCalledTimes(1); 225 | expect(fakeGraphqlHandler).toBeCalledWith(fakeReq, fakeRes); 226 | 227 | expect(fakeRes.statusCode).toBe(200); 228 | 229 | expect(fakeService.sendResponse).toBeCalledTimes(1); 230 | expect(fakeService.sendResponse).toBeCalledWith( 231 | fakeReq, 232 | fakeRes, 233 | "GraphQL Response Data" 234 | ); 235 | 236 | expect(fakeCtx.meta.$responseType).toBe("application/json"); 237 | }); 238 | 239 | test("should handle as health-check request", async () => { 240 | // Clear mocks 241 | moleculerApollo.mockClear(); 242 | fakeGraphqlHandler.mockClear(); 243 | fakeService.sendResponse.mockClear(); 244 | apolloServer.createGraphQLServerOptions.mockClear(); 245 | GraphqlUpload.processRequest.mockClear(); 246 | jest.spyOn(apolloServer, "handleHealthCheck"); 247 | 248 | // Init mocks 249 | fakeReq.url = "/.well-known/apollo/server-health"; 250 | const onHealthCheck = jest.fn(); 251 | 252 | // Create handler 253 | const handler = apolloServer.createHandler({ onHealthCheck }); 254 | 255 | // Call handler 256 | await handler(fakeReq, fakeRes); 257 | 258 | // Assertions 259 | expect(apolloServer.handleHealthCheck).toBeCalledTimes(1); 260 | expect(apolloServer.handleHealthCheck).toBeCalledWith({ 261 | req: fakeReq, 262 | res: fakeRes, 263 | onHealthCheck, 264 | }); 265 | 266 | expect(moleculerApollo).toBeCalledTimes(0); 267 | }); 268 | 269 | test("should not handle as health-check request if disabled", async () => { 270 | // Clear mocks 271 | moleculerApollo.mockClear(); 272 | fakeGraphqlHandler.mockClear(); 273 | fakeService.sendResponse.mockClear(); 274 | apolloServer.createGraphQLServerOptions.mockClear(); 275 | GraphqlUpload.processRequest.mockClear(); 276 | apolloServer.handleHealthCheck.mockClear(); 277 | 278 | // Init mocks 279 | fakeReq.url = "/.well-known/apollo/server-health"; 280 | const onHealthCheck = jest.fn(); 281 | 282 | // Create handler 283 | const handler = apolloServer.createHandler({ disableHealthCheck: true, onHealthCheck }); 284 | 285 | // Call handler 286 | await handler(fakeReq, fakeRes); 287 | 288 | // Assertions 289 | expect(apolloServer.handleHealthCheck).toBeCalledTimes(0); 290 | expect(moleculerApollo).toBeCalledTimes(1); 291 | }); 292 | 293 | test("should handle as playground request", async () => { 294 | // Clear mocks 295 | moleculerApollo.mockClear(); 296 | fakeService.sendResponse.mockClear(); 297 | Playground.renderPlaygroundPage.mockImplementation(() => "playground-page"); 298 | 299 | // Init mocks 300 | apolloServer.playgroundOptions = { 301 | b: "John", 302 | }; 303 | apolloServer.subscriptionsPath = "/subscription"; 304 | fakeCtx.meta.$responseType = null; 305 | fakeReq.url = "/graphql"; 306 | fakeReq.method = "GET"; 307 | fakeReq.headers = { 308 | accept: "text/html", 309 | }; 310 | 311 | // Create handler 312 | const handler = apolloServer.createHandler(); 313 | 314 | // Call handler 315 | await handler(fakeReq, fakeRes); 316 | 317 | // Assertions 318 | expect(moleculerApollo).toBeCalledTimes(0); 319 | 320 | expect(Playground.renderPlaygroundPage).toBeCalledTimes(1); 321 | expect(Playground.renderPlaygroundPage).toBeCalledWith({ 322 | endpoint: "/graphql", 323 | subscriptionEndpoint: "/subscription", 324 | b: "John", 325 | }); 326 | 327 | expect(fakeRes.statusCode).toBe(200); 328 | 329 | expect(fakeService.sendResponse).toBeCalledTimes(1); 330 | expect(fakeService.sendResponse).toBeCalledWith(fakeReq, fakeRes, "playground-page"); 331 | 332 | expect(fakeCtx.meta.$responseType).toBe("text/html"); 333 | }); 334 | }); 335 | }); 336 | -------------------------------------------------------------------------------- /test/unit/__snapshots__/service.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test Service Test 'generateGraphQLSchema' should create a schema with global, service & action definitions 1`] = ` 4 | Object { 5 | "resolvers": Object { 6 | "Date": Object { 7 | "__parseValue": [Function], 8 | "__serialize": [Function], 9 | }, 10 | "Mutation": Object { 11 | "upvote": [Function], 12 | }, 13 | "Post": Object { 14 | "author": [Function], 15 | "voters": [Function], 16 | }, 17 | "Query": Object { 18 | "posts": [Function], 19 | "users": [Function], 20 | }, 21 | "Subscription": Object { 22 | "vote": Object { 23 | "resolve": [Function], 24 | "subscribe": Array [ 25 | [Function], 26 | [Function], 27 | ], 28 | }, 29 | }, 30 | "User": Object { 31 | "postCount": [Function], 32 | "posts": [Function], 33 | }, 34 | "UserType": Object { 35 | "ADMIN": "1", 36 | "PUBLISHER": "2", 37 | "READER": "3", 38 | }, 39 | }, 40 | "schemaDirectives": null, 41 | "typeDefs": Array [ 42 | " 43 | scalar Date 44 | ", 45 | " 46 | type Query { 47 | 48 | categories(): [String] 49 | 50 | posts(limit: Int): [Post] 51 | 52 | users(limit: Int): [User] 53 | 54 | } 55 | 56 | type Mutation { 57 | 58 | addCategory(name: String!): String 59 | 60 | upvote(input: PostVoteInput): Post 61 | } 62 | 63 | type Subscription { 64 | 65 | categoryChanges(): String! 66 | 67 | 68 | vote(userID: Int!): String! 69 | 70 | } 71 | 72 | 73 | type Post { 74 | id: Int! 75 | title: String! 76 | author: User! 77 | votes: Int! 78 | voters: [User] 79 | createdAt: Timestamp 80 | error: String 81 | } 82 | 83 | 84 | type VoteInfo { 85 | votes: Int!, 86 | voters: [User] 87 | } 88 | 89 | 90 | \\"\\"\\" 91 | This type describes a user entity. 92 | \\"\\"\\" 93 | type User { 94 | id: Int! 95 | name: String! 96 | birthday: Date 97 | posts(limit: Int): [Post] 98 | postCount: Int 99 | type: UserType 100 | } 101 | 102 | 103 | 104 | interface Book { 105 | title: String 106 | author: Author 107 | } 108 | 109 | 110 | 111 | union Result = User | Author 112 | 113 | 114 | 115 | enum VoteType { 116 | VOTE_UP, 117 | VOTE_DOWN 118 | } 119 | 120 | 121 | \\"\\"\\" 122 | Enumerations for user types 123 | \\"\\"\\" 124 | enum UserType { 125 | ADMIN 126 | PUBLISHER 127 | READER 128 | } 129 | 130 | 131 | 132 | input PostVoteInput { 133 | id: Int!, 134 | userID: Int! 135 | } 136 | 137 | 138 | input PostAndMediaInput { 139 | title: String 140 | body: String 141 | mediaUrls: [String] 142 | } 143 | 144 | ", 145 | ], 146 | } 147 | `; 148 | -------------------------------------------------------------------------------- /test/unit/gql.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const gql = require("../../src/gql"); 4 | 5 | // prettier-ignore 6 | describe("Test gql", () => { 7 | it("should format Query", () => { 8 | expect(gql` 9 | type Query { 10 | posts(limit: Int): [Post] 11 | } 12 | `).toBe(` 13 | posts(limit: Int): [Post] 14 | `); 15 | 16 | expect( 17 | gql`type Query { posts(limit: Int): [Post] }`, 18 | ).toBe(" posts(limit: Int): [Post] "); 19 | }); 20 | 21 | it("should format Mutation", () => { 22 | expect(gql` 23 | type Mutation { 24 | upvote(id: Int!, userID: Int!): Post 25 | } 26 | `).toBe(` 27 | upvote(id: Int!, userID: Int!): Post 28 | `); 29 | }); 30 | 31 | it("should format Subscription", () => { 32 | expect(gql` 33 | type Subscription { 34 | vote(userID: Int!): String! 35 | } 36 | `).toBe(` 37 | vote(userID: Int!): String! 38 | `); 39 | }); 40 | 41 | it("should not format", () => { 42 | expect(gql`posts(limit: Int): [Post]`).toBe("posts(limit: Int): [Post]"); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/unit/index.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const MolApolloServer = require("../../"); 4 | 5 | describe("Test ApolloService exports", () => { 6 | it("should export ApolloServerCore classes", () => { 7 | expect(MolApolloServer.GraphQLUpload).toBeDefined(); 8 | expect(MolApolloServer.GraphQLExtension).toBeDefined(); 9 | expect(MolApolloServer.gql).toBeDefined(); 10 | expect(MolApolloServer.ApolloError).toBeDefined(); 11 | expect(MolApolloServer.toApolloError).toBeDefined(); 12 | expect(MolApolloServer.SyntaxError).toBeDefined(); 13 | expect(MolApolloServer.ValidationError).toBeDefined(); 14 | expect(MolApolloServer.AuthenticationError).toBeDefined(); 15 | expect(MolApolloServer.ForbiddenError).toBeDefined(); 16 | expect(MolApolloServer.UserInputError).toBeDefined(); 17 | expect(MolApolloServer.defaultPlaygroundOptions).toBeDefined(); 18 | }); 19 | 20 | it("should export Moleculer modules", () => { 21 | expect(MolApolloServer.ApolloServer).toBeDefined(); 22 | expect(MolApolloServer.ApolloService).toBeDefined(); 23 | expect(MolApolloServer.moleculerGql).toBeDefined(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/unit/moleculerApollo.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | jest.mock("apollo-server-core"); 4 | const ApolloServerCore = require("apollo-server-core"); 5 | ApolloServerCore.convertNodeHttpToRequest.mockImplementation(() => "convertedRequest"); 6 | 7 | const graphqlMoleculer = require("../../src/moleculerApollo"); 8 | 9 | describe("Test graphqlMoleculer", () => { 10 | it("should throw error if not options", () => { 11 | expect(() => graphqlMoleculer()).toThrow("Apollo Server requires options."); 12 | }); 13 | 14 | it("should throw error if there are more arguments", () => { 15 | expect(() => graphqlMoleculer({}, true)).toThrow( 16 | "Apollo Server expects exactly one argument, got 2", 17 | ); 18 | }); 19 | 20 | it("should return a handler", () => { 21 | expect(graphqlMoleculer({})).toBeInstanceOf(Function); 22 | }); 23 | }); 24 | 25 | describe("Test graphqlMoleculer handler", () => { 26 | let options = { a: 5 }; 27 | 28 | let fakeReq = { 29 | method: "GET", 30 | url: "http://my-server/graphql?filter=something", 31 | }; 32 | let fakeRes = { 33 | setHeader: jest.fn(), 34 | end: jest.fn(), 35 | }; 36 | 37 | it("should return the response of runHttpQuery with GET request", async () => { 38 | ApolloServerCore.runHttpQuery.mockImplementation(() => 39 | Promise.resolve({ 40 | graphqlResponse: "my-response", 41 | responseInit: { 42 | headers: { 43 | "X-Response-Time": "123ms", 44 | }, 45 | }, 46 | }), 47 | ); 48 | 49 | const handler = graphqlMoleculer(options); 50 | 51 | const res = await handler(fakeReq, fakeRes); 52 | 53 | expect(res).toBe("my-response"); 54 | 55 | expect(ApolloServerCore.runHttpQuery).toBeCalledTimes(1); 56 | expect(ApolloServerCore.runHttpQuery).toBeCalledWith([fakeReq, fakeRes], { 57 | method: "GET", 58 | options: { 59 | a: 5, 60 | }, 61 | query: { 62 | filter: "something", 63 | }, 64 | request: "convertedRequest", 65 | }); 66 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledTimes(1); 67 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledWith(fakeReq); 68 | 69 | expect(fakeRes.setHeader).toBeCalledTimes(1); 70 | expect(fakeRes.setHeader).toBeCalledWith("X-Response-Time", "123ms"); 71 | 72 | expect(fakeRes.statusCode).toBeUndefined(); 73 | expect(fakeRes.end).toBeCalledTimes(0); 74 | }); 75 | 76 | it("should return the response of runHttpQuery with POST & body", async () => { 77 | ApolloServerCore.runHttpQuery.mockClear(); 78 | ApolloServerCore.convertNodeHttpToRequest.mockClear(); 79 | fakeRes.setHeader.mockClear(); 80 | fakeRes.end.mockClear(); 81 | 82 | fakeReq.method = "POST"; 83 | fakeReq.body = "postBody"; 84 | 85 | const handler = graphqlMoleculer(options); 86 | 87 | const res = await handler(fakeReq, fakeRes); 88 | 89 | expect(res).toBe("my-response"); 90 | 91 | expect(ApolloServerCore.runHttpQuery).toBeCalledTimes(1); 92 | expect(ApolloServerCore.runHttpQuery).toBeCalledWith([fakeReq, fakeRes], { 93 | method: "POST", 94 | options: { 95 | a: 5, 96 | }, 97 | query: "postBody", 98 | request: "convertedRequest", 99 | }); 100 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledTimes(1); 101 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledWith(fakeReq); 102 | 103 | expect(fakeRes.setHeader).toBeCalledTimes(1); 104 | expect(fakeRes.setHeader).toBeCalledWith("X-Response-Time", "123ms"); 105 | 106 | expect(fakeRes.statusCode).toBeUndefined(); 107 | expect(fakeRes.end).toBeCalledTimes(0); 108 | }); 109 | 110 | it("should return the response of runHttpQuery with POST & filePayload", async () => { 111 | ApolloServerCore.runHttpQuery.mockClear(); 112 | ApolloServerCore.convertNodeHttpToRequest.mockClear(); 113 | fakeRes.setHeader.mockClear(); 114 | fakeRes.end.mockClear(); 115 | 116 | fakeReq.method = "POST"; 117 | fakeReq.filePayload = "filePayload"; 118 | 119 | const handler = graphqlMoleculer(options); 120 | 121 | const res = await handler(fakeReq, fakeRes); 122 | 123 | expect(res).toBe("my-response"); 124 | 125 | expect(ApolloServerCore.runHttpQuery).toBeCalledTimes(1); 126 | expect(ApolloServerCore.runHttpQuery).toBeCalledWith([fakeReq, fakeRes], { 127 | method: "POST", 128 | options: { 129 | a: 5, 130 | }, 131 | query: "filePayload", 132 | request: "convertedRequest", 133 | }); 134 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledTimes(1); 135 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledWith(fakeReq); 136 | 137 | expect(fakeRes.setHeader).toBeCalledTimes(1); 138 | expect(fakeRes.setHeader).toBeCalledWith("X-Response-Time", "123ms"); 139 | 140 | expect(fakeRes.statusCode).toBeUndefined(); 141 | expect(fakeRes.end).toBeCalledTimes(0); 142 | }); 143 | 144 | it("should return the GraphQL error", async () => { 145 | ApolloServerCore.runHttpQuery.mockImplementation(() => { 146 | const err = new Error("Some GraphQL error"); 147 | throw err; 148 | }); 149 | 150 | ApolloServerCore.runHttpQuery.mockClear(); 151 | ApolloServerCore.convertNodeHttpToRequest.mockClear(); 152 | fakeRes.setHeader.mockClear(); 153 | fakeRes.end.mockClear(); 154 | 155 | const handler = graphqlMoleculer(options); 156 | 157 | const res = await handler(fakeReq, fakeRes); 158 | 159 | expect(res).toBeUndefined(); 160 | 161 | expect(ApolloServerCore.runHttpQuery).toBeCalledTimes(1); 162 | expect(ApolloServerCore.runHttpQuery).toBeCalledWith([fakeReq, fakeRes], { 163 | method: "POST", 164 | options: { 165 | a: 5, 166 | }, 167 | query: "filePayload", 168 | request: "convertedRequest", 169 | }); 170 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledTimes(1); 171 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledWith(fakeReq); 172 | 173 | expect(fakeRes.setHeader).toBeCalledTimes(0); 174 | 175 | expect(fakeRes.statusCode).toBe(500); 176 | expect(fakeRes.end).toBeCalledTimes(1); 177 | expect(fakeRes.end).toBeCalledWith("Some GraphQL error"); 178 | }); 179 | 180 | it("should return the GraphQL error", async () => { 181 | ApolloServerCore.runHttpQuery.mockImplementation(() => { 182 | const err = new Error("Some HTTP Query error"); 183 | err.name = "HttpQueryError"; 184 | err.statusCode = 422; 185 | err.headers = { 186 | "X-Http-Error": "Some error", 187 | }; 188 | throw err; 189 | }); 190 | 191 | ApolloServerCore.runHttpQuery.mockClear(); 192 | ApolloServerCore.convertNodeHttpToRequest.mockClear(); 193 | fakeRes.setHeader.mockClear(); 194 | fakeRes.end.mockClear(); 195 | 196 | const handler = graphqlMoleculer(options); 197 | 198 | const res = await handler(fakeReq, fakeRes); 199 | 200 | expect(res).toBeUndefined(); 201 | 202 | expect(ApolloServerCore.runHttpQuery).toBeCalledTimes(1); 203 | expect(ApolloServerCore.runHttpQuery).toBeCalledWith([fakeReq, fakeRes], { 204 | method: "POST", 205 | options: { 206 | a: 5, 207 | }, 208 | query: "filePayload", 209 | request: "convertedRequest", 210 | }); 211 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledTimes(1); 212 | expect(ApolloServerCore.convertNodeHttpToRequest).toBeCalledWith(fakeReq); 213 | 214 | expect(fakeRes.setHeader).toBeCalledTimes(1); 215 | expect(fakeRes.setHeader).toBeCalledWith("X-Http-Error", "Some error"); 216 | 217 | expect(fakeRes.statusCode).toBe(422); 218 | expect(fakeRes.end).toBeCalledTimes(1); 219 | expect(fakeRes.end).toBeCalledWith("Some HTTP Query error"); 220 | }); 221 | }); 222 | -------------------------------------------------------------------------------- /test/unit/service.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | jest.mock("../../src/ApolloServer"); 4 | const { ApolloServer } = require("../../src/ApolloServer"); 5 | 6 | jest.mock("graphql-tools"); 7 | const { makeExecutableSchema } = require("graphql-tools"); 8 | 9 | jest.mock("graphql"); 10 | const GraphQL = require("graphql"); 11 | 12 | jest.mock("graphql-subscriptions"); 13 | const { PubSub, withFilter } = require("graphql-subscriptions"); 14 | 15 | const ApolloServerService = require("../../src/service"); 16 | 17 | const { ServiceBroker, Context, Errors } = require("moleculer"); 18 | 19 | async function startService(mixinOptions, baseSchema) { 20 | const broker = new ServiceBroker({ logger: false }); 21 | 22 | baseSchema = baseSchema || { 23 | name: "api", 24 | settings: { 25 | routes: [], 26 | }, 27 | }; 28 | 29 | const svc = broker.createService(ApolloServerService(mixinOptions), baseSchema); 30 | await broker.start(); 31 | 32 | return { broker, svc, stop: () => broker.stop() }; 33 | } 34 | 35 | describe("Test Service", () => { 36 | describe("Test created handler", () => { 37 | it("should register a route with default options", async () => { 38 | const { svc, stop } = await startService(); 39 | 40 | expect(svc.shouldUpdateGraphqlSchema).toBe(true); 41 | 42 | expect(svc.settings.routes[0]).toStrictEqual({ 43 | path: "/graphql", 44 | 45 | aliases: { 46 | "/": expect.any(Function), 47 | "GET /.well-known/apollo/server-health": expect.any(Function), 48 | }, 49 | 50 | mappingPolicy: "restrict", 51 | 52 | bodyParsers: { 53 | json: true, 54 | urlencoded: { extended: true }, 55 | }, 56 | }); 57 | await stop(); 58 | }); 59 | 60 | describe("Test `/` route handler", () => { 61 | it("should prepare graphql schema & call handler", async () => { 62 | const { svc, stop } = await startService(); 63 | 64 | // Test `/` alias 65 | svc.prepareGraphQLSchema = jest.fn(); 66 | svc.graphqlHandler = jest.fn(() => "result"); 67 | const fakeReq = { req: 1 }; 68 | const fakeRes = { res: 1 }; 69 | 70 | const res = await svc.settings.routes[0].aliases["/"].call(svc, fakeReq, fakeRes); 71 | 72 | expect(res).toBe("result"); 73 | expect(svc.prepareGraphQLSchema).toBeCalledTimes(1); 74 | expect(svc.graphqlHandler).toBeCalledTimes(1); 75 | expect(svc.graphqlHandler).toBeCalledWith(fakeReq, fakeRes); 76 | 77 | await stop(); 78 | }); 79 | 80 | it("should call sendError if error occurs when preparing graphql schema", async () => { 81 | const { svc, stop } = await startService(); 82 | 83 | const err = new Error("Something happened"); 84 | svc.sendError = jest.fn(); 85 | svc.prepareGraphQLSchema = jest.fn(() => { 86 | throw err; 87 | }); 88 | svc.graphqlHandler = jest.fn(() => "result"); 89 | const fakeReq = { req: 1 }; 90 | const fakeRes = { res: 1 }; 91 | 92 | const res = await svc.settings.routes[0].aliases["/"].call(svc, fakeReq, fakeRes); 93 | 94 | expect(res).toBeUndefined(); 95 | expect(svc.prepareGraphQLSchema).toBeCalledTimes(1); 96 | expect(svc.graphqlHandler).toBeCalledTimes(0); 97 | expect(svc.sendError).toBeCalledTimes(1); 98 | expect(svc.sendError).toBeCalledWith(fakeReq, fakeRes, err); 99 | 100 | await stop(); 101 | }); 102 | 103 | it("should call sendError if error occurs when handling graphql request", async () => { 104 | const { svc, stop } = await startService(); 105 | 106 | const err = new Error("Something happened"); 107 | svc.sendError = jest.fn(); 108 | svc.prepareGraphQLSchema = jest.fn(); 109 | svc.graphqlHandler = jest.fn(() => { 110 | throw err; 111 | }); 112 | const fakeReq = { req: 1 }; 113 | const fakeRes = { res: 1 }; 114 | 115 | const res = await svc.settings.routes[0].aliases["/"].call(svc, fakeReq, fakeRes); 116 | 117 | expect(res).toBeUndefined(); 118 | expect(svc.prepareGraphQLSchema).toBeCalledTimes(1); 119 | expect(svc.graphqlHandler).toBeCalledTimes(1); 120 | expect(svc.sendError).toBeCalledTimes(1); 121 | expect(svc.sendError).toBeCalledWith(fakeReq, fakeRes, err); 122 | 123 | await stop(); 124 | }); 125 | }); 126 | 127 | describe("Test `GET /.well-known/apollo/server-health` route handler", () => { 128 | it("should prepare graphql schema & call handler", async () => { 129 | const { svc, stop } = await startService(); 130 | 131 | // Test `/` alias 132 | svc.prepareGraphQLSchema = jest.fn(); 133 | svc.graphqlHandler = jest.fn(() => "result"); 134 | const fakeReq = { req: 1 }; 135 | const fakeRes = { res: 1 }; 136 | 137 | const res = await svc.settings.routes[0].aliases[ 138 | "GET /.well-known/apollo/server-health" 139 | ].call(svc, fakeReq, fakeRes); 140 | 141 | expect(res).toBe("result"); 142 | expect(svc.prepareGraphQLSchema).toBeCalledTimes(1); 143 | expect(svc.graphqlHandler).toBeCalledTimes(1); 144 | expect(svc.graphqlHandler).toBeCalledWith(fakeReq, fakeRes); 145 | 146 | await stop(); 147 | }); 148 | 149 | it("should call sendError if error occurs when preparing graphql schema", async () => { 150 | const { svc, stop } = await startService(); 151 | 152 | const err = new Error("Something happened"); 153 | svc.sendResponse = jest.fn(); 154 | svc.prepareGraphQLSchema = jest.fn(() => { 155 | throw err; 156 | }); 157 | svc.graphqlHandler = jest.fn(() => "result"); 158 | const fakeReq = { req: 1 }; 159 | const fakeRes = { res: 1 }; 160 | 161 | const res = await svc.settings.routes[0].aliases[ 162 | "GET /.well-known/apollo/server-health" 163 | ].call(svc, fakeReq, fakeRes); 164 | 165 | expect(res).toBeUndefined(); 166 | expect(svc.prepareGraphQLSchema).toBeCalledTimes(1); 167 | expect(svc.graphqlHandler).toBeCalledTimes(0); 168 | expect(svc.sendResponse).toBeCalledTimes(1); 169 | expect(svc.sendResponse).toBeCalledWith( 170 | fakeReq, 171 | fakeRes, 172 | { status: "fail", schema: false }, 173 | { responseType: "application/health+json" } 174 | ); 175 | 176 | await stop(); 177 | }); 178 | 179 | it("should call sendError if error occurs when handling graphql request", async () => { 180 | const { svc, stop } = await startService(); 181 | 182 | const err = new Error("Something happened"); 183 | svc.sendResponse = jest.fn(); 184 | svc.prepareGraphQLSchema = jest.fn(); 185 | svc.graphqlHandler = jest.fn(() => { 186 | throw err; 187 | }); 188 | const fakeReq = { req: 1 }; 189 | const fakeRes = { res: 1 }; 190 | 191 | const res = await svc.settings.routes[0].aliases[ 192 | "GET /.well-known/apollo/server-health" 193 | ].call(svc, fakeReq, fakeRes); 194 | 195 | expect(res).toBeUndefined(); 196 | expect(svc.prepareGraphQLSchema).toBeCalledTimes(1); 197 | expect(svc.graphqlHandler).toBeCalledTimes(1); 198 | expect(svc.sendResponse).toBeCalledTimes(1); 199 | expect(svc.sendResponse).toBeCalledWith( 200 | fakeReq, 201 | fakeRes, 202 | { status: "fail", schema: false }, 203 | { responseType: "application/health+json" } 204 | ); 205 | 206 | await stop(); 207 | }); 208 | }); 209 | 210 | it("should register a route with custom options", async () => { 211 | const { svc, stop } = await startService({ 212 | routeOptions: { 213 | path: "/apollo-server", 214 | 215 | aliases: { 216 | "GET /my-alias": jest.fn(), 217 | }, 218 | 219 | cors: true, 220 | }, 221 | }); 222 | 223 | expect(svc.settings.routes[0]).toStrictEqual({ 224 | path: "/apollo-server", 225 | 226 | aliases: { 227 | "/": expect.any(Function), 228 | "GET /.well-known/apollo/server-health": expect.any(Function), 229 | "GET /my-alias": expect.any(Function), 230 | }, 231 | 232 | mappingPolicy: "restrict", 233 | 234 | bodyParsers: { 235 | json: true, 236 | urlencoded: { extended: true }, 237 | }, 238 | 239 | cors: true, 240 | }); 241 | 242 | await stop(); 243 | }); 244 | }); 245 | 246 | describe("Test registered events", () => { 247 | it("should subscribe to '$services.changed' event", async () => { 248 | const { broker, svc, stop } = await startService(); 249 | svc.invalidateGraphQLSchema = jest.fn(); 250 | 251 | await broker.broadcastLocal("$services.changed"); 252 | 253 | expect(svc.invalidateGraphQLSchema).toBeCalledTimes(1); 254 | expect(svc.invalidateGraphQLSchema).toBeCalledWith(); 255 | 256 | await stop(); 257 | }); 258 | 259 | it("should not invalidate schema when autoUpdateSchema is false", async () => { 260 | const { broker, svc, stop } = await startService({ 261 | autoUpdateSchema: false, 262 | }); 263 | svc.invalidateGraphQLSchema = jest.fn(); 264 | 265 | await broker.broadcastLocal("$services.changed"); 266 | 267 | expect(svc.invalidateGraphQLSchema).toBeCalledTimes(0); 268 | 269 | await stop(); 270 | }); 271 | 272 | it("should not invalidate schema when autoUpdateSchema is true", async () => { 273 | const { broker, svc, stop } = await startService({ 274 | autoUpdateSchema: true, 275 | }); 276 | svc.invalidateGraphQLSchema = jest.fn(); 277 | 278 | await broker.broadcastLocal("$services.changed"); 279 | 280 | expect(svc.invalidateGraphQLSchema).toBeCalledTimes(1); 281 | expect(svc.invalidateGraphQLSchema).toBeCalledWith(); 282 | 283 | await stop(); 284 | }); 285 | 286 | it("should subscribe to the default subscription event", async () => { 287 | const { broker, svc, stop } = await startService(); 288 | 289 | svc.pubsub = { 290 | publish: jest.fn(), 291 | }; 292 | 293 | await broker.broadcastLocal("graphql.publish", { 294 | tag: "tag", 295 | payload: { a: 5 }, 296 | }); 297 | 298 | expect(svc.pubsub.publish).toBeCalledTimes(1); 299 | expect(svc.pubsub.publish).toBeCalledWith("tag", { a: 5 }); 300 | 301 | await stop(); 302 | }); 303 | 304 | it("should subscribe to a custom subscription event", async () => { 305 | const { broker, svc, stop } = await startService({ 306 | subscriptionEventName: "my.graphql.event", 307 | }); 308 | 309 | svc.pubsub = { 310 | publish: jest.fn(), 311 | }; 312 | 313 | await broker.broadcastLocal("my.graphql.event", { 314 | tag: "tag", 315 | payload: { a: 5 }, 316 | }); 317 | 318 | expect(svc.pubsub.publish).toBeCalledTimes(1); 319 | expect(svc.pubsub.publish).toBeCalledWith("tag", { a: 5 }); 320 | 321 | await stop(); 322 | }); 323 | }); 324 | 325 | describe("Test action", () => { 326 | it("should create the 'graphql' action", async () => { 327 | const { broker, svc, stop } = await startService(); 328 | svc.prepareGraphQLSchema = jest.fn(); 329 | svc.graphqlSchema = "graphqlSchema"; 330 | GraphQL.graphql.mockImplementation(async () => "result"); 331 | 332 | const res = await broker.call("api.graphql", { 333 | query: "my-query", 334 | variables: { a: 5 }, 335 | }); 336 | expect(res).toBe("result"); 337 | 338 | expect(svc.prepareGraphQLSchema).toBeCalledTimes(1); 339 | expect(svc.prepareGraphQLSchema).toBeCalledWith(); 340 | 341 | expect(GraphQL.graphql).toBeCalledTimes(1); 342 | expect(GraphQL.graphql).toBeCalledWith( 343 | "graphqlSchema", 344 | "my-query", 345 | null, 346 | { ctx: expect.any(Context) }, 347 | { a: 5 } 348 | ); 349 | 350 | await stop(); 351 | }); 352 | 353 | it("should not create the 'graphql' action", async () => { 354 | const { broker, stop } = await startService({ createAction: false }); 355 | 356 | await expect(broker.call("api.graphql")).rejects.toThrow(Errors.ServiceNotFoundError); 357 | 358 | await stop(); 359 | }); 360 | }); 361 | 362 | describe("Test methods", () => { 363 | describe("Test 'invalidateGraphQLSchema'", () => { 364 | it("should create the 'graphql' action", async () => { 365 | const { svc, stop } = await startService(); 366 | 367 | svc.shouldUpdateGraphqlSchema = false; 368 | 369 | svc.invalidateGraphQLSchema(); 370 | 371 | expect(svc.shouldUpdateGraphqlSchema).toBe(true); 372 | 373 | await stop(); 374 | }); 375 | }); 376 | 377 | describe("Test 'getFieldName'", () => { 378 | let svc, stop; 379 | 380 | beforeAll(async () => { 381 | const res = await startService(); 382 | svc = res.svc; 383 | stop = res.stop; 384 | }); 385 | 386 | afterAll(async () => await stop()); 387 | 388 | it("should return field name from one-line declaration", async () => { 389 | expect(svc.getFieldName("posts(limit: Int): [Post]")).toBe("posts"); 390 | }); 391 | 392 | it("should return field name from multi-line declaration", async () => { 393 | expect( 394 | svc.getFieldName(` 395 | getWorkspaces( 396 | name: [String] 397 | clientId: [String] 398 | sort: [String] 399 | pageSize: Int 400 | page: Int 401 | ) : [Workspace]`) 402 | ).toBe("getWorkspaces"); 403 | }); 404 | 405 | it("should return field name with comments", async () => { 406 | expect( 407 | svc.getFieldName(` 408 | # Get all posts with limit 409 | # Returns an array 410 | posts(limit: Int): [Post]`) 411 | ).toBe("posts"); 412 | }); 413 | }); 414 | }); 415 | 416 | describe("Test 'getServiceName'", () => { 417 | it("should return the service fullName", async () => { 418 | const { svc, stop } = await startService(); 419 | 420 | expect(svc.getServiceName({ name: "posts" })).toBe("posts"); 421 | expect(svc.getServiceName({ name: "posts", version: 5 })).toBe("v5.posts"); 422 | expect(svc.getServiceName({ name: "posts", version: "staging" })).toBe("staging.posts"); 423 | expect( 424 | svc.getServiceName({ name: "posts", version: "staging", fullName: "full.posts" }) 425 | ).toBe("full.posts"); 426 | 427 | await stop(); 428 | }); 429 | }); 430 | 431 | describe("Test 'getResolverActionName'", () => { 432 | it("should return the resolver name", async () => { 433 | const { svc, stop } = await startService(); 434 | 435 | expect(svc.getResolverActionName("posts", "list")).toBe("posts.list"); 436 | expect(svc.getResolverActionName("users", "users.list")).toBe("users.list"); 437 | 438 | await stop(); 439 | }); 440 | }); 441 | 442 | describe("Test 'createServiceResolvers'", () => { 443 | it("should call actionResolvers", async () => { 444 | const { svc, stop } = await startService(); 445 | 446 | svc.createActionResolver = jest.fn(() => jest.fn()); 447 | 448 | const resolvers = { 449 | author: { 450 | // Call the `users.resolve` action with `id` params 451 | action: "users.resolve", 452 | rootParams: { 453 | author: "id", 454 | }, 455 | }, 456 | voters: { 457 | // Call the `users.resolve` action with `id` params 458 | action: "voters.get", 459 | rootParams: { 460 | voters: "id", 461 | }, 462 | }, 463 | 464 | UserType: { 465 | ADMIN: { value: "1" }, 466 | READER: { value: "2" }, 467 | }, 468 | }; 469 | 470 | expect(svc.createServiceResolvers("users", resolvers)).toStrictEqual({ 471 | author: expect.any(Function), 472 | voters: expect.any(Function), 473 | UserType: { 474 | ADMIN: { value: "1" }, 475 | READER: { value: "2" }, 476 | }, 477 | }); 478 | 479 | expect(svc.createActionResolver).toBeCalledTimes(2); 480 | expect(svc.createActionResolver).toBeCalledWith("users.resolve", resolvers.author); 481 | expect(svc.createActionResolver).toBeCalledWith("voters.get", resolvers.voters); 482 | 483 | await stop(); 484 | }); 485 | }); 486 | 487 | describe("Test 'createActionResolver' without DataLoader or Upload", () => { 488 | let broker, svc, stop; 489 | 490 | beforeAll(async () => { 491 | const res = await startService(); 492 | broker = res.broker; 493 | svc = res.svc; 494 | stop = res.stop; 495 | }); 496 | 497 | afterAll(async () => await stop()); 498 | 499 | it("should return a resolver Function", async () => { 500 | expect(svc.createActionResolver("posts.find")).toBeInstanceOf(Function); 501 | }); 502 | 503 | it("should call the given action with keys", async () => { 504 | const resolver = svc.createActionResolver("posts.find", { 505 | rootParams: { 506 | author: "id", 507 | }, 508 | 509 | params: { 510 | repl: false, 511 | }, 512 | }); 513 | 514 | const ctx = new Context(broker); 515 | ctx.call = jest.fn(() => "response from action"); 516 | 517 | const fakeRoot = { author: 12345 }; 518 | 519 | const res = await resolver(fakeRoot, { a: 5 }, { ctx }); 520 | 521 | expect(res).toBe("response from action"); 522 | 523 | expect(ctx.call).toBeCalledTimes(1); 524 | expect(ctx.call).toBeCalledWith("posts.find", { 525 | a: 5, 526 | id: 12345, 527 | repl: false, 528 | }); 529 | }); 530 | 531 | it("should throw error", async () => { 532 | const resolver = svc.createActionResolver("posts.find", { 533 | params: { 534 | limit: 5, 535 | }, 536 | }); 537 | 538 | const ctx = new Context(broker); 539 | ctx.call = jest.fn(() => 540 | Promise.reject(new Errors.MoleculerError("Something happened")) 541 | ); 542 | 543 | const fakeRoot = { author: 12345 }; 544 | 545 | expect.assertions(3); 546 | try { 547 | await resolver(fakeRoot, { a: 5 }, { ctx }); 548 | } catch (err) { 549 | expect(err.message).toBe("Something happened"); 550 | } 551 | 552 | expect(ctx.call).toBeCalledTimes(1); 553 | expect(ctx.call).toBeCalledWith("posts.find", { 554 | limit: 5, 555 | a: 5, 556 | }); 557 | }); 558 | 559 | it("should not throw error if nullIfError is true", async () => { 560 | const resolver = svc.createActionResolver("posts.find", { 561 | nullIfError: true, 562 | rootParams: { 563 | author: "id", 564 | "company.code": "company.code", 565 | }, 566 | }); 567 | 568 | const ctx = new Context(broker); 569 | ctx.call = jest.fn(() => 570 | Promise.reject(new Errors.MoleculerError("Something happened")) 571 | ); 572 | 573 | const fakeRoot = { author: 12345, company: { code: "Moleculer" } }; 574 | 575 | const res = await resolver(fakeRoot, { a: 5 }, { ctx }); 576 | 577 | expect(res).toBeNull(); 578 | 579 | expect(ctx.call).toBeCalledTimes(1); 580 | expect(ctx.call).toBeCalledWith("posts.find", { 581 | id: 12345, 582 | company: { 583 | code: "Moleculer", 584 | }, 585 | a: 5, 586 | }); 587 | }); 588 | 589 | it("should use null value if skipNullKeys is false", async () => { 590 | const resolver = svc.createActionResolver("posts.find", { 591 | rootParams: { 592 | author: "id", 593 | }, 594 | }); 595 | 596 | const ctx = new Context(broker); 597 | ctx.call = jest.fn(() => "response from action"); 598 | 599 | const fakeRoot = {}; 600 | 601 | const res = await resolver(fakeRoot, { a: 5 }, { ctx }); 602 | 603 | expect(res).toBe("response from action"); 604 | 605 | expect(ctx.call).toBeCalledTimes(1); 606 | expect(ctx.call).toBeCalledWith("posts.find", { 607 | id: undefined, 608 | a: 5, 609 | }); 610 | }); 611 | 612 | it("should not call action if id is null and skipNullKeys is true", async () => { 613 | const resolver = svc.createActionResolver("posts.find", { 614 | skipNullKeys: true, 615 | rootParams: { 616 | author: "id", 617 | }, 618 | }); 619 | 620 | const ctx = new Context(broker); 621 | ctx.call = jest.fn(() => "response from action"); 622 | 623 | const fakeRoot = {}; 624 | 625 | const res = await resolver(fakeRoot, { a: 5 }, { ctx }); 626 | 627 | expect(res).toBe(null); 628 | 629 | expect(ctx.call).toBeCalledTimes(0); 630 | }); 631 | }); 632 | 633 | describe("Test 'createActionResolver' with File Upload", () => { 634 | let broker, svc, stop; 635 | 636 | beforeAll(async () => { 637 | const res = await startService(); 638 | broker = res.broker; 639 | svc = res.svc; 640 | stop = res.stop; 641 | }); 642 | 643 | afterAll(async () => await stop()); 644 | 645 | it("should create a stream and pass to call", async () => { 646 | const resolver = svc.createActionResolver("posts.uploadSingle", { 647 | fileUploadArg: "file", 648 | }); 649 | 650 | const ctx = new Context(broker); 651 | ctx.call = jest.fn(() => "response from action"); 652 | 653 | const fakeRoot = {}; 654 | 655 | const file = { 656 | filename: "filename.txt", 657 | encoding: "7bit", 658 | mimetype: "text/plain", 659 | createReadStream: () => "fake read stream", 660 | }; 661 | 662 | const res = await resolver(fakeRoot, { file, other: "something" }, { ctx }); 663 | 664 | expect(res).toBe("response from action"); 665 | 666 | expect(ctx.call).toBeCalledTimes(1); 667 | expect(ctx.call).toBeCalledWith("posts.uploadSingle", "fake read stream", { 668 | meta: { 669 | $fileInfo: { 670 | filename: "filename.txt", 671 | encoding: "7bit", 672 | mimetype: "text/plain", 673 | }, 674 | $args: { other: "something" }, 675 | }, 676 | }); 677 | }); 678 | 679 | it("should invoke call once per file when handling an array of file uploads", async () => { 680 | const resolver = svc.createActionResolver("posts.uploadMulti", { 681 | fileUploadArg: "files", 682 | }); 683 | 684 | const ctx = new Context(broker); 685 | ctx.call = jest.fn((_, stream) => `response for ${stream}`); 686 | 687 | const fakeRoot = {}; 688 | 689 | const files = [ 690 | { 691 | filename: "filename1.txt", 692 | encoding: "7bit", 693 | mimetype: "text/plain", 694 | createReadStream: () => "fake read stream 1", 695 | }, 696 | { 697 | filename: "filename2.txt", 698 | encoding: "7bit", 699 | mimetype: "text/plain", 700 | createReadStream: () => "fake read stream 2", 701 | }, 702 | ]; 703 | 704 | const res = await resolver(fakeRoot, { files, other: "something" }, { ctx }); 705 | 706 | expect(res).toEqual([ 707 | "response for fake read stream 1", 708 | "response for fake read stream 2", 709 | ]); 710 | 711 | expect(ctx.call).toBeCalledTimes(2); 712 | expect(ctx.call).toBeCalledWith("posts.uploadMulti", "fake read stream 1", { 713 | meta: { 714 | $fileInfo: { 715 | filename: "filename1.txt", 716 | encoding: "7bit", 717 | mimetype: "text/plain", 718 | }, 719 | $args: { other: "something" }, 720 | }, 721 | }); 722 | expect(ctx.call).toBeCalledWith("posts.uploadMulti", "fake read stream 2", { 723 | meta: { 724 | $fileInfo: { 725 | filename: "filename2.txt", 726 | encoding: "7bit", 727 | mimetype: "text/plain", 728 | }, 729 | $args: { other: "something" }, 730 | }, 731 | }); 732 | }); 733 | }); 734 | 735 | describe("Test 'createActionResolver' with DataLoader", () => { 736 | let broker, svc, stop; 737 | 738 | beforeAll(async () => { 739 | const res = await startService(); 740 | broker = res.broker; 741 | svc = res.svc; 742 | stop = res.stop; 743 | }); 744 | 745 | afterAll(async () => await stop()); 746 | 747 | beforeEach(() => { 748 | svc.dataLoaderOptions.clear(); 749 | svc.dataLoaderBatchParams.clear(); 750 | }); 751 | 752 | it("should return null if no rootValue", async () => { 753 | const resolver = svc.createActionResolver("posts.find", { 754 | rootParams: { 755 | author: "id", 756 | }, 757 | 758 | dataLoader: true, 759 | }); 760 | 761 | const fakeRoot = { user: 12345 }; 762 | 763 | const res = await resolver(fakeRoot, { a: 5 }, { dataLoaders: new Map() }); 764 | 765 | expect(res).toBeNull(); 766 | }); 767 | 768 | it("should call the action via the loader with single value", async () => { 769 | const resolver = svc.createActionResolver("users.resolve", { 770 | rootParams: { 771 | author: "id", 772 | }, 773 | 774 | dataLoader: true, 775 | }); 776 | 777 | const ctx = new Context(broker); 778 | ctx.call = jest.fn().mockResolvedValue(["response from action"]); 779 | 780 | const fakeRoot = { author: 12345 }; 781 | 782 | const res = await resolver(fakeRoot, { a: 5 }, { ctx, dataLoaders: new Map() }); 783 | 784 | expect(res).toBe("response from action"); 785 | 786 | expect(ctx.call).toHaveBeenCalledTimes(1); 787 | expect(ctx.call).toHaveBeenNthCalledWith(1, "users.resolve", { a: 5, id: [12345] }); 788 | }); 789 | 790 | it("should call the action via the loader with multi value", async () => { 791 | const resolver = svc.createActionResolver("users.resolve", { 792 | rootParams: { 793 | author: "id", 794 | }, 795 | 796 | dataLoader: true, 797 | }); 798 | 799 | const ctx = new Context(broker); 800 | ctx.call = jest.fn().mockResolvedValue(["res1", "res2", "res3"]); 801 | 802 | const fakeRoot = { author: [1, 2, 5] }; 803 | 804 | const res = await resolver(fakeRoot, { a: 5 }, { ctx, dataLoaders: new Map() }); 805 | 806 | expect(res).toEqual(["res1", "res2", "res3"]); 807 | 808 | expect(ctx.call).toHaveBeenCalledTimes(1); 809 | expect(ctx.call).toHaveBeenNthCalledWith(1, "users.resolve", { a: 5, id: [1, 2, 5] }); 810 | }); 811 | 812 | it("should call the action via the loader with multi value and use max batch size", async () => { 813 | svc.dataLoaderOptions.set("users.resolve", { maxBatchSize: 2 }); 814 | const resolver = svc.createActionResolver("users.resolve", { 815 | rootParams: { 816 | author: "id", 817 | }, 818 | 819 | dataLoader: true, 820 | }); 821 | 822 | const ctx = new Context(broker); 823 | ctx.call = jest 824 | .fn() 825 | .mockResolvedValueOnce(["res1", "res2"]) 826 | .mockResolvedValueOnce(["res3"]); 827 | 828 | const fakeRoot = { author: [1, 2, 5] }; 829 | 830 | const res = await resolver(fakeRoot, { a: 5 }, { ctx, dataLoaders: new Map() }); 831 | 832 | expect(res).toEqual(["res1", "res2", "res3"]); 833 | 834 | expect(ctx.call).toHaveBeenCalledTimes(2); 835 | expect(ctx.call).toHaveBeenNthCalledWith(1, "users.resolve", { a: 5, id: [1, 2] }); 836 | expect(ctx.call).toHaveBeenNthCalledWith(2, "users.resolve", { a: 5, id: [5] }); 837 | }); 838 | 839 | it("should call the action via the loader using all root params", async () => { 840 | svc.dataLoaderBatchParams.set("users.resolve", "testBatchParam"); 841 | const resolver = svc.createActionResolver("users.resolve", { 842 | rootParams: { 843 | authorId: "authorIdParam", 844 | testId: "testIdParam", 845 | }, 846 | 847 | dataLoader: true, 848 | }); 849 | 850 | const ctx = new Context(broker); 851 | ctx.call = jest.fn().mockResolvedValue(["res1", "res2", "res3"]); 852 | 853 | const fakeRoot1 = { authorId: 1, testId: "foo" }; 854 | const fakeRoot2 = { authorId: 2, testId: "bar" }; 855 | const fakeRoot3 = { authorId: 5, testId: "baz" }; 856 | 857 | const fakeContext = { ctx, dataLoaders: new Map() }; 858 | const res = await Promise.all([ 859 | resolver(fakeRoot1, {}, fakeContext), 860 | resolver(fakeRoot2, {}, fakeContext), 861 | resolver(fakeRoot3, {}, fakeContext), 862 | ]); 863 | 864 | expect(res).toEqual(["res1", "res2", "res3"]); 865 | 866 | expect(ctx.call).toHaveBeenCalledTimes(1); 867 | expect(ctx.call).toHaveBeenNthCalledWith(1, "users.resolve", { 868 | testBatchParam: [ 869 | { authorIdParam: 1, testIdParam: "foo" }, 870 | { authorIdParam: 2, testIdParam: "bar" }, 871 | { authorIdParam: 5, testIdParam: "baz" }, 872 | ], 873 | }); 874 | }); 875 | 876 | it("should call the action via the loader using all root params while leveraging cache", async () => { 877 | svc.dataLoaderBatchParams.set("users.resolve", "testBatchParam"); 878 | const resolver = svc.createActionResolver("users.resolve", { 879 | rootParams: { 880 | authorId: "authorIdParam", 881 | testId: "testIdParam", 882 | }, 883 | 884 | dataLoader: true, 885 | }); 886 | 887 | const ctx = new Context(broker); 888 | ctx.call = jest.fn().mockResolvedValue(["res1", "res2"]); 889 | 890 | const fakeRoot1 = { authorId: 1, testId: "foo" }; 891 | const fakeRoot2 = { authorId: 2, testId: "bar" }; 892 | const fakeRoot3 = { authorId: 1, testId: "foo" }; // same as fakeRoot1 893 | 894 | const fakeContext = { ctx, dataLoaders: new Map() }; 895 | const res = await Promise.all([ 896 | resolver(fakeRoot1, {}, fakeContext), 897 | resolver(fakeRoot2, {}, fakeContext), 898 | resolver(fakeRoot3, {}, fakeContext), 899 | ]); 900 | 901 | expect(res).toEqual(["res1", "res2", "res1"]); 902 | 903 | expect(ctx.call).toHaveBeenCalledTimes(1); 904 | expect(ctx.call).toHaveBeenNthCalledWith(1, "users.resolve", { 905 | testBatchParam: [ 906 | { authorIdParam: 1, testIdParam: "foo" }, 907 | { authorIdParam: 2, testIdParam: "bar" }, 908 | ], 909 | }); 910 | }); 911 | 912 | it("should reuse the loader for multiple calls with the same context", async () => { 913 | const resolver = svc.createActionResolver("users.resolve", { 914 | rootParams: { 915 | author: "id", 916 | }, 917 | 918 | dataLoader: true, 919 | }); 920 | 921 | const ctx = new Context(broker); 922 | ctx.call = jest 923 | .fn() 924 | .mockResolvedValueOnce(["res1", "res2", "res5"]) 925 | .mockResolvedValueOnce(["res3", "res4", "res6"]); 926 | 927 | const fakeRoot1 = { author: [1, 2, 5] }; 928 | const fakeRoot2 = { author: [3, 4, 6] }; 929 | 930 | const fakeContext = { ctx, dataLoaders: new Map() }; 931 | const res1 = await resolver(fakeRoot1, { a: 5 }, fakeContext); 932 | expect(fakeContext.dataLoaders.size).toBe(1); 933 | const res2 = await resolver(fakeRoot2, { a: 5 }, fakeContext); 934 | expect(fakeContext.dataLoaders.size).toBe(1); 935 | 936 | expect(res1).toEqual(["res1", "res2", "res5"]); 937 | expect(res2).toEqual(["res3", "res4", "res6"]); 938 | 939 | expect(ctx.call).toHaveBeenCalledTimes(2); 940 | expect(ctx.call).toHaveBeenNthCalledWith(1, "users.resolve", { 941 | a: 5, 942 | id: [1, 2, 5], 943 | }); 944 | expect(ctx.call).toHaveBeenNthCalledWith(2, "users.resolve", { 945 | a: 5, 946 | id: [3, 4, 6], 947 | }); 948 | }); 949 | 950 | it("should make multiple loaders for multiple calls with different args", async () => { 951 | const resolver = svc.createActionResolver("users.resolve", { 952 | rootParams: { 953 | author: "id", 954 | }, 955 | 956 | dataLoader: true, 957 | }); 958 | 959 | const ctx = new Context(broker); 960 | ctx.call = jest 961 | .fn() 962 | .mockResolvedValueOnce(["res1", "res2", "res5"]) 963 | .mockResolvedValueOnce(["res3", "res4", "res6"]); 964 | 965 | const fakeRoot1 = { author: [1, 2, 5] }; 966 | const fakeRoot2 = { author: [3, 4, 6] }; 967 | 968 | const fakeContext = { ctx, dataLoaders: new Map() }; 969 | const res1 = await resolver(fakeRoot1, { a: 5 }, fakeContext); 970 | expect(fakeContext.dataLoaders.size).toBe(1); 971 | const res2 = await resolver(fakeRoot2, { a: 10 }, fakeContext); 972 | expect(fakeContext.dataLoaders.size).toBe(2); 973 | 974 | expect(res1).toEqual(["res1", "res2", "res5"]); 975 | expect(res2).toEqual(["res3", "res4", "res6"]); 976 | 977 | expect(ctx.call).toHaveBeenCalledTimes(2); 978 | expect(ctx.call).toHaveBeenNthCalledWith(1, "users.resolve", { 979 | a: 5, 980 | id: [1, 2, 5], 981 | }); 982 | expect(ctx.call).toHaveBeenNthCalledWith(2, "users.resolve", { 983 | a: 10, 984 | id: [3, 4, 6], 985 | }); 986 | }); 987 | 988 | it("should construct a loader with key without a hash if no args and no params", async () => { 989 | const resolver = svc.createActionResolver("users.resolve", { 990 | rootParams: { 991 | author: "id", 992 | }, 993 | 994 | dataLoader: true, 995 | }); 996 | 997 | const ctx = new Context(broker); 998 | ctx.call = jest.fn().mockResolvedValue(["response from action"]); 999 | 1000 | const fakeRoot = { author: 12345 }; 1001 | 1002 | const fakeContext = { ctx, dataLoaders: new Map() }; 1003 | await resolver(fakeRoot, {}, fakeContext); 1004 | 1005 | const dataLoaderEntries = [...fakeContext.dataLoaders.entries()]; 1006 | 1007 | expect(dataLoaderEntries.length).toBe(1); 1008 | expect(dataLoaderEntries[0][0].split(":").length).toBe(1); 1009 | }); 1010 | 1011 | it("should construct a loader with key with a hash if args passed", async () => { 1012 | const resolver = svc.createActionResolver("users.resolve", { 1013 | rootParams: { 1014 | author: "id", 1015 | }, 1016 | 1017 | dataLoader: true, 1018 | }); 1019 | 1020 | const ctx = new Context(broker); 1021 | ctx.call = jest.fn().mockResolvedValue(["response from action"]); 1022 | 1023 | const fakeRoot = { author: 12345 }; 1024 | 1025 | const fakeContext = { ctx, dataLoaders: new Map() }; 1026 | await resolver(fakeRoot, { a: 5 }, fakeContext); 1027 | 1028 | const dataLoaderEntries = [...fakeContext.dataLoaders.entries()]; 1029 | 1030 | expect(dataLoaderEntries.length).toBe(1); 1031 | expect(dataLoaderEntries[0][0].split(":").length).toBe(2); 1032 | }); 1033 | }); 1034 | 1035 | describe("Test 'createAsyncIteratorResolver'", () => { 1036 | let broker, svc, stop; 1037 | 1038 | beforeAll(async () => { 1039 | const res = await startService(); 1040 | broker = res.broker; 1041 | svc = res.svc; 1042 | stop = res.stop; 1043 | 1044 | svc.pubsub = { asyncIterator: jest.fn(() => "iterator-result") }; 1045 | broker.call = jest.fn(async () => "action response"); 1046 | }); 1047 | 1048 | afterAll(async () => await stop()); 1049 | 1050 | it("should create resolver without tags & filter", async () => { 1051 | const res = svc.createAsyncIteratorResolver("posts.find"); 1052 | 1053 | expect(res).toEqual({ 1054 | subscribe: expect.any(Function), 1055 | resolve: expect.any(Function), 1056 | }); 1057 | 1058 | // Test subscribe 1059 | const res2 = res.subscribe(); 1060 | 1061 | expect(res2).toBe("iterator-result"); 1062 | expect(svc.pubsub.asyncIterator).toBeCalledTimes(1); 1063 | expect(svc.pubsub.asyncIterator).toBeCalledWith([]); 1064 | 1065 | // Test resolve 1066 | const ctx = new Context(broker); 1067 | ctx.call = jest.fn(async () => "action response"); 1068 | const res3 = await res.resolve({ a: 5 }, { b: "John" }, { ctx }); 1069 | 1070 | expect(res3).toBe("action response"); 1071 | expect(ctx.call).toBeCalledTimes(1); 1072 | expect(ctx.call).toBeCalledWith("posts.find", { b: "John", payload: { a: 5 } }); 1073 | }); 1074 | 1075 | it("should create resolver with tags", async () => { 1076 | svc.pubsub.asyncIterator.mockClear(); 1077 | 1078 | const res = svc.createAsyncIteratorResolver("posts.find", ["a", "b"]); 1079 | 1080 | expect(res).toEqual({ 1081 | subscribe: expect.any(Function), 1082 | resolve: expect.any(Function), 1083 | }); 1084 | 1085 | // Test subscribe 1086 | const res2 = res.subscribe(); 1087 | 1088 | expect(res2).toBe("iterator-result"); 1089 | expect(svc.pubsub.asyncIterator).toBeCalledTimes(1); 1090 | expect(svc.pubsub.asyncIterator).toBeCalledWith(["a", "b"]); 1091 | }); 1092 | 1093 | it("should create resolver with tags & filter", async () => { 1094 | svc.pubsub.asyncIterator.mockClear(); 1095 | broker.call.mockClear(); 1096 | withFilter.mockImplementation((fn1, fn2) => [fn1, fn2]); 1097 | 1098 | const res = svc.createAsyncIteratorResolver("posts.find", ["a", "b"], "posts.filter"); 1099 | 1100 | expect(res).toEqual({ 1101 | subscribe: [expect.any(Function), expect.any(Function)], 1102 | resolve: expect.any(Function), 1103 | }); 1104 | 1105 | // Test first function 1106 | const ctx = new Context(broker); 1107 | expect(res.subscribe[0](undefined, undefined, { ctx })).toBe("iterator-result"); 1108 | 1109 | expect(svc.pubsub.asyncIterator).toBeCalledTimes(1); 1110 | expect(svc.pubsub.asyncIterator).toBeCalledWith(["a", "b"]); 1111 | 1112 | // Test second function without payload 1113 | expect(await res.subscribe[1](undefined, undefined, { ctx })).toBe(false); 1114 | 1115 | // Test second function with payload 1116 | ctx.call = jest.fn(async () => "action response"); 1117 | expect(await res.subscribe[1]({ a: 5 }, { b: "John" }, { ctx })).toBe( 1118 | "action response" 1119 | ); 1120 | 1121 | expect(ctx.call).toBeCalledTimes(1); 1122 | expect(ctx.call).toBeCalledWith("posts.filter", { b: "John", payload: { a: 5 } }); 1123 | }); 1124 | }); 1125 | 1126 | describe("Test 'generateGraphQLSchema'", () => { 1127 | it("should create an empty schema", async () => { 1128 | makeExecutableSchema.mockImplementation(() => "generated-schema"); 1129 | const { svc, stop } = await startService(); 1130 | 1131 | const res = svc.generateGraphQLSchema([]); 1132 | expect(res).toBe("generated-schema"); 1133 | 1134 | expect(makeExecutableSchema).toBeCalledTimes(1); 1135 | expect(makeExecutableSchema).toBeCalledWith({ 1136 | typeDefs: [], 1137 | resolvers: {}, 1138 | schemaDirectives: null, 1139 | }); 1140 | 1141 | await stop(); 1142 | }); 1143 | 1144 | it("should create a schema with schemaDirectives", async () => { 1145 | makeExecutableSchema.mockClear(); 1146 | const UniqueIdDirective = jest.fn(); 1147 | const { svc, stop } = await startService({ 1148 | schemaDirectives: { 1149 | uid: UniqueIdDirective, 1150 | }, 1151 | }); 1152 | 1153 | const res = svc.generateGraphQLSchema([]); 1154 | expect(res).toBe("generated-schema"); 1155 | 1156 | expect(makeExecutableSchema).toBeCalledTimes(1); 1157 | expect(makeExecutableSchema).toBeCalledWith({ 1158 | typeDefs: [], 1159 | resolvers: {}, 1160 | schemaDirectives: { 1161 | uid: UniqueIdDirective, 1162 | }, 1163 | }); 1164 | 1165 | await stop(); 1166 | }); 1167 | 1168 | it("should create a schema with global, service & action definitions", async () => { 1169 | makeExecutableSchema.mockClear(); 1170 | const globalResolvers = { 1171 | Date: { 1172 | __parseValue(value) { 1173 | return new Date(value); // value from the client 1174 | }, 1175 | __serialize(value) { 1176 | return value.getTime(); // value sent to the client 1177 | }, 1178 | }, 1179 | }; 1180 | const { svc, stop } = await startService({ 1181 | typeDefs: ` 1182 | scalar Date 1183 | `, 1184 | 1185 | resolvers: globalResolvers, 1186 | }); 1187 | 1188 | const res = svc.generateGraphQLSchema([ 1189 | { 1190 | name: "posts", 1191 | fullName: "posts", 1192 | 1193 | settings: { 1194 | graphql: { 1195 | type: ` 1196 | type Post { 1197 | id: Int! 1198 | title: String! 1199 | author: User! 1200 | votes: Int! 1201 | voters: [User] 1202 | createdAt: Timestamp 1203 | error: String 1204 | } 1205 | `, 1206 | 1207 | query: ` 1208 | categories(): [String] 1209 | `, 1210 | 1211 | mutation: ` 1212 | addCategory(name: String!): String 1213 | `, 1214 | 1215 | subscription: ` 1216 | categoryChanges(): String! 1217 | `, 1218 | 1219 | resolvers: { 1220 | Post: { 1221 | author: { 1222 | action: "users.resolve", 1223 | rootParams: { 1224 | author: "id", 1225 | }, 1226 | }, 1227 | voters: { 1228 | action: "users.resolve", 1229 | dataLoader: true, 1230 | rootParams: { 1231 | voters: "id", 1232 | }, 1233 | }, 1234 | }, 1235 | }, 1236 | }, 1237 | }, 1238 | 1239 | actions: { 1240 | find: { 1241 | graphql: { 1242 | query: "posts(limit: Int): [Post]", 1243 | 1244 | type: ` 1245 | type VoteInfo { 1246 | votes: Int!, 1247 | voters: [User] 1248 | } 1249 | `, 1250 | }, 1251 | }, 1252 | upvote: { 1253 | params: { 1254 | id: "number", 1255 | userID: "number", 1256 | }, 1257 | graphql: { 1258 | mutation: "upvote(input: PostVoteInput): Post", 1259 | input: ` 1260 | input PostVoteInput { 1261 | id: Int!, 1262 | userID: Int! 1263 | } 1264 | `, 1265 | }, 1266 | }, 1267 | vote: { 1268 | params: { payload: "object" }, 1269 | graphql: { 1270 | enum: ` 1271 | enum VoteType { 1272 | VOTE_UP, 1273 | VOTE_DOWN 1274 | } 1275 | `, 1276 | 1277 | subscription: ` 1278 | vote(userID: Int!): String! 1279 | `, 1280 | tags: ["VOTE"], 1281 | filter: "posts.vote.filter", 1282 | }, 1283 | handler(ctx) { 1284 | return ctx.params.payload.type; 1285 | }, 1286 | }, 1287 | }, 1288 | }, 1289 | 1290 | { 1291 | name: "users", 1292 | version: 2, 1293 | fullName: "v2.users", 1294 | 1295 | settings: { 1296 | graphql: { 1297 | type: ` 1298 | """ 1299 | This type describes a user entity. 1300 | """ 1301 | type User { 1302 | id: Int! 1303 | name: String! 1304 | birthday: Date 1305 | posts(limit: Int): [Post] 1306 | postCount: Int 1307 | type: UserType 1308 | } 1309 | `, 1310 | enum: ` 1311 | """ 1312 | Enumerations for user types 1313 | """ 1314 | enum UserType { 1315 | ADMIN 1316 | PUBLISHER 1317 | READER 1318 | } 1319 | `, 1320 | 1321 | interface: ` 1322 | interface Book { 1323 | title: String 1324 | author: Author 1325 | } 1326 | `, 1327 | 1328 | union: ` 1329 | union Result = User | Author 1330 | `, 1331 | 1332 | input: ` 1333 | input PostAndMediaInput { 1334 | title: String 1335 | body: String 1336 | mediaUrls: [String] 1337 | } 1338 | `, 1339 | 1340 | resolvers: { 1341 | User: { 1342 | posts: { 1343 | action: "posts.findByUser", 1344 | rootParams: { 1345 | id: "userID", 1346 | }, 1347 | }, 1348 | postCount: { 1349 | // Call the "posts.count" action 1350 | action: "posts.count", 1351 | // Get `id` value from `root` and put it into `ctx.params.query.author` 1352 | rootParams: { 1353 | id: "query.author", 1354 | }, 1355 | }, 1356 | }, 1357 | UserType: { 1358 | ADMIN: "1", 1359 | PUBLISHER: "2", 1360 | READER: "3", 1361 | }, 1362 | }, 1363 | }, 1364 | }, 1365 | 1366 | actions: { 1367 | find: { 1368 | //cache: true, 1369 | params: { 1370 | limit: { type: "number", optional: true }, 1371 | }, 1372 | graphql: { 1373 | query: ` 1374 | users(limit: Int): [User] 1375 | `, 1376 | }, 1377 | }, 1378 | }, 1379 | }, 1380 | { 1381 | // Must be skipped 1382 | name: "posts", 1383 | fullName: "posts", 1384 | 1385 | settings: { 1386 | graphql: { 1387 | type: ` 1388 | type Post2 { 1389 | id: Int! 1390 | title: String! 1391 | } 1392 | `, 1393 | }, 1394 | }, 1395 | }, 1396 | ]); 1397 | expect(res).toBe("generated-schema"); 1398 | 1399 | expect(makeExecutableSchema).toBeCalledTimes(1); 1400 | expect(makeExecutableSchema.mock.calls[0][0]).toMatchSnapshot(); 1401 | 1402 | await stop(); 1403 | }); 1404 | 1405 | it("should throw further the error", async () => { 1406 | makeExecutableSchema.mockImplementation(() => { 1407 | throw new Error("Something is wrong"); 1408 | }); 1409 | const { svc, stop } = await startService(); 1410 | 1411 | expect(() => svc.generateGraphQLSchema([])).toThrow(Errors.MoleculerServerError); 1412 | 1413 | await stop(); 1414 | }); 1415 | }); 1416 | 1417 | describe("Test 'prepareGraphQLSchema'", () => { 1418 | const createHandler = jest.fn(() => "createdHandler"); 1419 | const installSubscriptionHandlers = jest.fn(); 1420 | 1421 | const fakeApolloServer = { 1422 | createHandler, 1423 | installSubscriptionHandlers, 1424 | }; 1425 | 1426 | ApolloServer.mockImplementation(() => fakeApolloServer); 1427 | 1428 | GraphQL.printSchema.mockImplementation(() => "printed schema"); 1429 | 1430 | const services = [ 1431 | { 1432 | name: "test-svc-1", 1433 | actions: [ 1434 | { 1435 | name: "test-action-1", 1436 | graphql: { 1437 | dataLoaderOptions: { option1: "option-value-1" }, 1438 | dataLoaderBatchParam: "batch-param-1", 1439 | }, 1440 | }, 1441 | { name: "test-action-2" }, 1442 | ], 1443 | }, 1444 | { 1445 | name: "test-svc-2", 1446 | version: 1, 1447 | actions: [ 1448 | { 1449 | name: "test-action-3", 1450 | graphql: { 1451 | dataLoaderOptions: { option2: "option-value-2" }, 1452 | dataLoaderBatchParam: "batch-param-2", 1453 | }, 1454 | }, 1455 | { name: "test-action-4" }, 1456 | ], 1457 | }, 1458 | ]; 1459 | 1460 | beforeEach(() => { 1461 | createHandler.mockClear(); 1462 | installSubscriptionHandlers.mockClear(); 1463 | 1464 | ApolloServer.mockClear(); 1465 | GraphQL.printSchema.mockClear(); 1466 | }); 1467 | 1468 | it("should create local variables", async () => { 1469 | const { broker, svc, stop } = await startService({ 1470 | serverOptions: { 1471 | path: "/my-graphql", 1472 | playground: true, 1473 | }, 1474 | }); 1475 | 1476 | svc.server = "server"; 1477 | broker.broadcast = jest.fn(); 1478 | broker.registry.getServiceList = jest.fn(() => services); 1479 | svc.generateGraphQLSchema = jest.fn(() => "graphql schema"); 1480 | 1481 | expect(svc.pubsub).toBeNull(); 1482 | expect(svc.apolloServer).toBeNull(); 1483 | expect(svc.graphqlHandler).toBeNull(); 1484 | expect(svc.graphqlSchema).toBeNull(); 1485 | expect(svc.shouldUpdateGraphqlSchema).toBe(true); 1486 | 1487 | svc.prepareGraphQLSchema(); 1488 | 1489 | expect(svc.pubsub).toBeInstanceOf(PubSub); 1490 | 1491 | expect(broker.registry.getServiceList).toBeCalledTimes(1); 1492 | expect(broker.registry.getServiceList).toBeCalledWith({ withActions: true }); 1493 | 1494 | expect(svc.generateGraphQLSchema).toBeCalledTimes(1); 1495 | expect(svc.generateGraphQLSchema).toBeCalledWith(services); 1496 | 1497 | expect(svc.apolloServer).toBe(fakeApolloServer); 1498 | 1499 | expect(ApolloServer).toBeCalledTimes(1); 1500 | expect(ApolloServer).toBeCalledWith({ 1501 | schema: "graphql schema", 1502 | context: expect.any(Function), 1503 | path: "/my-graphql", 1504 | playground: true, 1505 | subscriptions: { 1506 | onConnect: expect.any(Function), 1507 | }, 1508 | }); 1509 | 1510 | expect(svc.graphqlHandler).toBe("createdHandler"); 1511 | 1512 | expect(createHandler).toBeCalledTimes(1); 1513 | expect(createHandler).toBeCalledWith({ 1514 | path: "/my-graphql", 1515 | playground: true, 1516 | }); 1517 | 1518 | expect(installSubscriptionHandlers).toBeCalledTimes(1); 1519 | expect(installSubscriptionHandlers).toBeCalledWith("server"); 1520 | 1521 | expect(svc.graphqlSchema).toBe("graphql schema"); 1522 | 1523 | expect(svc.shouldUpdateGraphqlSchema).toBe(false); 1524 | 1525 | expect(broker.broadcast).toBeCalledTimes(1); 1526 | expect(broker.broadcast).toBeCalledWith("graphql.schema.updated", { 1527 | schema: "printed schema", 1528 | }); 1529 | 1530 | expect(GraphQL.printSchema).toBeCalledTimes(2); 1531 | expect(GraphQL.printSchema).toBeCalledWith("graphql schema"); 1532 | 1533 | expect(svc.dataLoaderOptions).toEqual( 1534 | new Map([ 1535 | ["test-svc-1.test-action-1", { option1: "option-value-1" }], 1536 | ["v1.test-svc-2.test-action-3", { option2: "option-value-2" }], 1537 | ]) 1538 | ); 1539 | 1540 | expect(svc.dataLoaderBatchParams).toEqual( 1541 | new Map([ 1542 | ["test-svc-1.test-action-1", "batch-param-1"], 1543 | ["v1.test-svc-2.test-action-3", "batch-param-2"], 1544 | ]) 1545 | ); 1546 | 1547 | // Test `context` method 1548 | const contextFn = ApolloServer.mock.calls[0][0].context; 1549 | 1550 | expect( 1551 | contextFn({ 1552 | connection: { 1553 | context: { 1554 | $service: "service", 1555 | $ctx: "context", 1556 | $params: { a: 5 }, 1557 | }, 1558 | }, 1559 | }) 1560 | ).toEqual({ 1561 | ctx: "context", 1562 | dataLoaders: new Map(), 1563 | params: { 1564 | a: 5, 1565 | }, 1566 | service: "service", 1567 | }); 1568 | 1569 | const req = { 1570 | $ctx: "context", 1571 | $service: "service", 1572 | $params: { a: 5 }, 1573 | }; 1574 | expect( 1575 | contextFn({ 1576 | req, 1577 | connection: { 1578 | $service: "service", 1579 | }, 1580 | }) 1581 | ).toEqual({ 1582 | ctx: "context", 1583 | dataLoaders: new Map(), 1584 | params: { 1585 | a: 5, 1586 | }, 1587 | service: "service", 1588 | }); 1589 | 1590 | // Test subscription `onConnect` 1591 | const onConnect = ApolloServer.mock.calls[0][0].subscriptions.onConnect; 1592 | 1593 | const connectionParams = { b: 100 }; 1594 | const socket = { connectionParams, upgradeReq: { query: 101 } }; 1595 | const connect = await onConnect(connectionParams, socket); 1596 | 1597 | expect(connect.$service).toEqual(svc); 1598 | expect(connect.$ctx).toBeDefined(); 1599 | expect(connect.$params.body).toEqual(connectionParams); 1600 | expect(connect.$params.query).toEqual(socket.upgradeReq.query); 1601 | 1602 | await stop(); 1603 | }); 1604 | 1605 | it("Should avoid binding apollo subscription handlers if the server config has them disabled", async () => { 1606 | const { broker, svc, stop } = await startService({ 1607 | serverOptions: { 1608 | path: "/my-graphql", 1609 | subscriptions: false, 1610 | }, 1611 | }); 1612 | 1613 | svc.server = "server"; 1614 | broker.broadcast = jest.fn(); 1615 | 1616 | broker.registry.getServiceList = jest.fn(() => services); 1617 | svc.generateGraphQLSchema = jest.fn(() => "graphql schema"); 1618 | 1619 | expect(svc.pubsub).toBeNull(); 1620 | expect(svc.apolloServer).toBeNull(); 1621 | expect(svc.graphqlHandler).toBeNull(); 1622 | expect(svc.graphqlSchema).toBeNull(); 1623 | expect(svc.shouldUpdateGraphqlSchema).toBe(true); 1624 | 1625 | svc.prepareGraphQLSchema(); 1626 | 1627 | expect(installSubscriptionHandlers).not.toHaveBeenCalled(); 1628 | 1629 | expect(svc.generateGraphQLSchema).toBeCalledTimes(1); 1630 | expect(svc.generateGraphQLSchema).toBeCalledWith(services); 1631 | 1632 | expect(svc.shouldUpdateGraphqlSchema).toBe(false); 1633 | expect(svc.graphqlSchema).toBe("graphql schema"); 1634 | 1635 | await stop(); 1636 | }); 1637 | }); 1638 | }); 1639 | --------------------------------------------------------------------------------