├── .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 | 
2 |
3 | [](https://travis-ci.org/moleculerjs/moleculer-apollo-server)
4 | [](https://coveralls.io/github/moleculerjs/moleculer-apollo-server?branch=master)
5 | [](https://david-dm.org/moleculerjs/moleculer-apollo-server)
6 | [](https://snyk.io/test/github/moleculerjs/moleculer-apollo-server)
7 |
8 |
9 | # moleculer-apollo-server [](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 | [](https://github.com/moleculerjs) [](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 |
--------------------------------------------------------------------------------