├── .editorconfig ├── .eslintignore ├── .github └── workflows │ ├── ci.yml │ ├── notification.yml │ └── typecheck.yml ├── .gitignore ├── .npmignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── eslint.config.js ├── examples ├── full │ ├── generated-schema.gql │ ├── index.js │ ├── posts.service.js │ └── users.service.js ├── index.js └── simple │ └── index.js ├── index.d.ts ├── index.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── ApolloServer.js ├── gql.js └── service.js ├── test ├── integration │ ├── __snapshots__ │ │ └── index.spec.js.snap │ ├── greeter.spec.js │ └── index.spec.js ├── typescript │ ├── index.ts │ └── tsconfig.json └── unit │ ├── gql.spec.js │ └── index.spec.js └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.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: [20.x, 22.x, 24.x] 13 | fail-fast: false 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | - name: Cache node modules 24 | uses: actions/cache@v4 25 | env: 26 | cache-name: cache-node-modules 27 | with: 28 | # npm cache files are stored in `~/.npm` on Linux/macOS 29 | path: ~/.npm 30 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 31 | restore-keys: | 32 | ${{ runner.os }}-build-${{ env.cache-name }}- 33 | ${{ runner.os }}-build- 34 | ${{ runner.os }}- 35 | 36 | - name: Install dependencies 37 | run: npm ci 38 | 39 | - name: Execute unit tests 40 | run: npm run test 41 | -------------------------------------------------------------------------------- /.github/workflows/notification.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Discord Notification 4 | 5 | on: 6 | release: 7 | types: 8 | - published 9 | 10 | jobs: 11 | notify: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Discord notification 16 | env: 17 | DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} 18 | uses: Ilshidur/action-discord@master 19 | with: 20 | args: ":tada: **The {{ EVENT_PAYLOAD.repository.name }} {{ EVENT_PAYLOAD.release.tag_name }} has been released.**:tada:\nChangelog: {{EVENT_PAYLOAD.release.html_url}}" 21 | -------------------------------------------------------------------------------- /.github/workflows/typecheck.yml: -------------------------------------------------------------------------------- 1 | name: TypeScript Type Check 2 | 3 | on: 4 | push: {} 5 | pull_request: {} 6 | 7 | jobs: 8 | typecheck: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [20.x, 22.x, 24.x] 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Typecheck 29 | run: npm run typecheck 30 | 31 | - name: Test TS 32 | run: npm run test:ts 33 | -------------------------------------------------------------------------------- /.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 | prettier.config.js 11 | eslint.config.js 12 | -------------------------------------------------------------------------------- /.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 | "name": "Attach", 9 | "port": 9229, 10 | "request": "attach", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "type": "node" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Launch demo", 20 | "program": "${workspaceRoot}\\examples\\index.js", 21 | "cwd": "${workspaceRoot}", 22 | "args": [ 23 | "simple" 24 | ] 25 | }, 26 | { 27 | "type": "node", 28 | "request": "launch", 29 | "name": "Jest", 30 | "program": "${workspaceRoot}\\node_modules\\jest-cli\\bin\\jest.js", 31 | "args": ["--runInBand"], 32 | "cwd": "${workspaceRoot}", 33 | "runtimeArgs": [ 34 | "--nolazy" 35 | ] 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # 0.4.0 (2025-09-25) 3 | 4 | ## Breaking Changes 5 | - **Apollo Server 5**: Upgraded from Apollo Server 2 to Apollo Server 5 6 | - **Node.js Requirements**: Now requires Node.js >= 20.x.x (updated from >= 10.x) 7 | - **File Upload Removal**: Removed GraphQL file upload support because [Apollo Server 3+ no longer supports it](https://www.apollographql.com/docs/apollo-server/v3/migration#file-uploads). 8 | - **Healthcheck Removal**: Built-in healthcheck endpoint removed because [Apollo Server 4+ no longer supports it](https://www.apollographql.com/docs/apollo-server/migration-from-v3#health-checks). 9 | - **WebSocket Subscriptions**: Rewritten subscription function from `graphql-subscriptions` to `graphql-ws`. 10 | - **Move from GraphQL Playground to Apollo Sandbox**: Apollo Server 3+ removes the GraphQL Playground. [It supports Apollo Sandbox.](https://www.apollographql.com/docs/apollo-server/v3/migration#graphql-playground) 11 | 12 | ## Major Updates 13 | - **Modern Tooling**: Migrated from legacy ESLint config to flat config format 14 | - **GitHub Actions**: Updated CI workflow to use latest GitHub Actions (v4) and test on Node.js 20.x, 22.x, 24.x 15 | - **Dependencies**: Updated all dependencies to latest compatible versions 16 | - **Configuration**: Replaced `.eslintrc.js` and `.prettierrc.js` with modern `eslint.config.js` and `prettier.config.js` 17 | - **Async Methods**: Made `makeExecutableSchema` and `generateGraphQLSchema` methods async for better async/await support 18 | - **Preparation**: Improved GraphQL schema preparation with promise-based mechanism to prevent multiple concurrent preparations 19 | 20 | ## Removed Features 21 | - Removed file upload examples and documentation 22 | - Removed legacy Apollo Server 2/3 configuration options 23 | 24 | ## Documentation 25 | - Updated README.md to reflect Apollo Server 5 compatibility 26 | - Improved examples and removed outdated features 27 | 28 | ## Typescript types 29 | - Improved Typescript d.ts file 30 | - Exported helper interfaces `ApolloServiceSettings`, `ApolloServiceMethods`, `ApolloServiceLocalVars` to support Moleculer 0.15 Service generics. 31 | - Augmented Moleculer `ActionSchema` with graphql property. 32 | - Typescript CI tests. 33 | 34 | -------------------------------------------------- 35 | 36 | # 0.3.8 (2023-04-23) 37 | 38 | ## Changes 39 | - add `graphql.invalidate` event, to invalidate GraphQL Schema manually. [#122](https://github.com/moleculerjs/moleculer-apollo-server/pull/122) 40 | 41 | -------------------------------------------------- 42 | 43 | # 0.3.7 (2022-10-04) 44 | 45 | ## Changes 46 | - update dependencies 47 | - fix CORS methods type definition. [#115](https://github.com/moleculerjs/moleculer-apollo-server/pull/115) 48 | - add `skipNullKeys` resolver option. [#116](https://github.com/moleculerjs/moleculer-apollo-server/pull/116) 49 | - add `checkActionVisibility` option. [#117](https://github.com/moleculerjs/moleculer-apollo-server/pull/117) 50 | 51 | -------------------------------------------------- 52 | 53 | # 0.3.6 (2022-01-17) 54 | 55 | ## Changes 56 | - custom `onConnect` issue fixed. [#105](https://github.com/moleculerjs/moleculer-apollo-server/pull/105) 57 | - update dependencies 58 | 59 | -------------------------------------------------- 60 | 61 | # 0.3.5 (2021-11-30) 62 | 63 | ## Changes 64 | - Prepare params before action calling. [#98](https://github.com/moleculerjs/moleculer-apollo-server/pull/98) 65 | - update dependencies 66 | 67 | -------------------------------------------------- 68 | 69 | # 0.3.4 (2021-04-09) 70 | 71 | ## Changes 72 | - disable timeout for `ws`. 73 | - gracefully stop Apollo Server. 74 | - add `onAfterCall` support. 75 | 76 | -------------------------------------------------- 77 | 78 | # 0.3.3 (2020-09-08) 79 | 80 | ## Changes 81 | - add `ctx.meta.$args` to store additional arguments in case of file uploading. 82 | 83 | -------------------------------------------------- 84 | 85 | # 0.3.2 (2020-08-30) 86 | 87 | ## Changes 88 | - update dependencies 89 | - new `createPubSub` & `makeExecutableSchema` methods 90 | - fix context in WS by [@Hugome](https://github.com/Hugome). [#73](https://github.com/moleculerjs/moleculer-apollo-server/pull/73) 91 | 92 | -------------------------------------------------- 93 | 94 | # 0.3.1 (2020-06-03) 95 | 96 | ## Changes 97 | - update dependencies 98 | - No longer installing subscription handlers when disabled by [@Kauabunga](https://github.com/Kauabunga). [#64](https://github.com/moleculerjs/moleculer-apollo-server/pull/64) 99 | 100 | -------------------------------------------------- 101 | 102 | # 0.3.0 (2020-04-04) 103 | 104 | ## Breaking changes 105 | - transform Uploads to `Stream`s before calling action by [@dylanwulf](https://github.com/dylanwulf). [#71](https://github.com/moleculerjs/moleculer-apollo-server/pull/71) 106 | 107 | ## Changes 108 | - update dependencies 109 | 110 | -------------------------------------------------- 111 | 112 | # 0.2.2 (2020-03-04) 113 | 114 | ## Changes 115 | - update dependencies 116 | 117 | -------------------------------------------------- 118 | 119 | # 0.2.1 (2020-03-03) 120 | 121 | ## Changes 122 | - add `autoUpdateSchema` option. [#63](https://github.com/moleculerjs/moleculer-apollo-server/pull/63) 123 | - Allow multiple rootParams to be used with Dataloader child resolution. [#65](https://github.com/moleculerjs/moleculer-apollo-server/pull/65) 124 | 125 | -------------------------------------------------- 126 | 127 | # 0.2.0 (2020-02-12) 128 | 129 | ## Breaking changes 130 | - minimum required Node version is 10.x 131 | - update dependencies and some require Node 10.x 132 | 133 | ## Changes 134 | - Typescript definition files added. 135 | - update dependencies 136 | - integration & unit tests added. 137 | - fix graphql undefined of issue when have others RESTful API node 138 | - Avoid mutating in defaultsDeep calls and use proper key in called action params 139 | 140 | -------------------------------------------------- 141 | 142 | # 0.1.3 (2019-10-16) 143 | 144 | First initial version on NPM. UNTESTED. 145 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | - `npm test` - Run Jest tests with coverage 8 | - `npm run ci` - Run tests in watch mode for development 9 | - `npm run lint` - Run ESLint on src and test directories 10 | - `npm run lint:fix` - Auto-fix ESLint issues 11 | - `npm run dev` - Run development server with examples/index.js (simple example) 12 | - `npm run dev full` - Run development server with examples/full/index.js (full example) 13 | - `npm run deps` - Update dependencies interactively using ncu 14 | - `npm run postdeps` - Automatically run tests after dependency updates 15 | 16 | ## Project Architecture 17 | 18 | This is a **Moleculer mixin** that integrates Apollo GraphQL Server 5 with Moleculer API Gateway. The core architecture consists of: 19 | 20 | ### Core Components 21 | - **src/service.js** - Main service mixin factory that returns a Moleculer service schema 22 | - **src/ApolloServer.js** - Custom ApolloServer class extending @apollo/server 23 | - **src/gql.js** - GraphQL template literal formatter utility 24 | - **index.js** - Main module exports 25 | 26 | ### Key Architectural Patterns 27 | 28 | **Service Mixin Pattern**: The library exports a factory function `ApolloService(options)` that returns a Moleculer service schema to be mixed into API Gateway services. 29 | 30 | **Auto-Schema Generation**: GraphQL schemas are dynamically generated from: 31 | - Service action definitions with `graphql` property containing `query`, `mutation`, or `subscription` fields 32 | - Service-level GraphQL definitions in `settings.graphql` 33 | - Global typeDefs and resolvers passed to the mixin 34 | 35 | **Action-to-Resolver Mapping**: Moleculer actions automatically become GraphQL resolvers when they include GraphQL definitions. The system creates resolver functions that call `ctx.call(actionName, params)`. 36 | 37 | **DataLoader Integration**: Built-in DataLoader support for batch loading with automatic key mapping and caching via resolver configuration. 38 | 39 | **WebSocket Subscriptions**: GraphQL subscriptions are handled through WebSocket connections with PubSub pattern integration. 40 | 41 | ### Testing Structure 42 | - **test/unit/** - Unit tests for individual components 43 | - **test/integration/** - Integration tests with full service setup 44 | - Jest snapshots for schema generation testing 45 | 46 | ### Examples Structure 47 | - **examples/simple/** - Basic setup demonstration 48 | - **examples/full/** - Complete setup with multiple services, DataLoader, and complex resolvers 49 | 50 | ## Important Development Notes 51 | 52 | **Schema Regeneration**: The GraphQL schema is automatically regenerated when services change (`$services.changed` event) unless `autoUpdateSchema: false`. 53 | 54 | **TypeScript Support**: Full TypeScript definitions in index.d.ts with comprehensive interfaces for all configuration options. 55 | 56 | **Node.js Version**: Requires Node.js >= 20.x.x (specified in package.json engines). -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 MoleculerJS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Moleculer logo](http://moleculer.services/images/banner.png) 2 | 3 | # moleculer-apollo-server [![CI test](https://github.com/moleculerjs/moleculer-apollo-server/actions/workflows/ci.yml/badge.svg)](https://github.com/moleculerjs/moleculer-apollo-server/actions/workflows/ci.yml) [![NPM version](https://img.shields.io/npm/v/moleculer-apollo-server.svg)](https://www.npmjs.com/package/moleculer-apollo-server) 4 | 5 | [Apollo GraphQL server 5](https://www.apollographql.com/docs/apollo-server/) mixin for [Moleculer API Gateway](https://github.com/moleculerjs/moleculer-web) 6 | 7 | ## Features 8 | 9 | ## Install 10 | ``` 11 | npm i moleculer-apollo-server moleculer-web graphql 12 | ``` 13 | 14 | ## Usage 15 | 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. 16 | 17 | ```js 18 | "use strict"; 19 | 20 | const ApiGateway = require("moleculer-web"); 21 | const { ApolloService } = require("moleculer-apollo-server"); 22 | 23 | module.exports = { 24 | name: "api", 25 | 26 | mixins: [ 27 | // Gateway 28 | ApiGateway, 29 | 30 | // GraphQL Apollo Server 31 | ApolloService({ 32 | 33 | // Global GraphQL typeDefs 34 | typeDefs: ``, 35 | 36 | // Global resolvers 37 | resolvers: {}, 38 | 39 | // API Gateway route options 40 | routeOptions: { 41 | path: "/graphql", 42 | cors: true, 43 | mappingPolicy: "restrict" 44 | }, 45 | 46 | // https://www.apollographql.com/docs/apollo-server/api/apollo-server#options 47 | serverOptions: {} 48 | }) 49 | ] 50 | }; 51 | 52 | ``` 53 | 54 | Start your Moleculer project, open http://localhost:3000/graphql in your browser to run queries using Apollo Sandbox or send GraphQL requests directly to the same URL. 55 | 56 | 57 | **Define queries & mutations in service action definitions** 58 | 59 | ```js 60 | module.exports = { 61 | name: "greeter", 62 | 63 | actions: { 64 | hello: { 65 | graphql: { 66 | query: "hello: String" 67 | }, 68 | handler(ctx) { 69 | return "Hello Moleculer!" 70 | } 71 | }, 72 | welcome: { 73 | params: { 74 | name: "string" 75 | }, 76 | graphql: { 77 | mutation: "welcome(name: String!): String" 78 | }, 79 | handler(ctx) { 80 | return `Hello ${ctx.params.name}`; 81 | } 82 | } 83 | } 84 | }; 85 | ``` 86 | 87 | **Generated schema** 88 | ```gql 89 | type Mutation { 90 | welcome(name: String!): String 91 | } 92 | 93 | type Query { 94 | hello: String 95 | } 96 | ``` 97 | 98 | ### Resolvers between services 99 | 100 | **posts.service.js** 101 | ```js 102 | module.exports = { 103 | name: "posts", 104 | settings: { 105 | graphql: { 106 | type: ` 107 | """ 108 | This type describes a post entity. 109 | """ 110 | type Post { 111 | id: Int! 112 | title: String! 113 | author: User! 114 | votes: Int! 115 | voters: [User] 116 | createdAt: Timestamp 117 | } 118 | `, 119 | resolvers: { 120 | Post: { 121 | author: { 122 | // Call the `users.resolve` action with `id` params 123 | action: "users.resolve", 124 | rootParams: { 125 | "author": "id" 126 | } 127 | }, 128 | voters: { 129 | // Call the `users.resolve` action with `id` params 130 | action: "users.resolve", 131 | rootParams: { 132 | "voters": "id" 133 | } 134 | } 135 | } 136 | } 137 | } 138 | }, 139 | actions: { 140 | find: { 141 | //cache: true, 142 | params: { 143 | limit: { type: "number", optional: true } 144 | }, 145 | graphql: { 146 | query: `posts(limit: Int): [Post]` 147 | }, 148 | handler(ctx) { 149 | let result = _.cloneDeep(posts); 150 | if (ctx.params.limit) 151 | result = posts.slice(0, ctx.params.limit); 152 | else 153 | result = posts; 154 | 155 | return _.cloneDeep(result); 156 | } 157 | }, 158 | 159 | findByUser: { 160 | params: { 161 | userID: "number" 162 | }, 163 | handler(ctx) { 164 | return _.cloneDeep(posts.filter(post => post.author == ctx.params.userID)); 165 | } 166 | }, 167 | } 168 | }; 169 | ``` 170 | 171 | **users.service.js** 172 | ```js 173 | module.exports = { 174 | name: "users", 175 | settings: { 176 | graphql: { 177 | type: ` 178 | """ 179 | This type describes a user entity. 180 | """ 181 | type User { 182 | id: Int! 183 | name: String! 184 | birthday: Date 185 | posts(limit: Int): [Post] 186 | postCount: Int 187 | } 188 | `, 189 | resolvers: { 190 | User: { 191 | posts: { 192 | // Call the `posts.findByUser` action with `userID` param. 193 | action: "posts.findByUser", 194 | rootParams: { 195 | "id": "userID" 196 | } 197 | }, 198 | postCount: { 199 | // Call the "posts.count" action 200 | action: "posts.count", 201 | // Get `id` value from `root` and put it into `ctx.params.query.author` 202 | rootParams: { 203 | "id": "query.author" 204 | } 205 | } 206 | } 207 | } 208 | } 209 | }, 210 | actions: { 211 | find: { 212 | //cache: true, 213 | params: { 214 | limit: { type: "number", optional: true } 215 | }, 216 | graphql: { 217 | query: "users(limit: Int): [User]" 218 | }, 219 | handler(ctx) { 220 | let result = _.cloneDeep(users); 221 | if (ctx.params.limit) 222 | result = users.slice(0, ctx.params.limit); 223 | else 224 | result = users; 225 | 226 | return _.cloneDeep(result); 227 | } 228 | }, 229 | 230 | resolve: { 231 | params: { 232 | id: [ 233 | { type: "number" }, 234 | { type: "array", items: "number" } 235 | ] 236 | }, 237 | handler(ctx) { 238 | if (Array.isArray(ctx.params.id)) { 239 | return _.cloneDeep(ctx.params.id.map(id => this.findByID(id))); 240 | } else { 241 | return _.cloneDeep(this.findByID(ctx.params.id)); 242 | } 243 | } 244 | } 245 | } 246 | }; 247 | ``` 248 | 249 | ### Dataloader 250 | moleculer-apollo-server supports [DataLoader](https://github.com/graphql/dataloader) via configuration in the resolver definition. 251 | 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, 252 | with the results in the same order as they were provided. 253 | 254 | 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: 255 | 256 | ```js 257 | module.exports = { 258 | settings: { 259 | graphql: { 260 | resolvers: { 261 | Post: { 262 | author: { 263 | action: "users.resolve", 264 | dataLoader: true, 265 | rootParams: { 266 | author: "id", 267 | }, 268 | }, 269 | voters: { 270 | action: "users.resolve", 271 | dataLoader: true, 272 | rootParams: { 273 | voters: "id", 274 | }, 275 | }, 276 | // ... 277 | } 278 | } 279 | } 280 | } 281 | }; 282 | ``` 283 | 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. 284 | 285 | 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'. 286 | 287 | ```js 288 | resolve: { 289 | params: { 290 | id: [{ type: "number" }, { type: "array", items: "number" }], 291 | graphql: { dataLoaderOptions: { maxBatchSize: 100 } }, 292 | }, 293 | handler(ctx) { 294 | this.logger.debug("resolve action called.", { params: ctx.params }); 295 | if (Array.isArray(ctx.params.id)) { 296 | return _.cloneDeep(ctx.params.id.map(id => this.findByID(id))); 297 | } else { 298 | return _.cloneDeep(this.findByID(ctx.params.id)); 299 | } 300 | }, 301 | }, 302 | ``` 303 | 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. 304 | 305 | ## Examples 306 | 307 | - [Simple](examples/simple/index.js) 308 | - `npm run dev` 309 | - [Full](examples/full/index.js) 310 | - `npm run dev full` 311 | - [Full With Dataloader](examples/full/index.js) 312 | - set `DATALOADER` environment variable to `"true"` 313 | - `npm run dev full` 314 | - [Typescript](test/typescript/index.ts) 315 | - `npm run test:ts` 316 | 317 | ## Test 318 | ``` 319 | $ npm test 320 | ``` 321 | 322 | In development with watching 323 | 324 | ``` 325 | $ npm run ci 326 | ``` 327 | 328 | ## Contribution 329 | 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. 330 | 331 | ## License 332 | The project is available under the [MIT license](https://tldrlegal.com/license/mit-license). 333 | 334 | ## Contact 335 | Copyright (c) 2025 MoleculerJS 336 | 337 | [![@moleculerjs](https://img.shields.io/badge/github-moleculerjs-green.svg)](https://github.com/moleculerjs) [![@MoleculerJS](https://img.shields.io/badge/twitter-MoleculerJS-blue.svg)](https://twitter.com/MoleculerJS) 338 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const js = require("@eslint/js"); 2 | const globals = require("globals"); 3 | const eslintPluginPrettierRecommended = require("eslint-plugin-prettier/recommended"); 4 | 5 | /** @type {import('eslint').Linter.FlatConfig[]} */ 6 | module.exports = [ 7 | js.configs.recommended, 8 | eslintPluginPrettierRecommended, 9 | { 10 | files: ["**/*.js", "**/*.mjs"], 11 | languageOptions: { 12 | parserOptions: { 13 | sourceType: "module", 14 | ecmaVersion: 2023 15 | }, 16 | globals: { 17 | ...globals.node, 18 | ...globals.es2020, 19 | ...globals.commonjs, 20 | ...globals.es6, 21 | ...globals.jquery, 22 | ...globals.jest, 23 | ...globals.jasmine, 24 | process: "readonly", 25 | fetch: "readonly" 26 | } 27 | }, 28 | // plugins: ["node", "security"], 29 | rules: { 30 | "no-var": ["error"], 31 | "no-console": ["error"], 32 | "no-unused-vars": ["warn"], 33 | "no-trailing-spaces": ["error"], 34 | "security/detect-object-injection": ["off"], 35 | "security/detect-non-literal-require": ["off"], 36 | "security/detect-non-literal-fs-filename": ["off"], 37 | "no-process-exit": ["off"], 38 | "node/no-unpublished-require": 0 39 | } 40 | // ignores: ["benchmark/test.js", "test/typescript/hello-world/out/*.js"] 41 | }, 42 | { 43 | files: ["test/**/*.js"], 44 | rules: { 45 | "no-console": ["off"], 46 | "no-unused-vars": ["off"] 47 | } 48 | }, 49 | { 50 | files: ["benchmarks/**/*.js", "examples/**/*.js"], 51 | rules: { 52 | "no-console": ["off"], 53 | "no-unused-vars": ["off"] 54 | } 55 | } 56 | ]; 57 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/api/apollo-server#options 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 | return _.cloneDeep(post); 166 | } 167 | }, 168 | 169 | downvote: { 170 | params: { 171 | id: "number", 172 | userID: "number" 173 | }, 174 | graphql: { 175 | mutation: gql` 176 | type Mutation { 177 | downvote(id: Int!, userID: Int!): Post 178 | } 179 | ` 180 | }, 181 | async handler(ctx) { 182 | const post = this.findByID(ctx.params.id); 183 | if (!post) { 184 | throw new MoleculerClientError("Post is not found"); 185 | } 186 | 187 | const has = post.voters.find(voter => voter == ctx.params.userID); 188 | if (!has) { 189 | throw new MoleculerClientError("User has not voted this post yet"); 190 | } 191 | 192 | post.voters = post.voters.filter(voter => voter != ctx.params.userID); 193 | post.votes = post.voters.length; 194 | 195 | return _.cloneDeep(post); 196 | } 197 | }, 198 | error: { 199 | handler() { 200 | throw new Error("Oh look an error !"); 201 | } 202 | } 203 | }, 204 | 205 | methods: { 206 | findByID(id) { 207 | return posts.find(post => post.id == id); 208 | } 209 | } 210 | }; 211 | -------------------------------------------------------------------------------- /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/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({ 10 | logLevel: "info", 11 | tracing: { 12 | enabled: true, 13 | exporter: { 14 | type: "Console" 15 | } 16 | } 17 | }); 18 | 19 | broker.createService({ 20 | name: "api", 21 | 22 | mixins: [ 23 | // Gateway 24 | ApiGateway, 25 | 26 | // GraphQL Apollo Server 27 | ApolloService({ 28 | // API Gateway route options 29 | routeOptions: { 30 | path: "/graphql", 31 | cors: true, 32 | mappingPolicy: "restrict" 33 | }, 34 | 35 | checkActionVisibility: true, 36 | 37 | // https://www.apollographql.com/docs/apollo-server/api/apollo-server#options 38 | serverOptions: {} 39 | }) 40 | ], 41 | 42 | events: { 43 | "graphql.schema.updated"(ctx) { 44 | this.logger.info("Generated GraphQL schema:\n\n" + ctx.params.schema); 45 | } 46 | } 47 | }); 48 | 49 | broker.createService({ 50 | name: "greeter", 51 | 52 | actions: { 53 | hello: { 54 | graphql: { 55 | query: "hello: String!" 56 | }, 57 | handler() { 58 | return "Hello Moleculer!"; 59 | } 60 | }, 61 | welcome: { 62 | graphql: { 63 | mutation: ` 64 | welcome( 65 | name: String! 66 | ): String! 67 | ` 68 | }, 69 | handler(ctx) { 70 | return `Hello ${ctx.params.name}`; 71 | } 72 | }, 73 | 74 | update: { 75 | graphql: { 76 | mutation: "update(id: Int!): Boolean!" 77 | }, 78 | async handler(ctx) { 79 | await ctx.broadcast("graphql.publish", { tag: "UPDATED", payload: ctx.params.id }); 80 | 81 | return true; 82 | } 83 | }, 84 | 85 | updated: { 86 | graphql: { 87 | subscription: "updated: Int!", 88 | tags: ["UPDATED"], 89 | filter: "greeter.updatedFilter" 90 | }, 91 | handler(ctx) { 92 | return ctx.params.payload; 93 | } 94 | }, 95 | 96 | delete: { 97 | graphql: { 98 | subscription: "delete: Int!", 99 | tags: ["DELETE"] 100 | }, 101 | handler(ctx) { 102 | return ctx.params.payload; 103 | } 104 | }, 105 | 106 | updatedFilter: { 107 | handler(ctx) { 108 | return ctx.params.payload % 2 === 0; 109 | } 110 | }, 111 | 112 | danger: { 113 | graphql: { 114 | query: "danger: String!" 115 | }, 116 | async handler() { 117 | throw new MoleculerClientError("I've said it's a danger action!", 422, "DANGER"); 118 | } 119 | }, 120 | 121 | secret: { 122 | visibility: "protected", 123 | graphql: { 124 | query: "secret: String!" 125 | }, 126 | async handler() { 127 | return "! TOP SECRET !"; 128 | } 129 | }, 130 | 131 | visible: { 132 | visibility: "published", 133 | graphql: { 134 | query: "visible: String!" 135 | }, 136 | async handler() { 137 | return "Not secret"; 138 | } 139 | } 140 | } 141 | }); 142 | 143 | broker.start().then(async () => { 144 | broker.repl(); 145 | 146 | const res = await broker.call("api.graphql", { 147 | query: "query { hello }" 148 | }); 149 | 150 | if (res.errors && res.errors.length > 0) return res.errors.forEach(broker.logger.error); 151 | 152 | broker.logger.info(res.data); 153 | 154 | broker.logger.info("----------------------------------------------------------"); 155 | broker.logger.info("Open the http://localhost:3000/graphql URL in your browser"); 156 | broker.logger.info("----------------------------------------------------------"); 157 | 158 | let counter = 1; 159 | setInterval( 160 | async () => broker.broadcast("graphql.publish", { tag: "UPDATED", payload: counter++ }), 161 | 2000 162 | ); 163 | }); 164 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { IExecutableSchemaDefinition } from "@graphql-tools/schema"; 2 | import { GraphQLSchema, GraphQLScalarType } from "graphql"; 3 | import { PubSub } from "graphql-subscriptions"; 4 | import { WebSocketServer } from "ws"; 5 | 6 | import { ServiceSchema } from "moleculer"; 7 | import { ApiRouteSchema, GatewayResponse, IncomingRequest } from "moleculer-web"; 8 | import { 9 | ApolloServer as ApolloServerBase, 10 | BaseContext, 11 | ApolloServerOptions as BaseApolloServerOptions 12 | } from "@apollo/server"; 13 | import { ServerOptions as WsServerOptions } from "graphql-ws"; 14 | 15 | interface GraphQLActionOptions { 16 | query?: string | string[]; 17 | mutation?: string | string[]; 18 | subscription?: string | string[]; 19 | type?: string | string[]; 20 | interface?: string | string[]; 21 | union?: string | string[]; 22 | enum?: string | string[]; 23 | input?: string | string[]; 24 | tags?: string[]; 25 | filter?: string; 26 | dataLoaderOptions?: any; 27 | dataLoaderBatchParam?: string; 28 | } 29 | 30 | declare module "moleculer-apollo-server" { 31 | export { GraphQLError } from "graphql"; 32 | 33 | export type ContextCreator = (args: { 34 | req: IncomingRequest; 35 | res: GatewayResponse; 36 | }) => BaseContext | Promise; 37 | 38 | export interface ApolloServerOptions { 39 | path: string; 40 | subscriptions?: boolean | WsServerOptions; 41 | } 42 | 43 | export class ApolloServer extends ApolloServerBase { 44 | createHandler( 45 | context: ContextCreator 46 | ): (req: IncomingRequest, res: GatewayResponse) => Promise; 47 | } 48 | 49 | export interface ActionResolverSchema { 50 | action: string; 51 | rootParams?: { 52 | [key: string]: string; 53 | }; 54 | dataLoader?: boolean; 55 | nullIfError?: boolean; 56 | skipNullKeys?: boolean; 57 | params?: { [key: string]: any }; 58 | } 59 | 60 | export interface ServiceResolverSchema { 61 | [key: string]: 62 | | { 63 | [key: string]: ActionResolverSchema; 64 | } 65 | | GraphQLScalarType; 66 | } 67 | 68 | export interface ServiceGraphQLSettings { 69 | query?: string | string[]; 70 | mutation?: string | string[]; 71 | subscription?: string | string[]; 72 | type?: string | string[]; 73 | interface?: string | string[]; 74 | union?: string | string[]; 75 | enum?: string | string[]; 76 | input?: string | string[]; 77 | resolvers?: ServiceResolverSchema; 78 | } 79 | 80 | export interface ApolloMixinOptions { 81 | serverOptions?: Partial> & { 82 | subscriptions?: boolean | WsServerOptions; 83 | }; 84 | routeOptions?: ApiRouteSchema; 85 | 86 | typeDefs?: string | string[]; 87 | resolvers?: ServiceResolverSchema; 88 | 89 | subscriptionEventName?: string; 90 | invalidateEventName?: string; 91 | 92 | createAction?: boolean; 93 | checkActionVisibility?: boolean; 94 | autoUpdateSchema?: boolean; 95 | } 96 | 97 | export interface ApolloServiceMethods { 98 | invalidateGraphQLSchema(): void; 99 | getFieldName(declaration: string): string; 100 | getResolverActionName(service: string, action: string): string; 101 | createServiceResolvers( 102 | serviceName: string, 103 | resolvers: { [key: string]: ActionResolverSchema } 104 | ): { [key: string]: Function }; 105 | createActionResolver(actionName: string, def?: ActionResolverSchema): Function; 106 | getDataLoaderMapKey(actionName: string, staticParams: object, args: object): string; 107 | buildDataLoader( 108 | ctx: any, 109 | actionName: string, 110 | batchedParamKey: string, 111 | staticParams: object, 112 | args: object, 113 | options?: { hashCacheKey?: boolean } 114 | ): any; 115 | buildLoaderOptionMap(services: ServiceSchema[]): void; 116 | createAsyncIteratorResolver( 117 | actionName: string, 118 | tags?: string[], 119 | filter?: string 120 | ): { subscribe: Function; resolve: Function }; 121 | generateGraphQLSchema(services: ServiceSchema[]): Promise; 122 | makeExecutableSchema(schemaDef: IExecutableSchemaDefinition): Promise; 123 | createPubSub(): PubSub | Promise; 124 | prepareGraphQLSchema(): Promise; 125 | createGraphqlContext(args: { req: any }): BaseContext; 126 | prepareContextParams?( 127 | mergedParams: any, 128 | actionName: string, 129 | context: BaseContext, 130 | root: any, 131 | args: any 132 | ): Promise; 133 | } 134 | 135 | export interface ApolloServiceLocalVars { 136 | apolloServer?: ApolloServer; 137 | graphqlHandler?: Function; 138 | graphqlSchema?: GraphQLSchema; 139 | shouldUpdateGraphqlSchema: boolean; 140 | dataLoaderOptions: Map; 141 | dataLoaderBatchParams: Map; 142 | pubsub?: PubSub; 143 | wsServer?: WebSocketServer; 144 | } 145 | 146 | export interface ApolloServiceSettings { 147 | graphql?: ServiceGraphQLSettings; 148 | } 149 | 150 | export function ApolloService(options: ApolloMixinOptions): ServiceSchema; 151 | 152 | export function moleculerGql( 153 | typeString: TemplateStringsArray | string, 154 | ...placeholders: any[] 155 | ): string; 156 | } 157 | 158 | declare module "moleculer" { 159 | interface ActionSchema { 160 | graphql?: GraphQLActionOptions; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Apollo Server for Moleculer API Gateway. 3 | * 4 | * Copyright (c) 2025 MoleculerJS (https://github.com/moleculerjs/moleculer-apollo-server) 5 | * MIT Licensed 6 | */ 7 | 8 | "use strict"; 9 | 10 | const { GraphQLError } = require("graphql"); 11 | 12 | const { ApolloServer } = require("./src/ApolloServer"); 13 | const ApolloService = require("./src/service"); 14 | const gql = require("./src/gql"); 15 | 16 | module.exports = { 17 | GraphQLError, 18 | 19 | // Apollo Server 20 | ApolloServer, 21 | 22 | // Apollo Moleculer Service 23 | ApolloService, 24 | 25 | // Moleculer gql formatter 26 | moleculerGql: gql 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moleculer-apollo-server", 3 | "version": "0.4.0", 4 | "description": "Apollo GraphQL server for Moleculer API Gateway", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "dev": "nodemon --inspect examples/index.js", 9 | "ci": "jest --watch", 10 | "test": "jest --coverage", 11 | "lint": "eslint --ext=.js src test", 12 | "lint:fix": "eslint --fix --ext=.js src test", 13 | "deps": "ncu -i --format group", 14 | "postdeps": "npm test", 15 | "typecheck": "tsc --project tsconfig.json", 16 | "test:ts": "tsc --project test/typescript/tsconfig.json && tsx test/typescript/index.ts" 17 | }, 18 | "keywords": [ 19 | "graphql", 20 | "apollo-server", 21 | "apollo", 22 | "moleculer", 23 | "microservice", 24 | "gateway" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/moleculerjs/moleculer-apollo-server.git" 29 | }, 30 | "author": "MoleculerJS", 31 | "license": "MIT", 32 | "peerDependencies": { 33 | "graphql": "^16.0.0", 34 | "moleculer": "^0.14.0 || ^0.15.0-0" 35 | }, 36 | "devDependencies": { 37 | "eslint": "^9.36.0", 38 | "eslint-config-prettier": "^10.1.8", 39 | "eslint-plugin-node": "^11.1.0", 40 | "eslint-plugin-prettier": "^5.5.4", 41 | "graphql": "^16.11.0", 42 | "jest": "^30.1.3", 43 | "jest-cli": "^30.1.3", 44 | "moleculer": "github:moleculerjs/moleculer#next", 45 | "moleculer-repl": "^0.7.4", 46 | "moleculer-web": "github:moleculerjs/moleculer-web#next", 47 | "nodemon": "^3.1.10", 48 | "npm-check-updates": "^18.3.0", 49 | "prettier": "^3.6.2", 50 | "tsx": "^4.20.5", 51 | "typescript": "^5.9.2" 52 | }, 53 | "jest": { 54 | "coverageDirectory": "../coverage", 55 | "testEnvironment": "node", 56 | "rootDir": "./src", 57 | "roots": [ 58 | "../test" 59 | ], 60 | "coveragePathIgnorePatterns": [ 61 | "/node_modules/", 62 | "/test/services/" 63 | ] 64 | }, 65 | "engines": { 66 | "node": ">= 20.x.x" 67 | }, 68 | "dependencies": { 69 | "@apollo/server": "^5.0.0", 70 | "@graphql-tools/schema": "^10.0.25", 71 | "dataloader": "^2.2.3", 72 | "graphql-subscriptions": "^3.0.0", 73 | "graphql-ws": "^6.0.6", 74 | "lodash": "^4.17.21", 75 | "object-hash": "^3.0.0", 76 | "ws": "^8.18.3" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: true, 3 | printWidth: 100, 4 | trailingComma: "none", 5 | tabWidth: 4, 6 | singleQuote: false, 7 | semi: true, 8 | bracketSpacing: true, 9 | arrowParens: "avoid", 10 | overrides: [ 11 | { 12 | files: "*.md", 13 | options: { 14 | useTabs: false 15 | } 16 | }, 17 | { 18 | files: "*.json", 19 | options: { 20 | tabWidth: 2, 21 | useTabs: false 22 | } 23 | } 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /src/ApolloServer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * moleculer-apollo-server 3 | * Copyright (c) 2025 MoleculerJS (https://github.com/moleculerjs/moleculer-apollo-server) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const { ApolloServer: ApolloServerBase, HeaderMap } = require("@apollo/server"); 10 | const url = require("url"); 11 | 12 | // Utility function used to set multiple headers on a response object. 13 | function convertHeaderMapToHeaders(res, headers) { 14 | for (const [key, value] of headers) { 15 | res.setHeader(key, value); 16 | } 17 | } 18 | 19 | function convertHeadersToHeaderMap(req) { 20 | const headers = new HeaderMap(); 21 | for (const [key, value] of Object.entries(req.headers)) { 22 | if (value !== undefined) { 23 | headers.set(key, Array.isArray(value) ? value.join(", ") : value); 24 | } 25 | } 26 | return headers; 27 | } 28 | 29 | async function send(req, res, statusCode, data, responseType = "application/json") { 30 | res.statusCode = statusCode; 31 | 32 | const ctx = res.$ctx; 33 | if (!ctx.meta.$responseType) { 34 | ctx.meta.$responseType = responseType; 35 | } 36 | 37 | const route = res.$route; 38 | if (route.onAfterCall) { 39 | data = await route.onAfterCall.call(this, ctx, route, req, res, data); 40 | } 41 | 42 | const service = res.$service; 43 | service.sendResponse(req, res, data); 44 | } 45 | 46 | class ApolloServer extends ApolloServerBase { 47 | // Prepares and returns an async function that can be used to handle 48 | // GraphQL requests. 49 | createHandler(context) { 50 | return async (req, res) => { 51 | // Handle incoming GraphQL requests using Apollo Server. 52 | const response = await this.executeHTTPGraphQLRequest({ 53 | httpGraphQLRequest: { 54 | method: req.method.toUpperCase(), 55 | headers: convertHeadersToHeaderMap(req), 56 | search: url.parse(req.url, true).query ?? "", 57 | body: req.body 58 | }, 59 | context: () => context({ req, res }) 60 | }); 61 | 62 | convertHeaderMapToHeaders(res, response.headers); 63 | 64 | if (response?.body?.kind == "complete") { 65 | return send( 66 | req, 67 | res, 68 | response.status ?? 200, 69 | response.body.string, 70 | response.headers?.get("content-type") 71 | ); 72 | } 73 | 74 | // Handle chunked response 75 | res.statusCode = response.status || 200; 76 | for await (const chunk of response.body.asyncIterator) { 77 | res.write(chunk); 78 | } 79 | res.end(); 80 | }; 81 | } 82 | } 83 | module.exports = { 84 | ApolloServer 85 | }; 86 | -------------------------------------------------------------------------------- /src/gql.js: -------------------------------------------------------------------------------- 1 | /* 2 | * moleculer-apollo-server 3 | * Copyright (c) 2025 MoleculerJS (https://github.com/moleculerjs/moleculer-apollo-server) 4 | * MIT Licensed 5 | */ 6 | 7 | const { zip } = require("lodash"); 8 | 9 | /** 10 | * @function gql Format graphql strings for usage in moleculer-apollo-server 11 | * @param {TemplateStringsArray} typeString - Template string array for formatting 12 | * @param {...string} placeholders - Placeholder expressions 13 | */ 14 | const gql = (typeString, ...placeholders) => { 15 | // combine template string array and placeholders into a single string 16 | const zipped = zip(typeString, placeholders); 17 | const combinedString = zipped.reduce( 18 | (prev, [next, placeholder]) => `${prev}${next}${placeholder || ""}`, 19 | "" 20 | ); 21 | const re = /type\s+(Query|Mutation|Subscription)\s+{(.*?)}/s; 22 | 23 | const result = re.exec(combinedString); 24 | // eliminate Query/Mutation/Subscription wrapper if present as moleculer-apollo-server will stitch them together 25 | return Array.isArray(result) ? result[2] : combinedString; 26 | }; 27 | 28 | module.exports = gql; 29 | -------------------------------------------------------------------------------- /src/service.js: -------------------------------------------------------------------------------- 1 | /* 2 | * moleculer-apollo-server 3 | * Copyright (c) 2025 MoleculerJS (https://github.com/moleculerjs/moleculer-apollo-server) 4 | * MIT Licensed 5 | */ 6 | 7 | "use strict"; 8 | 9 | const _ = require("lodash"); 10 | const hash = require("object-hash"); 11 | 12 | const { MoleculerServerError } = require("moleculer").Errors; 13 | const { ApolloServer } = require("./ApolloServer"); 14 | const DataLoader = require("dataloader"); 15 | const { makeExecutableSchema } = require("@graphql-tools/schema"); 16 | const GraphQL = require("graphql"); 17 | 18 | const { PubSub, withFilter } = require("graphql-subscriptions"); 19 | const { WebSocketServer } = require("ws"); 20 | const { useServer } = require("graphql-ws/use/ws"); 21 | 22 | module.exports = function (mixinOptions) { 23 | mixinOptions = _.defaultsDeep(mixinOptions, { 24 | serverOptions: {}, 25 | routeOptions: { 26 | path: "/graphql" 27 | }, 28 | 29 | typeDefs: null, 30 | resolvers: null, 31 | schemaDirectives: null, 32 | 33 | subscriptionEventName: "graphql.publish", 34 | invalidateEventName: "graphql.invalidate", 35 | 36 | createAction: true, 37 | autoUpdateSchema: true, 38 | checkActionVisibility: false 39 | }); 40 | 41 | const serviceSchema = { 42 | actions: { 43 | subscription: { 44 | timeout: 0, 45 | visibility: "private", 46 | tracing: { 47 | tags: { 48 | params: ["req.url"] 49 | }, 50 | spanName: ctx => `SUBSCRIPTION ${ctx.params.req.url}` 51 | }, 52 | handler(ctx) { 53 | const { socket, connectionParams, req } = ctx.params; 54 | return { 55 | $ctx: ctx, 56 | $socket: socket, 57 | $service: this, 58 | $params: { body: connectionParams, query: req.query } 59 | }; 60 | } 61 | } 62 | }, 63 | events: { 64 | [mixinOptions.invalidateEventName]() { 65 | this.invalidateGraphQLSchema(); 66 | }, 67 | 68 | [mixinOptions.subscriptionEventName]: { 69 | params: { 70 | tag: { type: "string" }, 71 | payload: { type: "any", optional: true } 72 | }, 73 | handler(ctx) { 74 | if (this.pubsub) { 75 | this.pubsub.publish(ctx.params.tag, ctx.params.payload); 76 | } 77 | } 78 | }, 79 | 80 | "$services.changed"() { 81 | if (mixinOptions.autoUpdateSchema) { 82 | this.invalidateGraphQLSchema(); 83 | } 84 | } 85 | }, 86 | 87 | methods: { 88 | /** 89 | * Invalidate the generated GraphQL schema 90 | */ 91 | invalidateGraphQLSchema() { 92 | this.shouldUpdateGraphqlSchema = true; 93 | }, 94 | 95 | /** 96 | * Return the field name in a GraphQL Mutation, Query, or Subscription declaration 97 | * @param {String} declaration - Mutation, Query, or Subscription declaration 98 | * @returns {String} Field name of declaration 99 | */ 100 | getFieldName(declaration) { 101 | // Remove all multi-line/single-line descriptions and comments 102 | const cleanedDeclaration = declaration 103 | .replace(/"([\s\S]*?)"/g, "") 104 | .replace(/^[\s]*?#.*\n?/gm, "") 105 | .trim(); 106 | return cleanedDeclaration.split(/[(:]/g)[0]; 107 | }, 108 | 109 | /** 110 | * Get action name for resolver 111 | * 112 | * @param {String} service 113 | * @param {String} action 114 | */ 115 | getResolverActionName(service, action) { 116 | if (action.indexOf(".") === -1) { 117 | return `${service}.${action}`; 118 | } else { 119 | return action; 120 | } 121 | }, 122 | 123 | /** 124 | * Create resolvers from service settings 125 | * 126 | * @param {String} serviceName 127 | * @param {Object} resolvers 128 | */ 129 | createServiceResolvers(serviceName, resolvers) { 130 | return Object.entries(resolvers).reduce((acc, [name, r]) => { 131 | if (_.isPlainObject(r) && r.action != null) { 132 | // matches signature for remote action resolver 133 | acc[name] = this.createActionResolver( 134 | this.getResolverActionName(serviceName, r.action), 135 | r 136 | ); 137 | } else { 138 | // something else (enum, etc.) 139 | acc[name] = r; 140 | } 141 | 142 | return acc; 143 | }, {}); 144 | }, 145 | 146 | /** 147 | * Create resolver for action 148 | * 149 | * @param {String} actionName 150 | * @param {Object?} def 151 | */ 152 | createActionResolver(actionName, def = {}) { 153 | const { 154 | dataLoader: useDataLoader = false, 155 | nullIfError = false, 156 | params: staticParams = {}, 157 | rootParams = {} 158 | } = def; 159 | const rootKeys = Object.keys(rootParams); 160 | 161 | return async (root, args, context) => { 162 | try { 163 | if (useDataLoader) { 164 | const dataLoaderMapKey = this.getDataLoaderMapKey( 165 | actionName, 166 | staticParams, 167 | args 168 | ); 169 | // if a dataLoader batching parameter is specified, then all root params can be data loaded; 170 | // otherwise use only the primary rootParam 171 | const primaryDataLoaderRootKey = rootKeys[0]; // for dataloader, use the first root key only 172 | const dataLoaderBatchParam = this.dataLoaderBatchParams.get(actionName); 173 | const dataLoaderUseAllRootKeys = dataLoaderBatchParam != null; 174 | 175 | // check to see if the DataLoader has already been added to the GraphQL context; if not then add it for subsequent use 176 | let dataLoader; 177 | if (context.dataLoaders.has(dataLoaderMapKey)) { 178 | dataLoader = context.dataLoaders.get(dataLoaderMapKey); 179 | } else { 180 | const batchedParamKey = 181 | dataLoaderBatchParam || rootParams[primaryDataLoaderRootKey]; 182 | 183 | dataLoader = this.buildDataLoader( 184 | context.ctx, 185 | actionName, 186 | batchedParamKey, 187 | staticParams, 188 | args, 189 | { hashCacheKey: dataLoaderUseAllRootKeys } // must hash the cache key if not loading scalar 190 | ); 191 | context.dataLoaders.set(dataLoaderMapKey, dataLoader); 192 | } 193 | 194 | let dataLoaderKey; 195 | if (dataLoaderUseAllRootKeys) { 196 | if (root && rootKeys) { 197 | dataLoaderKey = {}; 198 | 199 | rootKeys.forEach(key => { 200 | _.set(dataLoaderKey, rootParams[key], _.get(root, key)); 201 | }); 202 | } 203 | } else { 204 | dataLoaderKey = root && _.get(root, primaryDataLoaderRootKey); 205 | } 206 | 207 | if (dataLoaderKey == null) { 208 | return null; 209 | } 210 | 211 | return Array.isArray(dataLoaderKey) 212 | ? await dataLoader.loadMany(dataLoaderKey) 213 | : await dataLoader.load(dataLoaderKey); 214 | } else { 215 | const params = {}; 216 | let hasRootKeyValue = false; 217 | if (root && rootKeys) { 218 | rootKeys.forEach(key => { 219 | const v = _.get(root, key); 220 | _.set(params, rootParams[key], v); 221 | if (v != null) hasRootKeyValue = true; 222 | }); 223 | 224 | if (def.skipNullKeys && !hasRootKeyValue) { 225 | return null; 226 | } 227 | } 228 | 229 | let mergedParams = _.defaultsDeep({}, args, params, staticParams); 230 | 231 | if (this.prepareContextParams) { 232 | mergedParams = await this.prepareContextParams( 233 | mergedParams, 234 | actionName, 235 | context, 236 | root, 237 | args 238 | ); 239 | } 240 | 241 | return await context.ctx.call(actionName, mergedParams); 242 | } 243 | } catch (err) { 244 | if (nullIfError) { 245 | return null; 246 | } 247 | throw err; 248 | } 249 | }; 250 | }, 251 | 252 | /** 253 | * Get the unique key assigned to the DataLoader map 254 | * @param {string} actionName - Fully qualified action name to bind to dataloader 255 | * @param {Object.} staticParams - Static parameters to use in dataloader 256 | * @param {Object.} args - Arguments passed to GraphQL child resolver 257 | * @returns {string} Key to the dataloader instance 258 | */ 259 | getDataLoaderMapKey(actionName, staticParams, args) { 260 | if (Object.keys(staticParams).length > 0 || Object.keys(args).length > 0) { 261 | // create a unique hash of the static params and the arguments to ensure a unique DataLoader instance 262 | const actionParams = _.defaultsDeep({}, args, staticParams); 263 | const paramsHash = hash(actionParams); 264 | return `${actionName}:${paramsHash}`; 265 | } 266 | 267 | // if no static params or arguments are present then the action name can serve as the key 268 | return actionName; 269 | }, 270 | 271 | /** 272 | * Build a DataLoader instance 273 | * 274 | * @param {Object} ctx - Moleculer context 275 | * @param {string} actionName - Fully qualified action name to bind to dataloader 276 | * @param {string} batchedParamKey - Parameter key to use for loaded values 277 | * @param {Object} staticParams - Static parameters to use in dataloader 278 | * @param {Object} args - Arguments passed to GraphQL child resolver 279 | * @param {Object} [options={}] - Optional arguments 280 | * @param {Boolean} [options.hashCacheKey=false] - Use a hash for the cacheKeyFn 281 | * @returns {DataLoader} Dataloader instance 282 | */ 283 | buildDataLoader( 284 | ctx, 285 | actionName, 286 | batchedParamKey, 287 | staticParams, 288 | args, 289 | { hashCacheKey = false } = {} 290 | ) { 291 | const batchLoadFn = keys => { 292 | const rootParams = { [batchedParamKey]: keys }; 293 | return ctx.call(actionName, _.defaultsDeep({}, args, rootParams, staticParams)); 294 | }; 295 | 296 | const dataLoaderOptions = this.dataLoaderOptions.get(actionName) || {}; 297 | const cacheKeyFn = hashCacheKey && (key => hash(key)); 298 | const options = { 299 | ...(cacheKeyFn && { cacheKeyFn }), 300 | ...dataLoaderOptions 301 | }; 302 | 303 | return new DataLoader(batchLoadFn, options); 304 | }, 305 | 306 | /** 307 | * Create resolver for subscription 308 | * 309 | * @param {String} actionName 310 | * @param {Array?} tags 311 | * @param {String?} filter 312 | */ 313 | createAsyncIteratorResolver(actionName, tags = [], filter) { 314 | return { 315 | subscribe: filter 316 | ? withFilter( 317 | () => this.pubsub.asyncIterableIterator(tags), 318 | async (payload, params, { ctx }) => 319 | payload !== undefined 320 | ? ctx.call(filter, { ...params, payload }) 321 | : false 322 | ) 323 | : () => this.pubsub.asyncIterableIterator(tags), 324 | resolve: (payload, params, { ctx }) => { 325 | return ctx.call(actionName, { ...params, payload }); 326 | } 327 | }; 328 | }, 329 | 330 | /** 331 | * Generate GraphQL Schema 332 | * 333 | * @param {Object[]} services 334 | * @returns {Object} Generated schema 335 | */ 336 | async generateGraphQLSchema(services) { 337 | let str; 338 | try { 339 | let typeDefs = []; 340 | let resolvers = {}; 341 | // TODO: let schemaDirectives = null; 342 | 343 | if (mixinOptions.typeDefs) { 344 | typeDefs = typeDefs.concat(mixinOptions.typeDefs); 345 | } 346 | 347 | if (mixinOptions.resolvers) { 348 | resolvers = _.cloneDeep(mixinOptions.resolvers); 349 | } 350 | 351 | // TODO: 352 | // if (mixinOptions.schemaDirectives) { 353 | // schemaDirectives = _.cloneDeep(mixinOptions.schemaDirectives); 354 | // } 355 | 356 | let queries = []; 357 | let mutations = []; 358 | let subscriptions = []; 359 | let types = []; 360 | let interfaces = []; 361 | let unions = []; 362 | let enums = []; 363 | let inputs = []; 364 | 365 | const processedServices = new Set(); 366 | 367 | services.forEach(service => { 368 | const serviceName = service.fullName; 369 | 370 | // Skip multiple instances of services 371 | if (processedServices.has(serviceName)) return; 372 | processedServices.add(serviceName); 373 | 374 | if (service.settings && service.settings.graphql) { 375 | // --- COMPILE SERVICE-LEVEL DEFINITIONS --- 376 | if (_.isObject(service.settings.graphql)) { 377 | const globalDef = service.settings.graphql; 378 | 379 | if (globalDef.query) { 380 | queries = queries.concat(globalDef.query); 381 | } 382 | 383 | if (globalDef.mutation) { 384 | mutations = mutations.concat(globalDef.mutation); 385 | } 386 | 387 | if (globalDef.subscription) { 388 | subscriptions = subscriptions.concat(globalDef.subscription); 389 | } 390 | 391 | if (globalDef.type) { 392 | types = types.concat(globalDef.type); 393 | } 394 | 395 | if (globalDef.interface) { 396 | interfaces = interfaces.concat(globalDef.interface); 397 | } 398 | 399 | if (globalDef.union) { 400 | unions = unions.concat(globalDef.union); 401 | } 402 | 403 | if (globalDef.enum) { 404 | enums = enums.concat(globalDef.enum); 405 | } 406 | 407 | if (globalDef.input) { 408 | inputs = inputs.concat(globalDef.input); 409 | } 410 | 411 | if (globalDef.resolvers) { 412 | resolvers = Object.entries(globalDef.resolvers).reduce( 413 | (acc, [name, resolver]) => { 414 | acc[name] = _.merge( 415 | acc[name] || {}, 416 | this.createServiceResolvers(serviceName, resolver) 417 | ); 418 | return acc; 419 | }, 420 | resolvers 421 | ); 422 | } 423 | } 424 | } 425 | 426 | // --- COMPILE ACTION-LEVEL DEFINITIONS --- 427 | const resolver = {}; 428 | 429 | Object.values(service.actions).forEach(action => { 430 | const { graphql: def } = action; 431 | if ( 432 | mixinOptions.checkActionVisibility && 433 | action.visibility != null && 434 | action.visibility != "published" 435 | ) 436 | return; 437 | 438 | if (def && _.isObject(def)) { 439 | if (def.query) { 440 | if (!resolver["Query"]) resolver.Query = {}; 441 | 442 | _.castArray(def.query).forEach(query => { 443 | const name = this.getFieldName(query); 444 | queries.push(query); 445 | resolver.Query[name] = this.createActionResolver( 446 | action.name 447 | ); 448 | }); 449 | } 450 | 451 | if (def.mutation) { 452 | if (!resolver["Mutation"]) resolver.Mutation = {}; 453 | 454 | _.castArray(def.mutation).forEach(mutation => { 455 | const name = this.getFieldName(mutation); 456 | mutations.push(mutation); 457 | resolver.Mutation[name] = this.createActionResolver( 458 | action.name 459 | ); 460 | }); 461 | } 462 | 463 | if (def.subscription) { 464 | if (!resolver["Subscription"]) resolver.Subscription = {}; 465 | 466 | _.castArray(def.subscription).forEach(subscription => { 467 | const name = this.getFieldName(subscription); 468 | subscriptions.push(subscription); 469 | resolver.Subscription[name] = 470 | this.createAsyncIteratorResolver( 471 | action.name, 472 | def.tags, 473 | def.filter 474 | ); 475 | }); 476 | } 477 | 478 | if (def.type) { 479 | types = types.concat(def.type); 480 | } 481 | 482 | if (def.interface) { 483 | interfaces = interfaces.concat(def.interface); 484 | } 485 | 486 | if (def.union) { 487 | unions = unions.concat(def.union); 488 | } 489 | 490 | if (def.enum) { 491 | enums = enums.concat(def.enum); 492 | } 493 | 494 | if (def.input) { 495 | inputs = inputs.concat(def.input); 496 | } 497 | } 498 | }); 499 | 500 | if (Object.keys(resolver).length > 0) { 501 | resolvers = _.merge(resolvers, resolver); 502 | } 503 | }); 504 | 505 | if ( 506 | queries.length > 0 || 507 | types.length > 0 || 508 | mutations.length > 0 || 509 | subscriptions.length > 0 || 510 | interfaces.length > 0 || 511 | unions.length > 0 || 512 | enums.length > 0 || 513 | inputs.length > 0 514 | ) { 515 | str = ""; 516 | if (queries.length > 0) { 517 | str += ` 518 | type Query { 519 | ${queries.join("\n")} 520 | } 521 | `; 522 | } 523 | 524 | if (mutations.length > 0) { 525 | str += ` 526 | type Mutation { 527 | ${mutations.join("\n")} 528 | } 529 | `; 530 | } 531 | 532 | if (subscriptions.length > 0) { 533 | str += ` 534 | type Subscription { 535 | ${subscriptions.join("\n")} 536 | } 537 | `; 538 | } 539 | 540 | if (types.length > 0) { 541 | str += ` 542 | ${types.join("\n")} 543 | `; 544 | } 545 | 546 | if (interfaces.length > 0) { 547 | str += ` 548 | ${interfaces.join("\n")} 549 | `; 550 | } 551 | 552 | if (unions.length > 0) { 553 | str += ` 554 | ${unions.join("\n")} 555 | `; 556 | } 557 | 558 | if (enums.length > 0) { 559 | str += ` 560 | ${enums.join("\n")} 561 | `; 562 | } 563 | 564 | if (inputs.length > 0) { 565 | str += ` 566 | ${inputs.join("\n")} 567 | `; 568 | } 569 | 570 | typeDefs.push(str); 571 | } 572 | 573 | return await this.makeExecutableSchema({ typeDefs, resolvers }); 574 | } catch (err) { 575 | throw new MoleculerServerError( 576 | "Unable to compile GraphQL schema", 577 | 500, 578 | "UNABLE_COMPILE_GRAPHQL_SCHEMA", 579 | { err, str } 580 | ); 581 | } 582 | }, 583 | 584 | /** 585 | * Call the `makeExecutableSchema`. If you would like 586 | * to manipulate the concatenated typeDefs, or the generated schema, 587 | * just overwrite it in your service file. 588 | * @param {Object} schemaDef 589 | */ 590 | async makeExecutableSchema(schemaDef) { 591 | return makeExecutableSchema(schemaDef); 592 | }, 593 | 594 | /** 595 | * Create PubSub instance. If you want to use your own PubSub implementation, 596 | * just overwrite this method in your service file. 597 | * @returns {PubSub} PubSub instance 598 | */ 599 | createPubSub() { 600 | return new PubSub(); 601 | }, 602 | 603 | /** 604 | * Prepare GraphQL schemas based on Moleculer services. 605 | */ 606 | async prepareGraphQLSchema() { 607 | // Schema is up-to-date 608 | if (!this.shouldUpdateGraphqlSchema && this.graphqlHandler) { 609 | return; 610 | } 611 | 612 | if (this.preparePromise) return this.preparePromise.promise; 613 | 614 | this.preparePromise = {}; 615 | this.preparePromise.promise = new Promise((resolve, reject) => { 616 | this.preparePromise.resolve = resolve; 617 | this.preparePromise.reject = reject; 618 | }); 619 | 620 | if (this.apolloServer) { 621 | await this.apolloServer.stop(); 622 | } 623 | 624 | // Create new server & regenerate GraphQL schema 625 | this.logger.info( 626 | "♻ Recreate Apollo GraphQL server and regenerate GraphQL schema..." 627 | ); 628 | 629 | try { 630 | if (mixinOptions.serverOptions?.subscriptions !== false) { 631 | if (!this.pubsub) { 632 | this.pubsub = await this.createPubSub(); 633 | } 634 | } 635 | 636 | const services = this.broker.registry.getServiceList({ withActions: true }); 637 | const schema = await this.generateGraphQLSchema(services); 638 | 639 | this.logger.debug( 640 | "Generated GraphQL schema:\n\n" + GraphQL.printSchema(schema) 641 | ); 642 | 643 | const apolloServerOptions = { 644 | schema, 645 | ...(mixinOptions.serverOptions ?? {}) 646 | }; 647 | 648 | if (mixinOptions.serverOptions?.subscriptions !== false) { 649 | if (!this.wsServer) { 650 | this.wsServer = new WebSocketServer({ 651 | server: this.server, 652 | path: mixinOptions.routeOptions.path || "/graphql" 653 | }); 654 | } 655 | 656 | // Hand in the schema we just created and have the 657 | // WebSocketServer start listening. 658 | const serverCleanup = useServer( 659 | { 660 | ...(_.isObject(mixinOptions.serverOptions.subscriptions) 661 | ? mixinOptions.serverOptions.subscriptions 662 | : {}), 663 | 664 | schema, 665 | 666 | onConnect: async ctx => { 667 | ctx.$moleculer = await this.actions.subscription({ 668 | connectionParams: ctx.connectionParams, 669 | socket: ctx.extra.socket, 670 | req: ctx.extra.request 671 | }); 672 | 673 | if (mixinOptions.serverOptions.subscriptions?.onConnect) { 674 | return mixinOptions.serverOptions.subscriptions.onConnect.apply( 675 | this, 676 | arguments 677 | ); 678 | } 679 | 680 | return true; 681 | }, 682 | 683 | context: (ctx /*, id, payload, args*/) => { 684 | const newCtx = this.createGraphqlContext({ 685 | req: ctx.$moleculer 686 | }); 687 | 688 | if (mixinOptions.serverOptions.subscriptions?.context) { 689 | const customContext = 690 | mixinOptions.serverOptions.subscriptions.context.apply( 691 | this, 692 | arguments 693 | ); 694 | if (customContext != null) { 695 | Object.assign(newCtx, customContext); 696 | } 697 | } 698 | 699 | return newCtx; 700 | } 701 | }, 702 | this.wsServer 703 | ); 704 | 705 | if (!apolloServerOptions.plugins) apolloServerOptions.plugins = []; 706 | apolloServerOptions.plugins.push({ 707 | async serverWillStart() { 708 | return { 709 | async drainServer() { 710 | // Proper shutdown for the WebSocket server. 711 | await serverCleanup.dispose(); 712 | } 713 | }; 714 | } 715 | }); 716 | } 717 | 718 | this.apolloServer = new ApolloServer(apolloServerOptions); 719 | 720 | await this.apolloServer.start(); 721 | 722 | this.graphqlHandler = this.apolloServer.createHandler( 723 | this.createGraphqlContext 724 | ); 725 | 726 | this.graphqlSchema = schema; 727 | 728 | this.buildLoaderOptionMap(services); // rebuild the options for DataLoaders 729 | 730 | this.shouldUpdateGraphqlSchema = false; 731 | 732 | this.broker.broadcast("graphql.schema.updated", { 733 | schema: GraphQL.printSchema(schema) 734 | }); 735 | 736 | this.preparePromise.resolve(); 737 | this.preparePromise = null; 738 | } catch (err) { 739 | this.logger.error(err); 740 | this.preparePromise.reject(err); 741 | this.preparePromise = null; 742 | throw err; 743 | } 744 | }, 745 | 746 | /** 747 | * Create the GraphQL context for each request 748 | * 749 | * @param {*} args 750 | * @returns 751 | */ 752 | createGraphqlContext(args) { 753 | const context = { 754 | ctx: args.req.$ctx, 755 | service: args.req.$service, 756 | params: args.req.$params, 757 | 758 | dataLoaders: new Map() // create an empty map to load DataLoader instances into 759 | }; 760 | 761 | if (mixinOptions.serverOptions?.context) { 762 | const customContext = mixinOptions.serverOptions.context(context); 763 | if (customContext != null) { 764 | Object.assign(context, customContext); 765 | } 766 | } 767 | return context; 768 | }, 769 | 770 | /** 771 | * Build a map of options to use with DataLoader 772 | * 773 | * @param {Object[]} services 774 | * @modifies {this.dataLoaderOptions} 775 | * @modifies {this.dataLoaderBatchParams} 776 | */ 777 | buildLoaderOptionMap(services) { 778 | this.dataLoaderOptions.clear(); // clear map before rebuilding 779 | this.dataLoaderBatchParams.clear(); // clear map before rebuilding 780 | 781 | services.forEach(service => { 782 | Object.values(service.actions).forEach(action => { 783 | const { graphql: graphqlDefinition, name: actionName } = action; 784 | if ( 785 | graphqlDefinition && 786 | (graphqlDefinition.dataLoaderOptions || 787 | graphqlDefinition.dataLoaderBatchParam) 788 | ) { 789 | const serviceName = service.fullName; 790 | const fullActionName = this.getResolverActionName( 791 | serviceName, 792 | actionName 793 | ); 794 | 795 | if (graphqlDefinition.dataLoaderOptions) { 796 | this.dataLoaderOptions.set( 797 | fullActionName, 798 | graphqlDefinition.dataLoaderOptions 799 | ); 800 | } 801 | 802 | if (graphqlDefinition.dataLoaderBatchParam) { 803 | this.dataLoaderBatchParams.set( 804 | fullActionName, 805 | graphqlDefinition.dataLoaderBatchParam 806 | ); 807 | } 808 | } 809 | }); 810 | }); 811 | } 812 | }, 813 | 814 | created() { 815 | this.apolloServer = null; 816 | this.graphqlHandler = null; 817 | this.graphqlSchema = null; 818 | this.shouldUpdateGraphqlSchema = true; 819 | this.dataLoaderOptions = new Map(); 820 | this.dataLoaderBatchParams = new Map(); 821 | this.pubsub = null; 822 | this.wsServer = null; 823 | this.preparePromise = null; 824 | 825 | // Bind service to onConnect method 826 | if ( 827 | mixinOptions.serverOptions.subscriptions && 828 | _.isFunction(mixinOptions.serverOptions.subscriptions.onConnect) 829 | ) { 830 | mixinOptions.serverOptions.subscriptions.onConnect = 831 | mixinOptions.serverOptions.subscriptions.onConnect.bind(this); 832 | } 833 | 834 | const route = _.defaultsDeep(mixinOptions.routeOptions, { 835 | aliases: { 836 | async "/"(req, res) { 837 | try { 838 | await this.prepareGraphQLSchema(); 839 | return await this.graphqlHandler(req, res); 840 | } catch (err) { 841 | this.sendError(req, res, err); 842 | } 843 | } 844 | }, 845 | 846 | mappingPolicy: "restrict", 847 | 848 | bodyParsers: { 849 | json: true, 850 | urlencoded: { extended: true } 851 | } 852 | }); 853 | 854 | // Add route 855 | this.settings.routes.unshift(route); 856 | }, 857 | 858 | started() { 859 | this.logger.info(`🚀 GraphQL server is available at ${mixinOptions.routeOptions.path}`); 860 | }, 861 | 862 | async stopped() { 863 | if (this.apolloServer) { 864 | await this.apolloServer.stop(); 865 | } 866 | } 867 | }; 868 | 869 | if (mixinOptions.createAction) { 870 | serviceSchema.actions = { 871 | ...serviceSchema.actions, 872 | graphql: { 873 | params: { 874 | query: { type: "string" }, 875 | variables: { type: "object", optional: true } 876 | }, 877 | async handler(ctx) { 878 | await this.prepareGraphQLSchema(); 879 | const response = await this.apolloServer.executeOperation( 880 | { 881 | query: ctx.params.query, 882 | variables: ctx.params.variables 883 | }, 884 | { contextValue: { ctx } } 885 | ); 886 | 887 | if (response.body?.kind == "single") { 888 | return response.body?.singleResult; 889 | } 890 | 891 | // TODO: handle incremental response body as a Stream response 892 | 893 | return response; 894 | } 895 | } 896 | }; 897 | } 898 | 899 | return serviceSchema; 900 | }; 901 | -------------------------------------------------------------------------------- /test/integration/__snapshots__/index.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing 2 | 3 | exports[`Test Apollo Service Test schema merging should merge schemas 1`] = ` 4 | { 5 | "schema": "scalar Timestamp 6 | 7 | type Query { 8 | tags: [String!] 9 | posts: [Post!]! 10 | users: [User!]! 11 | } 12 | 13 | type Mutation { 14 | addTag(tag: String!): Boolean! 15 | vote(postID: Int!): Post! 16 | } 17 | 18 | """This type describes a post entity.""" 19 | type Post { 20 | id: Int! 21 | title: String! 22 | author: User! 23 | votes: Int! 24 | createdAt: Timestamp 25 | } 26 | 27 | type User { 28 | id: Int! 29 | name: String! 30 | posts: [Post] 31 | postCount: Int 32 | type: UserType 33 | } 34 | 35 | """Enumerations for user types""" 36 | enum UserType { 37 | ADMIN 38 | PUBLISHER 39 | READER 40 | }", 41 | } 42 | `; 43 | 44 | exports[`Test Apollo Service Test schema preparation should generate valid GraphQL schema 1`] = ` 45 | { 46 | "schema": "type Query { 47 | posts: [Post!]! 48 | users: [User!]! 49 | } 50 | 51 | """This type describes a post entity.""" 52 | type Post { 53 | id: Int! 54 | title: String! 55 | votes: Int! 56 | } 57 | 58 | type User { 59 | id: Int! 60 | name: String! 61 | }", 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /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 { createClient } = require("graphql-ws"); 8 | const ws = require("ws"); 9 | 10 | describe("Integration test for greeter service", () => { 11 | const broker = new ServiceBroker({ logger: false }); 12 | 13 | let GQL_URL; 14 | const apiSvc = broker.createService({ 15 | name: "api", 16 | 17 | mixins: [ 18 | // Gateway 19 | ApiGateway, 20 | 21 | // GraphQL Apollo Server 22 | ApolloService({ 23 | // API Gateway route options 24 | routeOptions: { 25 | path: "/graphql", 26 | cors: true, 27 | mappingPolicy: "restrict" 28 | }, 29 | 30 | checkActionVisibility: true, 31 | 32 | // https://www.apollographql.com/docs/apollo-server/api/apollo-server#options 33 | serverOptions: {} 34 | }) 35 | ], 36 | 37 | settings: { 38 | ip: "0.0.0.0", 39 | port: 0 // Random 40 | }, 41 | 42 | methods: { 43 | prepareContextParams(params, actionName) { 44 | if (actionName === "greeter.replace" && params.input) { 45 | return params.input; 46 | } 47 | return params; 48 | } 49 | } 50 | }); 51 | 52 | broker.createService({ 53 | name: "greeter", 54 | 55 | actions: { 56 | hello: { 57 | graphql: { 58 | query: "hello: String!" 59 | }, 60 | handler() { 61 | return "Hello Moleculer!"; 62 | } 63 | }, 64 | welcome: { 65 | graphql: { 66 | query: ` 67 | welcome(name: String!): String! 68 | ` 69 | }, 70 | handler(ctx) { 71 | return `Hello ${ctx.params.name}`; 72 | } 73 | }, 74 | update: { 75 | graphql: { 76 | mutation: "update(id: Int!): Boolean!" 77 | }, 78 | async handler(ctx) { 79 | await ctx.broadcast("graphql.publish", { 80 | tag: "UPDATED", 81 | payload: ctx.params.id 82 | }); 83 | 84 | return true; 85 | } 86 | }, 87 | updated: { 88 | graphql: { 89 | subscription: "updated: Int!", 90 | tags: ["UPDATED"], 91 | filter: "greeter.updatedFilter" 92 | }, 93 | handler(ctx) { 94 | return ctx.params.payload; 95 | } 96 | }, 97 | 98 | updatedFilter: { 99 | handler(ctx) { 100 | return ctx.params.payload % 2 === 0; 101 | } 102 | }, 103 | 104 | replace: { 105 | graphql: { 106 | input: `input GreeterInput { 107 | name: String! 108 | }`, 109 | type: `type GreeterOutput { 110 | name: String 111 | }`, 112 | mutation: "replace(input: GreeterInput!): GreeterOutput" 113 | }, 114 | handler(ctx) { 115 | return ctx.params; 116 | } 117 | }, 118 | 119 | danger: { 120 | graphql: { 121 | query: "danger: String!" 122 | }, 123 | async handler() { 124 | throw new MoleculerClientError( 125 | "I've said it's a danger action!", 126 | 422, 127 | "DANGER" 128 | ); 129 | } 130 | }, 131 | 132 | secret: { 133 | visibility: "protected", 134 | graphql: { 135 | query: "secret: String!" 136 | }, 137 | async handler() { 138 | return "! TOP SECRET !"; 139 | } 140 | } 141 | } 142 | }); 143 | 144 | beforeAll(async () => { 145 | await broker.start(); 146 | GQL_URL = `http://127.0.0.1:${apiSvc.server.address().port}/graphql`; 147 | }); 148 | afterAll(() => broker.stop()); 149 | 150 | it("should call the greeter.hello action", async () => { 151 | const res = await fetch(GQL_URL, { 152 | method: "post", 153 | body: JSON.stringify({ 154 | operationName: null, 155 | variables: {}, 156 | query: "{ hello }" 157 | }), 158 | headers: { "Content-Type": "application/json" } 159 | }); 160 | 161 | expect(res.status).toBe(200); 162 | expect(await res.json()).toEqual({ 163 | data: { 164 | hello: "Hello Moleculer!" 165 | } 166 | }); 167 | }); 168 | 169 | it("should call the greeter.welcome action with parameter", async () => { 170 | const res = await fetch(GQL_URL, { 171 | method: "post", 172 | body: JSON.stringify({ 173 | operationName: null, 174 | variables: {}, 175 | query: 'query { welcome(name: "GraphQL") }' 176 | }), 177 | headers: { "Content-Type": "application/json" } 178 | }); 179 | 180 | expect(res.status).toBe(200); 181 | expect(await res.json()).toEqual({ 182 | data: { 183 | welcome: "Hello GraphQL" 184 | } 185 | }); 186 | }); 187 | 188 | it("should call the greeter.welcome action with query variable", async () => { 189 | const res = await fetch(GQL_URL, { 190 | method: "post", 191 | body: JSON.stringify({ 192 | operationName: null, 193 | variables: { name: "Moleculer GraphQL" }, 194 | query: "query ($name: String!) { welcome(name: $name) }" 195 | }), 196 | headers: { "Content-Type": "application/json" } 197 | }); 198 | 199 | expect(res.status).toBe(200); 200 | expect(await res.json()).toEqual({ 201 | data: { 202 | welcome: "Hello Moleculer GraphQL" 203 | } 204 | }); 205 | }); 206 | 207 | it("should call the greeter.replace action with wrapped input params", async () => { 208 | const res = await fetch(GQL_URL, { 209 | method: "post", 210 | body: JSON.stringify({ 211 | operationName: null, 212 | variables: { name: "Moleculer GraphQL" }, 213 | query: "mutation ($name: String!) { replace(input: { name: $name }) { name } }" 214 | }), 215 | headers: { "Content-Type": "application/json" } 216 | }); 217 | 218 | expect(res.status).toBe(200); 219 | expect(await res.json()).toEqual({ 220 | data: { 221 | replace: { 222 | name: "Moleculer GraphQL" 223 | } 224 | } 225 | }); 226 | }); 227 | 228 | it("should call the greeter.danger and receives an error", async () => { 229 | const res = await fetch(GQL_URL, { 230 | method: "post", 231 | body: JSON.stringify({ 232 | operationName: null, 233 | variables: {}, 234 | query: "query { danger }" 235 | }), 236 | headers: { "Content-Type": "application/json" } 237 | }); 238 | 239 | expect(res.status).toBe(200); 240 | expect(await res.json()).toEqual({ 241 | data: null, 242 | errors: [ 243 | { 244 | extensions: { 245 | code: "INTERNAL_SERVER_ERROR" 246 | // exception: { 247 | // code: 422, 248 | // retryable: false, 249 | // type: "DANGER" 250 | // } 251 | }, 252 | locations: [ 253 | { 254 | column: 9, 255 | line: 1 256 | } 257 | ], 258 | message: "I've said it's a danger action!", 259 | path: ["danger"] 260 | } 261 | ] 262 | }); 263 | }); 264 | 265 | it("should not call the greeter.secret because it's protected", async () => { 266 | const res = await fetch(GQL_URL, { 267 | method: "post", 268 | body: JSON.stringify({ 269 | operationName: null, 270 | variables: {}, 271 | query: "query { secret }" 272 | }), 273 | headers: { "Content-Type": "application/json" } 274 | }); 275 | 276 | expect(res.status).toBe(400); 277 | expect(await res.json()).toEqual({ 278 | errors: [ 279 | { 280 | extensions: { 281 | code: "GRAPHQL_VALIDATION_FAILED" 282 | // exception: { 283 | // code: 422, 284 | // retryable: false, 285 | // type: "DANGER" 286 | // } 287 | }, 288 | locations: [ 289 | { 290 | column: 9, 291 | line: 1 292 | } 293 | ], 294 | message: 'Cannot query field "secret" on type "Query".' 295 | } 296 | ] 297 | }); 298 | }); 299 | 300 | it("should subscribe to the updated subscription", async () => { 301 | const client = createClient({ 302 | url: GQL_URL.replace("http", "ws"), 303 | webSocketImpl: ws 304 | }); 305 | const sub = client.iterate({ 306 | query: "subscription { updated }" 307 | }); 308 | 309 | // Wait for WS connection & subscription 310 | await new Promise(resolve => setTimeout(resolve, 1000)); 311 | 312 | const update = id => 313 | fetch(GQL_URL, { 314 | method: "post", 315 | body: JSON.stringify({ 316 | query: "mutation Update($id: Int!) { update(id: $id) }", 317 | variables: { id } 318 | }), 319 | headers: { "Content-Type": "application/json" } 320 | }); 321 | 322 | for (let i = 0; i < 5; i++) { 323 | await update(i + 1); 324 | } 325 | 326 | const FLOW = []; 327 | 328 | for await (const res of sub) { 329 | FLOW.push(res.data.updated); 330 | if (FLOW.length === 2) { 331 | break; 332 | } 333 | } 334 | 335 | expect(FLOW).toEqual([2, 4]); 336 | }); 337 | }); 338 | -------------------------------------------------------------------------------- /test/integration/index.spec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { Kind } = require("graphql"); 4 | const { ServiceBroker, Context, Errors } = require("moleculer"); 5 | const ApiGateway = require("moleculer-web"); 6 | const _ = require("lodash"); 7 | 8 | const { moleculerGql: gql } = require("../../index"); 9 | const ApolloServerService = require("../../src/service"); 10 | 11 | async function startService(mixinOptions, schemaMod) { 12 | const broker = new ServiceBroker({ logger: true, logLevel: "error" }); 13 | 14 | const svc = broker.createService( 15 | _.defaultsDeep({}, schemaMod, { 16 | name: "api", 17 | mixins: [ApiGateway, ApolloServerService(mixinOptions)], 18 | settings: { 19 | routes: [], 20 | ip: "0.0.0.0", 21 | port: 0 // Random 22 | } 23 | }) 24 | ); 25 | 26 | await broker.start(); 27 | 28 | const url = `http://127.0.0.1:${svc.server.address().port}/graphql`; 29 | 30 | return { broker, svc, url }; 31 | } 32 | 33 | function call(url, body) { 34 | return fetch(url, { 35 | method: "post", 36 | body: JSON.stringify(body), 37 | headers: { "Content-Type": "application/json" } 38 | }); 39 | } 40 | 41 | describe("Test Apollo Service", () => { 42 | describe("Test service options", () => { 43 | it("Test routeOptions", async () => { 44 | const { broker, url } = await startService( 45 | { 46 | routeOptions: { 47 | path: "/gql", 48 | aliases: { 49 | async "/custom"(req, res) { 50 | res.setHeader("Content-Type", "application/json"); 51 | res.end(JSON.stringify({ message: "Custom route" })); 52 | return; 53 | } 54 | } 55 | } 56 | }, 57 | { 58 | settings: { 59 | graphql: { 60 | query: ` 61 | test: String! 62 | `, 63 | resolvers: { 64 | Query: { 65 | test() { 66 | return "Test response"; 67 | } 68 | } 69 | } 70 | } 71 | } 72 | } 73 | ); 74 | 75 | const res = await call(url.replace(/graphql$/, "gql"), { 76 | query: "{ test }" 77 | }); 78 | 79 | expect(res.status).toBe(200); 80 | expect(await res.json()).toEqual({ 81 | data: { 82 | test: "Test response" 83 | } 84 | }); 85 | 86 | const res2 = await call(url.replace(/graphql$/, "gql") + "/custom", {}); 87 | 88 | expect(res2.status).toBe(200); 89 | expect(await res2.json()).toEqual({ 90 | message: "Custom route" 91 | }); 92 | 93 | await broker.stop(); 94 | }); 95 | 96 | it("Test serverOptions", async () => { 97 | const { broker, url } = await startService( 98 | { 99 | serverOptions: { 100 | formatError(formattedErr, error) { 101 | return { 102 | ...formattedErr, 103 | retryable: error.originalError?.retryable 104 | }; 105 | } 106 | } 107 | }, 108 | { 109 | settings: { 110 | graphql: { 111 | query: ` 112 | test: String! 113 | `, 114 | resolvers: { 115 | Query: { 116 | test() { 117 | throw new Errors.MoleculerRetryableError("Test error"); 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } 124 | ); 125 | 126 | const res = await call(url, { 127 | query: "{ test }" 128 | }); 129 | 130 | expect(res.status).toBe(200); 131 | expect(await res.json()).toEqual({ 132 | data: null, 133 | errors: [ 134 | { 135 | retryable: true, 136 | extensions: { 137 | code: "INTERNAL_SERVER_ERROR" 138 | }, 139 | locations: [{ column: 3, line: 1 }], 140 | message: "Test error", 141 | path: ["test"] 142 | } 143 | ] 144 | }); 145 | 146 | await broker.stop(); 147 | }); 148 | }); 149 | 150 | describe("Test schema preparation", () => { 151 | it("should generate valid GraphQL schema", async () => { 152 | const { broker, url } = await startService( 153 | { serverOptions: { subscriptions: false } }, 154 | { 155 | version: 2, 156 | settings: { 157 | graphql: { 158 | type: ` 159 | """ 160 | This type describes a post entity. 161 | """ 162 | type Post { 163 | id: Int! 164 | title: String! 165 | votes: Int! 166 | } 167 | ` 168 | } 169 | }, 170 | 171 | actions: { 172 | posts: { 173 | graphql: { 174 | query: "posts: [Post!]!" 175 | }, 176 | handler() { 177 | return [ 178 | { id: 1, title: "Post 1", votes: 10 }, 179 | { id: 2, title: "Post 2", votes: 20 } 180 | ]; 181 | } 182 | } 183 | } 184 | } 185 | ); 186 | 187 | const res = await call(url, { 188 | query: "{ posts { id title votes } }" 189 | }); 190 | 191 | expect(res.status).toBe(200); 192 | expect(await res.json()).toEqual({ 193 | data: { 194 | posts: [ 195 | { id: 1, title: "Post 1", votes: 10 }, 196 | { id: 2, title: "Post 2", votes: 20 } 197 | ] 198 | } 199 | }); 200 | 201 | jest.spyOn(broker, "broadcast"); 202 | 203 | await broker.createService({ 204 | name: "users", 205 | settings: { 206 | graphql: { 207 | type: ` 208 | type User { 209 | id: Int! 210 | name: String! 211 | } 212 | ` 213 | } 214 | }, 215 | 216 | actions: { 217 | users: { 218 | graphql: { 219 | query: "users: [User!]!" 220 | }, 221 | handler() { 222 | return [ 223 | { id: 1, name: "User 1" }, 224 | { id: 2, name: "User 2" } 225 | ]; 226 | } 227 | } 228 | } 229 | }); 230 | 231 | await broker.Promise.delay(1000); 232 | 233 | const res2 = await call(url, { 234 | query: "{ users { id name } }" 235 | }); 236 | 237 | expect(res2.status).toBe(200); 238 | expect(await res2.json()).toEqual({ 239 | data: { 240 | users: [ 241 | { id: 1, name: "User 1" }, 242 | { id: 2, name: "User 2" } 243 | ] 244 | } 245 | }); 246 | 247 | expect(broker.broadcast).toHaveBeenCalledTimes(2); 248 | expect(broker.broadcast).toHaveBeenCalledWith("graphql.schema.updated", { 249 | schema: expect.any(String) 250 | }); 251 | expect(broker.broadcast.mock.calls[1][1]).toMatchSnapshot(); 252 | 253 | await broker.stop(); 254 | }); 255 | }); 256 | 257 | describe("Test schema merging", () => { 258 | it("should merge schemas", async () => { 259 | const { broker, url } = await startService({ 260 | typeDefs: ["scalar Timestamp"], 261 | resolver: { 262 | Timestamp: { 263 | __parseValue(value) { 264 | return new Date(value); // value from the client 265 | }, 266 | __serialize(value) { 267 | return value.toISOString(); // value sent to the client 268 | }, 269 | __parseLiteral(ast) { 270 | if (ast.kind === Kind.INT) { 271 | return parseInt(ast.value, 10); // ast value is always in string format 272 | } 273 | 274 | return null; 275 | } 276 | } 277 | } 278 | }); 279 | 280 | jest.spyOn(broker, "broadcast"); 281 | jest.spyOn(broker, "call"); 282 | 283 | const TAGS = ["tag1", "tag2"]; 284 | 285 | const POSTS = [ 286 | { 287 | id: 1, 288 | title: "First post", 289 | author: 2, 290 | votes: 2, 291 | createdAt: new Date("2025-08-23T08:10:25Z") 292 | }, 293 | { 294 | id: 2, 295 | title: "Second post", 296 | author: 1, 297 | votes: 1, 298 | createdAt: new Date("2025-11-23T12:59:30Z") 299 | }, 300 | { 301 | id: 3, 302 | title: "Third post", 303 | author: 2, 304 | votes: 0, 305 | createdAt: new Date("2025-02-23T22:24:28Z") 306 | } 307 | ]; 308 | 309 | await broker.createService({ 310 | name: "posts", 311 | settings: { 312 | graphql: { 313 | type: ` 314 | """ 315 | This type describes a post entity. 316 | """ 317 | type Post { 318 | id: Int! 319 | title: String! 320 | author: User! 321 | votes: Int! 322 | createdAt: Timestamp 323 | } 324 | `, 325 | query: ` 326 | tags: [String!] 327 | `, 328 | mutation: ` 329 | addTag(tag: String!): Boolean! 330 | `, 331 | resolvers: { 332 | Post: { 333 | author: { 334 | action: "users.resolve", 335 | rootParams: { 336 | author: "id" 337 | } 338 | } 339 | }, 340 | Query: { 341 | tags() { 342 | return TAGS; 343 | } 344 | }, 345 | Mutation: { 346 | addTag(root, args) { 347 | TAGS.push(args.tag); 348 | return true; 349 | } 350 | } 351 | } 352 | } 353 | }, 354 | 355 | actions: { 356 | posts: { 357 | graphql: { 358 | query: "posts: [Post!]!" 359 | }, 360 | handler() { 361 | return POSTS; 362 | } 363 | }, 364 | 365 | vote: { 366 | graphql: { 367 | mutation: "vote(postID: Int!): Post!" 368 | }, 369 | handler(ctx) { 370 | const post = POSTS.find(p => p.id === ctx.params.postID); 371 | if (post) { 372 | post.votes++; 373 | return post; 374 | } 375 | throw new Error("Post not found"); 376 | } 377 | } 378 | } 379 | }); 380 | 381 | const USERS = [ 382 | { 383 | id: 1, 384 | name: "Genaro Krueger", 385 | type: "1" 386 | }, 387 | { 388 | id: 2, 389 | name: "Nicholas Paris", 390 | type: "2" 391 | }, 392 | { 393 | id: 3, 394 | name: "Quinton Loden", 395 | type: "3" 396 | } 397 | ]; 398 | 399 | await broker.createService({ 400 | name: "users", 401 | settings: { 402 | graphql: { 403 | type: gql` 404 | type User { 405 | id: Int! 406 | name: String! 407 | posts: [Post] 408 | postCount: Int 409 | type: UserType 410 | } 411 | `, 412 | enum: gql` 413 | """ 414 | Enumerations for user types 415 | """ 416 | enum UserType { 417 | ADMIN 418 | PUBLISHER 419 | READER 420 | } 421 | `, 422 | resolvers: { 423 | User: { 424 | posts: { 425 | action: "posts.findByUser", 426 | rootParams: { 427 | id: "userID" 428 | } 429 | }, 430 | postCount: { 431 | // Call the "posts.count" action 432 | action: "posts.count", 433 | // Get `id` value from `root` and put it into `ctx.params.query.author` 434 | rootParams: { 435 | id: "query.author" 436 | } 437 | } 438 | }, 439 | UserType: { 440 | ADMIN: "1", 441 | PUBLISHER: "2", 442 | READER: "3" 443 | } 444 | } 445 | } 446 | }, 447 | 448 | actions: { 449 | users: { 450 | graphql: { 451 | query: "users: [User!]!" 452 | }, 453 | handler() { 454 | return USERS; 455 | } 456 | }, 457 | 458 | resolve(ctx) { 459 | if (Array.isArray(ctx.params.id)) { 460 | return _.cloneDeep(ctx.params.id.map(id => this.findByID(id))); 461 | } else { 462 | return _.cloneDeep(this.findByID(ctx.params.id)); 463 | } 464 | } 465 | }, 466 | 467 | methods: { 468 | findByID(id) { 469 | return USERS.find(user => user.id == id); 470 | } 471 | } 472 | }); 473 | 474 | await broker.Promise.delay(1000); 475 | 476 | const res = await call(url, { 477 | query: "{ posts { id title author { name } createdAt } }" 478 | }); 479 | 480 | expect(res.status).toBe(200); 481 | expect(await res.json()).toEqual({ 482 | data: { 483 | posts: [ 484 | { 485 | id: 1, 486 | title: "First post", 487 | author: { name: "Nicholas Paris" }, 488 | createdAt: "2025-08-23T08:10:25.000Z" 489 | }, 490 | { 491 | id: 2, 492 | title: "Second post", 493 | author: { name: "Genaro Krueger" }, 494 | createdAt: "2025-11-23T12:59:30.000Z" 495 | }, 496 | { 497 | id: 3, 498 | title: "Third post", 499 | author: { name: "Nicholas Paris" }, 500 | createdAt: "2025-02-23T22:24:28.000Z" 501 | } 502 | ] 503 | } 504 | }); 505 | 506 | expect(broker.call).toHaveBeenCalledTimes(4); 507 | expect(broker.call).toHaveBeenNthCalledWith(1, "posts.posts", {}, expect.any(Object)); 508 | expect(broker.call).toHaveBeenNthCalledWith( 509 | 2, 510 | "users.resolve", 511 | { id: 2 }, 512 | expect.any(Object) 513 | ); 514 | expect(broker.call).toHaveBeenNthCalledWith( 515 | 3, 516 | "users.resolve", 517 | { id: 1 }, 518 | expect.any(Object) 519 | ); 520 | expect(broker.call).toHaveBeenNthCalledWith( 521 | 4, 522 | "users.resolve", 523 | { id: 2 }, 524 | expect.any(Object) 525 | ); 526 | 527 | // ------- 528 | 529 | const res2 = await call(url, { 530 | query: "mutation { vote(postID: 2) { id title votes author { name } } }" 531 | }); 532 | 533 | expect(res2.status).toBe(200); 534 | expect(await res2.json()).toEqual({ 535 | data: { 536 | vote: { 537 | id: 2, 538 | title: "Second post", 539 | votes: 2, 540 | author: { name: "Genaro Krueger" } 541 | } 542 | } 543 | }); 544 | 545 | // ------- 546 | 547 | const res3 = await call(url, { 548 | query: "{ tags }" 549 | }); 550 | 551 | expect(res3.status).toBe(200); 552 | expect(await res3.json()).toEqual({ data: { tags: ["tag1", "tag2"] } }); 553 | 554 | expect(broker.broadcast).toHaveBeenCalledTimes(2); 555 | expect(broker.broadcast).toHaveBeenCalledWith("graphql.schema.updated", { 556 | schema: expect.any(String) 557 | }); 558 | expect(broker.broadcast.mock.calls[1][1]).toMatchSnapshot(); 559 | 560 | const res4 = await call(url, { 561 | query: 'mutation { addTag(tag: "tag3") }' 562 | }); 563 | 564 | expect(res4.status).toBe(200); 565 | 566 | const res5 = await call(url, { 567 | query: "{ tags }" 568 | }); 569 | 570 | expect(res5.status).toBe(200); 571 | expect(await res5.json()).toEqual({ data: { tags: ["tag1", "tag2", "tag3"] } }); 572 | 573 | await broker.stop(); 574 | }); 575 | }); 576 | 577 | describe("Test resolvers", () => { 578 | const POSTS = [ 579 | { 580 | id: 1, 581 | title: "First post", 582 | author: 2, 583 | reviewer: 3, 584 | voters: [1, 3], 585 | likers: [1, 3] 586 | }, 587 | { 588 | id: 2, 589 | title: "Second post", 590 | author: 99, 591 | reviewer: null, 592 | voters: [2, 1, 3], 593 | likers: [2, 1, 3] 594 | }, 595 | { 596 | id: 3, 597 | title: "Third post", 598 | author: 1, 599 | voters: [], 600 | likers: [] 601 | } 602 | ]; 603 | 604 | const USERS = [ 605 | { id: 1, name: "Genaro Krueger" }, 606 | { id: 2, name: "Nicholas Paris" }, 607 | { id: 3, name: "Quinton Loden" } 608 | ]; 609 | 610 | it("should resolve fields", async () => { 611 | const { broker, url } = await startService(); 612 | 613 | jest.spyOn(broker, "broadcast"); 614 | jest.spyOn(broker, "call"); 615 | 616 | await broker.createService({ 617 | name: "posts", 618 | settings: { 619 | graphql: { 620 | type: ` 621 | """ 622 | This type describes a post entity. 623 | """ 624 | type Post { 625 | id: Int! 626 | title: String! 627 | author: User 628 | reviewer: User 629 | voters: [User] 630 | likers: [User] 631 | } 632 | 633 | """ 634 | This type describes a user entity. 635 | """ 636 | type User { 637 | id: Int! 638 | name: String! 639 | } 640 | `, 641 | resolvers: { 642 | Post: { 643 | author: { 644 | action: "posts.resolveUser", 645 | rootParams: { 646 | author: "id" 647 | }, 648 | nullIfError: true 649 | }, 650 | reviewer: { 651 | action: "posts.resolveUser", 652 | rootParams: { 653 | reviewer: "id" 654 | }, 655 | skipNullKeys: true, 656 | params: { 657 | a: 5 658 | } 659 | }, 660 | voters: { 661 | action: "posts.resolveUser", 662 | rootParams: { 663 | voters: "id" 664 | } 665 | }, 666 | likers: { 667 | action: "posts.resolveUser2", 668 | dataLoader: true, 669 | rootParams: { 670 | likers: "id" 671 | } 672 | } 673 | } 674 | } 675 | } 676 | }, 677 | 678 | actions: { 679 | posts: { 680 | graphql: { 681 | query: "posts: [Post!]!" 682 | }, 683 | handler() { 684 | return POSTS; 685 | } 686 | }, 687 | 688 | resolveUser: { 689 | params: { 690 | id: [{ type: "number" }, { type: "array", items: "number" }] 691 | }, 692 | handler(ctx) { 693 | if (Array.isArray(ctx.params.id)) { 694 | return _.cloneDeep(ctx.params.id.map(id => this.findByID(id))); 695 | } else { 696 | return _.cloneDeep(this.findByID(ctx.params.id)); 697 | } 698 | } 699 | }, 700 | 701 | resolveUser2: { 702 | params: { 703 | id: [{ type: "number" }, { type: "array", items: "number" }] 704 | }, 705 | handler(ctx) { 706 | if (Array.isArray(ctx.params.id)) { 707 | return _.cloneDeep(ctx.params.id.map(id => this.findByID(id))); 708 | } else { 709 | return _.cloneDeep(this.findByID(ctx.params.id)); 710 | } 711 | } 712 | } 713 | }, 714 | 715 | methods: { 716 | findByID(id) { 717 | const found = USERS.find(user => user.id == id); 718 | if (found) return found; 719 | 720 | throw new Error("User not found"); 721 | } 722 | } 723 | }); 724 | 725 | const res = await call(url, { 726 | query: `{ 727 | posts { 728 | id 729 | title 730 | author { name } 731 | reviewer { name } 732 | voters { name } 733 | likers { name } 734 | } 735 | }` 736 | }); 737 | 738 | expect(res.status).toBe(200); 739 | expect(await res.json()).toEqual({ 740 | data: { 741 | posts: [ 742 | { 743 | id: 1, 744 | title: "First post", 745 | author: { name: "Nicholas Paris" }, 746 | reviewer: { name: "Quinton Loden" }, 747 | voters: [{ name: "Genaro Krueger" }, { name: "Quinton Loden" }], 748 | likers: [{ name: "Genaro Krueger" }, { name: "Quinton Loden" }] 749 | }, 750 | { 751 | id: 2, 752 | title: "Second post", 753 | author: null, 754 | reviewer: null, 755 | voters: [ 756 | { name: "Nicholas Paris" }, 757 | { name: "Genaro Krueger" }, 758 | { name: "Quinton Loden" } 759 | ], 760 | likers: [ 761 | { name: "Nicholas Paris" }, 762 | { name: "Genaro Krueger" }, 763 | { name: "Quinton Loden" } 764 | ] 765 | }, 766 | { 767 | id: 3, 768 | title: "Third post", 769 | author: { name: "Genaro Krueger" }, 770 | reviewer: null, 771 | voters: [], 772 | likers: [] 773 | } 774 | ] 775 | } 776 | }); 777 | 778 | const calls = _.groupBy(broker.call.mock.calls, call => call[0]); 779 | expect(calls["posts.posts"]).toHaveLength(1); 780 | expect(calls["posts.resolveUser"]).toHaveLength(7); 781 | expect(calls["posts.resolveUser2"]).toHaveLength(1); 782 | 783 | expect(calls["posts.resolveUser"][1][1]).toEqual({ a: 5, id: 3 }); 784 | 785 | await broker.stop(); 786 | }); 787 | }); 788 | 789 | describe("Test GraphQL context", () => { 790 | it("should call custom context function", async () => { 791 | const { broker, url } = await startService( 792 | { 793 | serverOptions: { 794 | context(args) { 795 | return { 796 | user: { id: 1, name: "Test User" } 797 | }; 798 | } 799 | } 800 | }, 801 | { 802 | settings: { 803 | graphql: { 804 | query: ` 805 | currentUser: String! 806 | `, 807 | resolvers: { 808 | Query: { 809 | currentUser(root, args, context) { 810 | expect(context.user).toEqual({ 811 | id: 1, 812 | name: "Test User" 813 | }); 814 | expect(context.ctx).toBeInstanceOf(Context); 815 | expect(context.service.name).toBe("api"); 816 | expect(context.params).toEqual({ 817 | query: "query { currentUser }" 818 | }); 819 | return "OK"; 820 | } 821 | } 822 | } 823 | } 824 | } 825 | } 826 | ); 827 | 828 | const res = await call(url, { 829 | query: "query { currentUser }" 830 | }); 831 | 832 | expect(res.status).toBe(200); 833 | expect(await res.json()).toEqual({ 834 | data: { 835 | currentUser: "OK" 836 | } 837 | }); 838 | 839 | expect.assertions(6); 840 | }); 841 | }); 842 | 843 | describe("Test error handling", () => { 844 | it("should call the danger and receives an error", async () => { 845 | const { broker, url } = await startService(null, { 846 | actions: { 847 | danger: { 848 | graphql: { 849 | query: "danger: String!" 850 | }, 851 | handler(ctx) { 852 | throw new Errors.MoleculerClientError( 853 | "Danger action called", 854 | 400, 855 | "DANGER" 856 | ); 857 | } 858 | } 859 | } 860 | }); 861 | 862 | const res = await call(url, { 863 | query: "query { danger }" 864 | }); 865 | 866 | expect(res.status).toBe(200); 867 | expect(await res.json()).toEqual({ 868 | data: null, 869 | errors: [ 870 | { 871 | extensions: { 872 | code: "INTERNAL_SERVER_ERROR" 873 | // exception: { 874 | // code: 422, 875 | // retryable: false, 876 | // type: "DANGER" 877 | // } 878 | }, 879 | locations: [ 880 | { 881 | column: 9, 882 | line: 1 883 | } 884 | ], 885 | message: "Danger action called", 886 | path: ["danger"] 887 | } 888 | ] 889 | }); 890 | 891 | await broker.stop(); 892 | }); 893 | 894 | it("should call the echo with wrong parameter and receives an error", async () => { 895 | const { broker, url } = await startService(null, { 896 | actions: { 897 | echo: { 898 | graphql: { 899 | query: "echo(input: String!): String!" 900 | }, 901 | handler(ctx) { 902 | return ctx.params.input; 903 | } 904 | } 905 | } 906 | }); 907 | 908 | const res = await call(url, { 909 | query: "query { echo(input: 123) }" 910 | }); 911 | 912 | expect(res.status).toBe(400); 913 | expect(await res.json()).toEqual({ 914 | errors: [ 915 | { 916 | extensions: { 917 | code: "GRAPHQL_VALIDATION_FAILED" 918 | // exception: { 919 | // code: 422, 920 | // retryable: false, 921 | // type: "DANGER" 922 | // } 923 | }, 924 | locations: [ 925 | { 926 | column: 21, 927 | line: 1 928 | } 929 | ], 930 | message: "String cannot represent a non string value: 123" 931 | } 932 | ] 933 | }); 934 | 935 | await broker.stop(); 936 | }); 937 | 938 | it("should throw error if query is not found", async () => { 939 | const { broker, url } = await startService(null, { 940 | actions: { 941 | echo: { 942 | graphql: { 943 | query: "echo(input: String!): String!" 944 | }, 945 | handler(ctx) { 946 | return ctx.params.input; 947 | } 948 | } 949 | } 950 | }); 951 | 952 | const res = await call(url, { 953 | query: "query { notFound }" 954 | }); 955 | 956 | expect(res.status).toBe(400); 957 | expect(await res.json()).toEqual({ 958 | errors: [ 959 | { 960 | extensions: { code: "GRAPHQL_VALIDATION_FAILED" }, 961 | locations: [{ column: 9, line: 1 }], 962 | message: 'Cannot query field "notFound" on type "Query".' 963 | } 964 | ] 965 | }); 966 | 967 | await broker.stop(); 968 | }); 969 | }); 970 | 971 | describe("Test GraphQL action", () => { 972 | it("should create the 'graphql' action", async () => { 973 | const { broker } = await startService(null, { 974 | actions: { 975 | echo: { 976 | graphql: { 977 | query: "echo(input: String!): String!" 978 | }, 979 | handler(ctx) { 980 | return ctx.params.input; 981 | } 982 | }, 983 | danger: { 984 | graphql: { 985 | query: "danger(input: String!): String!" 986 | }, 987 | handler(ctx) { 988 | throw new Errors.MoleculerClientError( 989 | "Danger action called", 990 | 400, 991 | "DANGER" 992 | ); 993 | } 994 | } 995 | } 996 | }); 997 | 998 | const res = await broker.call("api.graphql", { 999 | query: "query echo($a: String!) { echo(input: $a) }", 1000 | variables: { a: "Moleculer" } 1001 | }); 1002 | expect(res).toEqual({ 1003 | data: { 1004 | echo: "Moleculer" 1005 | } 1006 | }); 1007 | 1008 | const res2 = await broker.call("api.graphql", { 1009 | query: "query danger($a: String!) { danger(input: $a) }", 1010 | variables: { a: "Moleculer" } 1011 | }); 1012 | 1013 | expect(res2).toEqual({ 1014 | data: null, 1015 | errors: [ 1016 | { 1017 | extensions: { code: "INTERNAL_SERVER_ERROR" }, 1018 | locations: [{ column: 29, line: 1 }], 1019 | message: "Danger action called", 1020 | path: ["danger"] 1021 | } 1022 | ] 1023 | }); 1024 | 1025 | const res3 = await broker.call("api.graphql", { 1026 | query: "query notFound($a: String!) { notFound(input: $a) }", 1027 | variables: { a: "Moleculer" } 1028 | }); 1029 | 1030 | expect(res3).toEqual({ 1031 | errors: [ 1032 | { 1033 | extensions: { code: "GRAPHQL_VALIDATION_FAILED" }, 1034 | locations: [{ column: 31, line: 1 }], 1035 | message: 'Cannot query field "notFound" on type "Query".' 1036 | } 1037 | ] 1038 | }); 1039 | 1040 | await broker.stop(); 1041 | }); 1042 | 1043 | it("should not create the 'graphql' action", async () => { 1044 | const { broker } = await startService({ createAction: false }); 1045 | 1046 | await expect(broker.call("api.graphql")).rejects.toThrow(Errors.ServiceNotFoundError); 1047 | 1048 | await broker.stop(); 1049 | }); 1050 | }); 1051 | 1052 | describe("Test schema interfaces & unions", () => { 1053 | it("should handle interface and union types", async () => { 1054 | const { broker, url } = await startService(); 1055 | 1056 | await broker.createService({ 1057 | name: "content", 1058 | settings: { 1059 | graphql: { 1060 | interface: ` 1061 | interface Node { 1062 | id: ID! 1063 | } 1064 | `, 1065 | union: ` 1066 | union SearchResult = Post | User 1067 | `, 1068 | type: ` 1069 | type Post implements Node { 1070 | id: ID! 1071 | title: String! 1072 | } 1073 | type User implements Node { 1074 | id: ID! 1075 | name: String! 1076 | } 1077 | ` 1078 | } 1079 | }, 1080 | actions: { 1081 | search: { 1082 | graphql: { 1083 | query: "search(term: String!): [SearchResult!]!" 1084 | }, 1085 | handler(ctx) { 1086 | if (ctx.params.term === "post") { 1087 | return [{ __typename: "Post", id: "1", title: "Test Post" }]; 1088 | } 1089 | return [{ __typename: "User", id: "1", name: "Test User" }]; 1090 | } 1091 | } 1092 | } 1093 | }); 1094 | 1095 | const res = await call(url, { 1096 | query: `{ 1097 | search(term: "post") { 1098 | ... on Post { 1099 | id 1100 | title 1101 | } 1102 | ... on User { 1103 | id 1104 | name 1105 | } 1106 | } 1107 | }` 1108 | }); 1109 | 1110 | expect(res.status).toBe(200); 1111 | expect(await res.json()).toEqual({ 1112 | data: { 1113 | search: [ 1114 | { 1115 | id: "1", 1116 | title: "Test Post" 1117 | } 1118 | ] 1119 | } 1120 | }); 1121 | 1122 | const res2 = await call(url, { 1123 | query: `{ 1124 | search(term: "user") { 1125 | ... on Post { 1126 | id 1127 | title 1128 | } 1129 | ... on User { 1130 | id 1131 | name 1132 | } 1133 | } 1134 | }` 1135 | }); 1136 | 1137 | expect(res2.status).toBe(200); 1138 | expect(await res2.json()).toEqual({ 1139 | data: { 1140 | search: [ 1141 | { 1142 | id: "1", 1143 | name: "Test User" 1144 | } 1145 | ] 1146 | } 1147 | }); 1148 | 1149 | await broker.stop(); 1150 | }); 1151 | }); 1152 | 1153 | describe("Test service lifecycle and error handling", () => { 1154 | it("should not publish private action", async () => { 1155 | const { broker, url } = await startService({ 1156 | checkActionVisibility: true 1157 | }); 1158 | 1159 | await broker.createService({ 1160 | name: "visibility-test", 1161 | actions: { 1162 | publishedAction: { 1163 | visibility: "published", 1164 | graphql: { 1165 | query: "publishedData: String!" 1166 | }, 1167 | handler() { 1168 | return "Published data"; 1169 | } 1170 | }, 1171 | publicAction: { 1172 | visibility: "public", 1173 | graphql: { 1174 | query: "publicData: String!" 1175 | }, 1176 | handler() { 1177 | return "Public data"; 1178 | } 1179 | }, 1180 | protectedAction: { 1181 | visibility: "protected", 1182 | graphql: { 1183 | query: "protectedData: String!" 1184 | }, 1185 | handler() { 1186 | return "Protected data"; 1187 | } 1188 | } 1189 | } 1190 | }); 1191 | 1192 | // Published action should be available 1193 | const res = await call(url, { 1194 | query: "{ publishedData }" 1195 | }); 1196 | expect(res.status).toBe(200); 1197 | 1198 | // Public action should not be in schema 1199 | const res2 = await call(url, { 1200 | query: "{ publicData }" 1201 | }); 1202 | expect(res2.status).toBe(400); // Should fail validation 1203 | 1204 | // Protected action should not be in schema 1205 | const res3 = await call(url, { 1206 | query: "{ protectedData }" 1207 | }); 1208 | expect(res3.status).toBe(400); // Should fail validation 1209 | 1210 | await broker.stop(); 1211 | }); 1212 | 1213 | it("should publish private actions", async () => { 1214 | const { broker, url } = await startService({ 1215 | checkActionVisibility: false 1216 | }); 1217 | 1218 | await broker.createService({ 1219 | name: "visibility-test", 1220 | actions: { 1221 | publishedAction: { 1222 | visibility: "published", 1223 | graphql: { 1224 | query: "publishedData: String!" 1225 | }, 1226 | handler() { 1227 | return "Published data"; 1228 | } 1229 | }, 1230 | publicAction: { 1231 | visibility: "public", 1232 | graphql: { 1233 | query: "publicData: String!" 1234 | }, 1235 | handler() { 1236 | return "Public data"; 1237 | } 1238 | }, 1239 | protectedAction: { 1240 | visibility: "protected", 1241 | graphql: { 1242 | query: "protectedData: String!" 1243 | }, 1244 | handler() { 1245 | return "Protected data"; 1246 | } 1247 | } 1248 | } 1249 | }); 1250 | 1251 | // Published action should be available 1252 | const res = await call(url, { 1253 | query: "{ publishedData }" 1254 | }); 1255 | expect(res.status).toBe(200); 1256 | 1257 | // Public action should not be in schema 1258 | const res2 = await call(url, { 1259 | query: "{ publicData }" 1260 | }); 1261 | expect(res2.status).toBe(200); 1262 | 1263 | // Protected action should not be in schema 1264 | const res3 = await call(url, { 1265 | query: "{ protectedData }" 1266 | }); 1267 | expect(res3.status).toBe(200); 1268 | 1269 | await broker.stop(); 1270 | }); 1271 | }); 1272 | 1273 | describe("Test DataLoader functionality", () => { 1274 | it("should batch requests with DataLoader", async () => { 1275 | const { broker, url } = await startService(); 1276 | 1277 | const USERS = [ 1278 | { id: 1, name: "User 1" }, 1279 | { id: 2, name: "User 2" }, 1280 | { id: 3, name: "User 3" } 1281 | ]; 1282 | 1283 | await broker.createService({ 1284 | name: "dataloader-test", 1285 | settings: { 1286 | graphql: { 1287 | type: ` 1288 | type Item { 1289 | id: Int! 1290 | name: String! 1291 | owner: User 1292 | } 1293 | type User { 1294 | id: Int! 1295 | name: String! 1296 | } 1297 | ` 1298 | } 1299 | }, 1300 | actions: { 1301 | items: { 1302 | graphql: { 1303 | query: "items: [Item!]!" 1304 | }, 1305 | handler() { 1306 | return [ 1307 | { id: 1, name: "Item 1", ownerId: 1 }, 1308 | { id: 2, name: "Item 2", ownerId: 2 }, 1309 | { id: 3, name: "Item 3", ownerId: 1 }, 1310 | { id: 4, name: "Item 4", ownerId: 3 } 1311 | ]; 1312 | } 1313 | }, 1314 | resolveUsers: { 1315 | params: { 1316 | id: [{ type: "number" }, { type: "array", items: "number" }] 1317 | }, 1318 | handler(ctx) { 1319 | const ids = Array.isArray(ctx.params.id) 1320 | ? ctx.params.id 1321 | : [ctx.params.id]; 1322 | return ids.map(id => USERS.find(u => u.id === id)); 1323 | } 1324 | } 1325 | } 1326 | }); 1327 | 1328 | // Update the service to add resolver 1329 | const svc = broker.getLocalService("dataloader-test"); 1330 | svc.settings.graphql.resolvers = { 1331 | Item: { 1332 | owner: { 1333 | action: "dataloader-test.resolveUsers", 1334 | dataLoader: true, 1335 | rootParams: { 1336 | ownerId: "id" 1337 | } 1338 | } 1339 | } 1340 | }; 1341 | 1342 | // Force schema regeneration 1343 | await broker.emit("$services.changed"); 1344 | await broker.Promise.delay(100); 1345 | 1346 | const res = await call(url, { 1347 | query: `{ 1348 | items { 1349 | id 1350 | name 1351 | owner { 1352 | id 1353 | name 1354 | } 1355 | } 1356 | }` 1357 | }); 1358 | 1359 | expect(res.status).toBe(200); 1360 | const result = await res.json(); 1361 | expect(result.data.items).toHaveLength(4); 1362 | expect(result.data.items[0].owner.name).toBe("User 1"); 1363 | expect(result.data.items[2].owner.name).toBe("User 1"); 1364 | 1365 | await broker.stop(); 1366 | }); 1367 | 1368 | it("should handle DataLoader with complex keys", async () => { 1369 | const { broker, url } = await startService(); 1370 | 1371 | await broker.createService({ 1372 | name: "complex-dataloader", 1373 | settings: { 1374 | graphql: { 1375 | type: ` 1376 | type Product { 1377 | id: Int! 1378 | name: String! 1379 | price: Price 1380 | } 1381 | type Price { 1382 | amount: Float! 1383 | currency: String! 1384 | } 1385 | ` 1386 | } 1387 | }, 1388 | actions: { 1389 | products: { 1390 | graphql: { 1391 | query: "products: [Product!]!" 1392 | }, 1393 | handler() { 1394 | return [ 1395 | { id: 1, name: "Product 1", priceId: 1, currency: "USD" }, 1396 | { id: 2, name: "Product 2", priceId: 2, currency: "EUR" } 1397 | ]; 1398 | } 1399 | }, 1400 | resolvePrices: { 1401 | graphql: { 1402 | dataLoaderBatchParam: "query", 1403 | dataLoaderOptions: { 1404 | cacheKeyFn: key => JSON.stringify(key) 1405 | } 1406 | }, 1407 | handler(ctx) { 1408 | return ctx.params.query.map(q => ({ 1409 | amount: q.priceId * 10, 1410 | currency: q.currency 1411 | })); 1412 | } 1413 | } 1414 | } 1415 | }); 1416 | 1417 | // Update resolver 1418 | const svc = broker.getLocalService("complex-dataloader"); 1419 | svc.settings.graphql.resolvers = { 1420 | Product: { 1421 | price: { 1422 | action: "complex-dataloader.resolvePrices", 1423 | dataLoader: true, 1424 | rootParams: { 1425 | priceId: "priceId", 1426 | currency: "currency" 1427 | } 1428 | } 1429 | } 1430 | }; 1431 | 1432 | await broker.emit("$services.changed"); 1433 | await broker.Promise.delay(100); 1434 | 1435 | const res = await call(url, { 1436 | query: `{ 1437 | products { 1438 | id 1439 | name 1440 | price { 1441 | amount 1442 | currency 1443 | } 1444 | } 1445 | }` 1446 | }); 1447 | 1448 | expect(res.status).toBe(200); 1449 | const result = await res.json(); 1450 | expect(result.data.products[0].price).toEqual({ amount: 10, currency: "USD" }); 1451 | expect(result.data.products[1].price).toEqual({ amount: 20, currency: "EUR" }); 1452 | 1453 | await broker.stop(); 1454 | }); 1455 | }); 1456 | 1457 | describe("Test schema directives and custom resolvers", () => { 1458 | it("should handle custom scalar types", async () => { 1459 | const { broker, url } = await startService({ 1460 | typeDefs: ["scalar JSON"], 1461 | resolvers: { 1462 | JSON: { 1463 | __parseValue(value) { 1464 | return value; // value from the client 1465 | }, 1466 | __serialize(value) { 1467 | return value; // value sent to the client 1468 | }, 1469 | __parseLiteral(ast) { 1470 | if (ast.kind === "ObjectValue") { 1471 | return parseObject(ast); 1472 | } 1473 | return null; 1474 | } 1475 | } 1476 | } 1477 | }); 1478 | 1479 | function parseObject(ast) { 1480 | const obj = {}; 1481 | ast.fields.forEach(field => { 1482 | obj[field.name.value] = parseValue(field.value); 1483 | }); 1484 | return obj; 1485 | } 1486 | 1487 | function parseValue(ast) { 1488 | switch (ast.kind) { 1489 | case "StringValue": 1490 | return ast.value; 1491 | case "IntValue": 1492 | return parseInt(ast.value, 10); 1493 | case "FloatValue": 1494 | return parseFloat(ast.value); 1495 | case "BooleanValue": 1496 | return ast.value; 1497 | case "ObjectValue": 1498 | return parseObject(ast); 1499 | default: 1500 | return null; 1501 | } 1502 | } 1503 | 1504 | await broker.createService({ 1505 | name: "json-test", 1506 | settings: { 1507 | graphql: { 1508 | type: ` 1509 | type Config { 1510 | id: ID! 1511 | data: JSON! 1512 | } 1513 | ` 1514 | } 1515 | }, 1516 | actions: { 1517 | getConfig: { 1518 | graphql: { 1519 | query: "config: Config!" 1520 | }, 1521 | handler() { 1522 | return { 1523 | id: "1", 1524 | data: { 1525 | theme: "dark", 1526 | features: ["feature1", "feature2"], 1527 | settings: { 1528 | notifications: true, 1529 | language: "en" 1530 | } 1531 | } 1532 | }; 1533 | } 1534 | } 1535 | } 1536 | }); 1537 | 1538 | const res = await call(url, { 1539 | query: `{ 1540 | config { 1541 | id 1542 | data 1543 | } 1544 | }` 1545 | }); 1546 | 1547 | expect(res.status).toBe(200); 1548 | const result = await res.json(); 1549 | expect(result.data.config.data).toEqual({ 1550 | theme: "dark", 1551 | features: ["feature1", "feature2"], 1552 | settings: { 1553 | notifications: true, 1554 | language: "en" 1555 | } 1556 | }); 1557 | 1558 | await broker.stop(); 1559 | }); 1560 | }); 1561 | 1562 | describe("Test Input type & prepareContextParams", () => { 1563 | it("should handle input types", async () => { 1564 | const { broker, url } = await startService(); 1565 | 1566 | await broker.createService({ 1567 | name: "input-test", 1568 | settings: { 1569 | graphql: { 1570 | type: ` 1571 | input CreateUserInput { 1572 | name: String! 1573 | email: String! 1574 | age: Int 1575 | } 1576 | 1577 | type User { 1578 | id: ID! 1579 | name: String! 1580 | email: String! 1581 | age: Int 1582 | } 1583 | `, 1584 | query: ` 1585 | dummy: String 1586 | `, 1587 | resolvers: { 1588 | Query: { 1589 | dummy: () => "dummy" 1590 | } 1591 | } 1592 | } 1593 | }, 1594 | actions: { 1595 | createUser: { 1596 | graphql: { 1597 | mutation: "createUser(input: CreateUserInput!): User!" 1598 | }, 1599 | handler(ctx) { 1600 | return { 1601 | id: "123", 1602 | ...ctx.params.input 1603 | }; 1604 | } 1605 | } 1606 | } 1607 | }); 1608 | 1609 | await broker.Promise.delay(100); 1610 | 1611 | const res = await call(url, { 1612 | query: `mutation { 1613 | createUser(input: { name: "John", email: "john@example.com", age: 30 }) { 1614 | id 1615 | name 1616 | email 1617 | age 1618 | } 1619 | }` 1620 | }); 1621 | 1622 | expect(res.status).toBe(200); 1623 | const result = await res.json(); 1624 | expect(result.data.createUser).toEqual({ 1625 | id: "123", 1626 | name: "John", 1627 | email: "john@example.com", 1628 | age: 30 1629 | }); 1630 | 1631 | await broker.stop(); 1632 | }); 1633 | 1634 | it("should unwrap input with prepareContextParams", async () => { 1635 | const { broker, url } = await startService(null, { 1636 | methods: { 1637 | prepareContextParams(params, actionName) { 1638 | if (params.input) { 1639 | return params.input; 1640 | } 1641 | return params; 1642 | } 1643 | } 1644 | }); 1645 | 1646 | await broker.createService({ 1647 | name: "input-test", 1648 | settings: { 1649 | graphql: { 1650 | type: ` 1651 | input CreateUserInput { 1652 | name: String! 1653 | email: String! 1654 | age: Int 1655 | } 1656 | 1657 | type User { 1658 | id: ID! 1659 | name: String! 1660 | email: String! 1661 | age: Int 1662 | } 1663 | `, 1664 | query: ` 1665 | dummy: String 1666 | `, 1667 | resolvers: { 1668 | Query: { 1669 | dummy: () => "dummy" 1670 | } 1671 | } 1672 | } 1673 | }, 1674 | actions: { 1675 | createUser: { 1676 | graphql: { 1677 | mutation: "createUser(input: CreateUserInput!): User!" 1678 | }, 1679 | handler(ctx) { 1680 | return { 1681 | id: "123", 1682 | ...ctx.params 1683 | }; 1684 | } 1685 | } 1686 | } 1687 | }); 1688 | 1689 | await broker.Promise.delay(100); 1690 | 1691 | const res = await call(url, { 1692 | query: `mutation { 1693 | createUser(input: { name: "John", email: "john@example.com", age: 30 }) { 1694 | id 1695 | name 1696 | email 1697 | age 1698 | } 1699 | }` 1700 | }); 1701 | 1702 | expect(res.status).toBe(200); 1703 | const result = await res.json(); 1704 | expect(result.data.createUser).toEqual({ 1705 | id: "123", 1706 | name: "John", 1707 | email: "john@example.com", 1708 | age: 30 1709 | }); 1710 | 1711 | await broker.stop(); 1712 | }); 1713 | }); 1714 | 1715 | describe("Test error handling edge cases", () => { 1716 | it("should handle resolver errors with nullIfError option", async () => { 1717 | const { broker, url } = await startService(); 1718 | 1719 | await broker.createService({ 1720 | name: "error-test", 1721 | settings: { 1722 | graphql: { 1723 | type: ` 1724 | type Data { 1725 | id: ID! 1726 | safe: String 1727 | unsafe: String 1728 | } 1729 | `, 1730 | resolvers: { 1731 | Data: { 1732 | safe: { 1733 | action: "error-test.throwError", 1734 | nullIfError: true 1735 | }, 1736 | unsafe: { 1737 | action: "error-test.throwError" 1738 | } 1739 | } 1740 | } 1741 | } 1742 | }, 1743 | actions: { 1744 | getData: { 1745 | graphql: { 1746 | query: "data: Data!" 1747 | }, 1748 | handler() { 1749 | return { id: "1" }; 1750 | } 1751 | }, 1752 | throwError: { 1753 | handler() { 1754 | throw new Error("Test error"); 1755 | } 1756 | } 1757 | } 1758 | }); 1759 | 1760 | const res = await call(url, { 1761 | query: `{ 1762 | data { 1763 | id 1764 | safe 1765 | } 1766 | }` 1767 | }); 1768 | 1769 | expect(res.status).toBe(200); 1770 | const result = await res.json(); 1771 | expect(result.data.data.id).toBe("1"); 1772 | expect(result.data.data.safe).toBeNull(); 1773 | 1774 | const res2 = await call(url, { 1775 | query: `{ 1776 | data { 1777 | id 1778 | unsafe 1779 | } 1780 | }` 1781 | }); 1782 | 1783 | expect(res2.status).toBe(200); 1784 | const result2 = await res2.json(); 1785 | expect(result2.data.data.id).toBe("1"); 1786 | expect(result2.data.data.unsafe).toBeNull(); 1787 | expect(result2.errors).toBeDefined(); 1788 | expect(result2.errors[0].message).toBe("Test error"); 1789 | 1790 | await broker.stop(); 1791 | }); 1792 | 1793 | it("should handle skipNullKeys option", async () => { 1794 | const { broker, url } = await startService(); 1795 | 1796 | await broker.createService({ 1797 | name: "skip-null-test", 1798 | settings: { 1799 | graphql: { 1800 | type: ` 1801 | type Item { 1802 | id: ID! 1803 | details: Details 1804 | } 1805 | type Details { 1806 | info: String 1807 | } 1808 | `, 1809 | resolvers: { 1810 | Item: { 1811 | details: { 1812 | action: "skip-null-test.getDetails", 1813 | skipNullKeys: true, 1814 | rootParams: { 1815 | detailsId: "id" 1816 | } 1817 | } 1818 | } 1819 | } 1820 | } 1821 | }, 1822 | actions: { 1823 | items: { 1824 | graphql: { 1825 | query: "items: [Item!]!" 1826 | }, 1827 | handler() { 1828 | return [ 1829 | { id: "1", detailsId: "d1" }, 1830 | { id: "2", detailsId: null }, 1831 | { id: "3" } 1832 | ]; 1833 | } 1834 | }, 1835 | getDetails: { 1836 | handler(ctx) { 1837 | return { info: `Details for ${ctx.params.id}` }; 1838 | } 1839 | } 1840 | } 1841 | }); 1842 | 1843 | const res = await call(url, { 1844 | query: `{ 1845 | items { 1846 | id 1847 | details { 1848 | info 1849 | } 1850 | } 1851 | }` 1852 | }); 1853 | 1854 | expect(res.status).toBe(200); 1855 | const result = await res.json(); 1856 | expect(result.data.items[0].details).toEqual({ info: "Details for d1" }); 1857 | expect(result.data.items[1].details).toBeNull(); 1858 | expect(result.data.items[2].details).toBeNull(); 1859 | 1860 | await broker.stop(); 1861 | }); 1862 | }); 1863 | 1864 | describe("Test autoUpdateSchema disabled", () => { 1865 | it("should not update schema when autoUpdateSchema is false", async () => { 1866 | const { broker } = await startService({ 1867 | autoUpdateSchema: false 1868 | }); 1869 | 1870 | const svc = broker.getLocalService("api"); 1871 | svc.shouldUpdateGraphqlSchema = false; 1872 | 1873 | await broker.emit("$services.changed"); 1874 | await broker.Promise.delay(100); 1875 | 1876 | expect(svc.shouldUpdateGraphqlSchema).toBe(false); 1877 | 1878 | await broker.stop(); 1879 | }); 1880 | 1881 | it("should invalidate schema on demand", async () => { 1882 | const { broker } = await startService({ 1883 | autoUpdateSchema: false 1884 | }); 1885 | 1886 | const svc = broker.getLocalService("api"); 1887 | svc.shouldUpdateGraphqlSchema = false; 1888 | 1889 | await broker.emit("graphql.invalidate"); 1890 | 1891 | expect(svc.shouldUpdateGraphqlSchema).toBe(true); 1892 | 1893 | await broker.stop(); 1894 | }); 1895 | }); 1896 | 1897 | describe("Test race-condition in prepareGraphQLSchema", () => { 1898 | it("should not prepare GQL twice", async () => { 1899 | const { broker, url } = await startService(null, { 1900 | actions: { 1901 | echo: { 1902 | graphql: { 1903 | query: "echo(input: String!): String!" 1904 | }, 1905 | handler(ctx) { 1906 | return ctx.params.input; 1907 | } 1908 | } 1909 | } 1910 | }); 1911 | 1912 | const svc = broker.getLocalService("api"); 1913 | 1914 | // Store original method 1915 | const originalGenerateGraphQLSchema = svc.generateGraphQLSchema.bind(svc); 1916 | 1917 | // Mock with 2 second delay 1918 | jest.spyOn(svc, "generateGraphQLSchema").mockImplementation(async function (...args) { 1919 | await new Promise(resolve => setTimeout(resolve, 2000)); 1920 | return originalGenerateGraphQLSchema(...args); 1921 | }); 1922 | 1923 | const query = { 1924 | query: 'query { echo(input: "Hello") }' 1925 | }; 1926 | 1927 | const res = await call(url, query); 1928 | 1929 | expect(res.status).toBe(200); 1930 | expect(await res.json()).toEqual({ data: { echo: "Hello" } }); 1931 | expect(svc.generateGraphQLSchema).toHaveBeenCalledTimes(1); 1932 | 1933 | // Should prepare 1934 | svc.shouldUpdateGraphqlSchema = true; 1935 | svc.generateGraphQLSchema.mockClear(); 1936 | 1937 | const p1 = call(url, query); 1938 | const p2 = call(url, query); 1939 | const p3 = call(url, query); 1940 | 1941 | const results = await Promise.all([p1, p2, p3]); 1942 | 1943 | for (const r of results) { 1944 | expect(r.status).toBe(200); 1945 | expect(await r.json()).toEqual({ data: { echo: "Hello" } }); 1946 | } 1947 | 1948 | expect(svc.generateGraphQLSchema).toHaveBeenCalledTimes(1); 1949 | 1950 | await broker.stop(); 1951 | }); 1952 | }); 1953 | }); 1954 | -------------------------------------------------------------------------------- /test/typescript/index.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { ExecutionResult } from "graphql"; 4 | import type { GraphQLRequest } from "@apollo/server"; 5 | import { ServiceBroker, Context, ServiceSchema, Errors } from "moleculer"; 6 | import ApiGateway from "moleculer-web"; 7 | import { ApolloService } from "../../"; 8 | import type { 9 | ApolloServiceSettings, 10 | ApolloServiceMethods, 11 | ApolloServiceLocalVars 12 | } from "../../"; 13 | 14 | const broker = new ServiceBroker({ 15 | logLevel: "info", 16 | tracing: { 17 | enabled: true, 18 | exporter: { 19 | type: "Console" 20 | } 21 | } 22 | }); 23 | 24 | const ApiService: ServiceSchema< 25 | ApolloServiceSettings, 26 | ApolloServiceMethods, 27 | ApolloServiceLocalVars 28 | > = { 29 | name: "api", 30 | 31 | mixins: [ 32 | // Gateway 33 | ApiGateway, 34 | 35 | // GraphQL Apollo Server 36 | ApolloService({ 37 | // API Gateway route options 38 | routeOptions: { 39 | path: "/graphql", 40 | cors: true, 41 | mappingPolicy: "restrict" 42 | }, 43 | 44 | checkActionVisibility: true, 45 | 46 | // https://www.apollographql.com/docs/apollo-server/api/apollo-server#options 47 | serverOptions: {} 48 | }) 49 | ], 50 | 51 | events: { 52 | "graphql.schema.updated"(ctx: Context<{ schema: string }>) { 53 | this.logger.info("Generated GraphQL schema:\n\n" + ctx.params.schema); 54 | } 55 | } 56 | }; 57 | 58 | const GreeterService: ServiceSchema = { 59 | name: "greeter", 60 | 61 | actions: { 62 | hello: { 63 | graphql: { 64 | query: "hello: String!" 65 | }, 66 | handler() { 67 | return "Hello Moleculer!"; 68 | } 69 | }, 70 | welcome: { 71 | graphql: { 72 | mutation: ` 73 | welcome( 74 | name: String! 75 | ): String! 76 | ` 77 | }, 78 | handler(ctx: Context<{ name: string }>) { 79 | return `Hello ${ctx.params.name}`; 80 | } 81 | }, 82 | 83 | update: { 84 | graphql: { 85 | mutation: "update(id: Int!): Boolean!" 86 | }, 87 | async handler(ctx: Context<{ id: number }>) { 88 | await ctx.broadcast("graphql.publish", { tag: "UPDATED", payload: ctx.params.id }); 89 | 90 | return true; 91 | } 92 | }, 93 | 94 | updated: { 95 | graphql: { 96 | subscription: "updated: Int!", 97 | tags: ["UPDATED"], 98 | filter: "greeter.updatedFilter" 99 | }, 100 | handler(ctx: Context<{ payload: number }>) { 101 | return ctx.params.payload; 102 | } 103 | }, 104 | 105 | updatedFilter: { 106 | handler(ctx: Context<{ payload: number }>) { 107 | return ctx.params.payload % 2 === 0; 108 | } 109 | }, 110 | 111 | danger: { 112 | graphql: { 113 | query: "danger: String!" 114 | }, 115 | async handler() { 116 | throw new Errors.MoleculerClientError( 117 | "I've said it's a danger action!", 118 | 422, 119 | "DANGER" 120 | ); 121 | } 122 | } 123 | } 124 | }; 125 | 126 | broker.createService(ApiService); 127 | broker.createService(GreeterService); 128 | 129 | async function start() { 130 | await broker.start(); 131 | 132 | const res = await broker.call, GraphQLRequest>("api.graphql", { 133 | query: "query { hello }" 134 | }); 135 | 136 | broker.logger.info(res.data); 137 | if (res.data?.hello != "Hello Moleculer!") { 138 | throw new Error("Invalid hello response"); 139 | } 140 | 141 | await broker.stop(); 142 | } 143 | 144 | start(); 145 | -------------------------------------------------------------------------------- /test/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "lib": ["es2020"], 6 | "strict": true, 7 | "noEmit": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "moduleResolution": "node", 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "exactOptionalPropertyTypes": true, 14 | "noImplicitAny": true, 15 | "noImplicitThis": true, 16 | "noImplicitReturns": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "noUncheckedIndexedAccess": true, 19 | "strictNullChecks": true, 20 | "strictFunctionTypes": true, 21 | "strictBindCallApply": true, 22 | "strictPropertyInitialization": true, 23 | "useUnknownInCatchVariables": true, 24 | "alwaysStrict": true, 25 | "baseUrl": "../../", 26 | "paths": { 27 | "moleculer-apollo-server": ["./index.d.ts"], 28 | "moleculer-apollo-server/*": ["./*"] 29 | }, 30 | "types": ["node"] 31 | }, 32 | "include": [ 33 | "*.ts", 34 | "**/*.ts" 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /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 MolApolloService = require("../../"); 4 | 5 | describe("Test ApolloService exports", () => { 6 | it("should export GraphQL classes", () => { 7 | expect(MolApolloService.GraphQLError).toBeDefined(); 8 | }); 9 | 10 | it("should export Moleculer modules", () => { 11 | expect(MolApolloService.ApolloServer).toBeDefined(); 12 | expect(MolApolloService.ApolloService).toBeDefined(); 13 | expect(MolApolloService.moleculerGql).toBeDefined(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "lib": ["es2020"], 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "strict": false, 9 | "noEmit": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "moduleResolution": "node", 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "skipDefaultLibCheck": true, 16 | "types": [] 17 | }, 18 | "include": [ 19 | "index.d.ts" 20 | ], 21 | "exclude": [ 22 | "node_modules", 23 | "dist", 24 | "test", 25 | "examples" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------