├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── nodejs.yml ├── .gitignore ├── .markdownlint.json ├── .npmignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE.md ├── README.md ├── docs └── diagrams │ ├── ast-transformation.drawio.svg │ └── overview.drawio.svg ├── examples ├── simple │ ├── index.ts │ ├── models │ │ ├── article.ts │ │ └── author.ts │ ├── package.json │ ├── schema │ │ ├── index.ts │ │ ├── mutation │ │ │ ├── addArticle.ts │ │ │ ├── addAuthor.ts │ │ │ ├── removeArticle.ts │ │ │ └── removeAuthor.ts │ │ └── query │ │ │ ├── article.ts │ │ │ ├── articleList.ts │ │ │ ├── author.ts │ │ │ └── authorList.ts │ └── yarn.lock ├── simpleNamespaces │ ├── index.ts │ ├── models │ │ ├── article.ts │ │ └── author.ts │ ├── package.json │ ├── schema │ │ ├── index.ts │ │ ├── mutation │ │ │ ├── articles │ │ │ │ ├── add.ts │ │ │ │ └── remove.ts │ │ │ └── authors │ │ │ │ ├── add.ts │ │ │ │ └── remove.ts │ │ └── query │ │ │ ├── articles │ │ │ ├── byId.ts │ │ │ └── list.ts │ │ │ └── authors │ │ │ ├── byId.ts │ │ │ └── list.ts │ └── yarn.lock └── testSchema │ ├── index.ts │ ├── package.json │ └── yarn.lock ├── jest.config.js ├── package.json ├── src ├── VisitInfo.ts ├── __tests__ │ ├── VisitInfo-test.ts │ ├── __fixtures__ │ │ └── merge │ │ │ ├── schema1 │ │ │ ├── mutation │ │ │ │ └── createTask.ts │ │ │ └── query │ │ │ │ ├── me.ts │ │ │ │ └── tasks │ │ │ │ ├── byId.ts │ │ │ │ └── list.ts │ │ │ └── schema2 │ │ │ ├── query │ │ │ ├── me.ts │ │ │ └── tasks │ │ │ │ ├── byId.ts │ │ │ │ └── byIds.ts │ │ │ └── subscription │ │ │ └── events.ts │ ├── __snapshots__ │ │ ├── astToSchema-test.ts.snap │ │ └── directoryToAst-test.ts.snap │ ├── __testSchema__ │ │ ├── index.ts │ │ ├── mutation.auth │ │ │ ├── index.ts │ │ │ ├── login.ts │ │ │ ├── logout.ts │ │ │ └── nested │ │ │ │ └── method.ts │ │ ├── mutation.user │ │ │ ├── create.ts │ │ │ └── update.ts │ │ ├── mutation │ │ │ └── logs.nested │ │ │ │ └── list.ts │ │ ├── query.auth │ │ │ ├── index.ts │ │ │ ├── isLoggedIn.ts │ │ │ └── nested │ │ │ │ └── method.ts │ │ └── query │ │ │ ├── .eslintrc.js │ │ │ ├── __skip │ │ │ └── field.ts │ │ │ ├── field.spec.ts │ │ │ ├── field.ts │ │ │ ├── index.ts │ │ │ ├── me │ │ │ ├── address.city.ts │ │ │ ├── address.street.ts │ │ │ ├── index.ts │ │ │ └── name.ts │ │ │ ├── skip.d.ts │ │ │ ├── skip.js.map │ │ │ ├── skip.ts.map │ │ │ ├── some.index.ts │ │ │ ├── some.nested.ts │ │ │ └── user │ │ │ ├── extendedData.ts │ │ │ ├── index.ts │ │ │ └── roles.ts │ ├── astMerge-test.ts │ ├── astToSchema-test.ts │ ├── astVisitor-test.ts │ └── directoryToAst-test.ts ├── astMerge.ts ├── astToSchema.ts ├── astVisitor.ts ├── directoryToAst.ts ├── index.ts ├── testHelpers.ts └── typeDefs.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_size = 2 9 | indent_style = space 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | 13 | # Set default charset 14 | [*.{js,ts}] 15 | charset = utf-8 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | flow-typed/* 2 | lib/* 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | parser: '@typescript-eslint/parser', 5 | plugins: ['@typescript-eslint', 'prettier'], 6 | extends: ['plugin:@typescript-eslint/recommended', 'prettier', 'plugin:prettier/recommended'], 7 | parserOptions: { 8 | sourceType: 'module', 9 | useJSXTextNode: true, 10 | project: [path.resolve(__dirname, 'tsconfig.json')], 11 | }, 12 | rules: { 13 | 'no-underscore-dangle': 0, 14 | 'arrow-body-style': 0, 15 | 'no-unused-expressions': 0, 16 | 'no-plusplus': 0, 17 | 'no-console': 0, 18 | 'func-names': 0, 19 | 'comma-dangle': [ 20 | 'error', 21 | { 22 | arrays: 'always-multiline', 23 | objects: 'always-multiline', 24 | imports: 'always-multiline', 25 | exports: 'always-multiline', 26 | functions: 'ignore', 27 | }, 28 | ], 29 | 'no-prototype-builtins': 0, 30 | 'prefer-destructuring': 0, 31 | 'no-else-return': 0, 32 | 'lines-between-class-members': ['error', 'always', { exceptAfterSingleLine: true }], 33 | '@typescript-eslint/explicit-member-accessibility': 0, 34 | '@typescript-eslint/no-explicit-any': 0, 35 | '@typescript-eslint/no-inferrable-types': 0, 36 | '@typescript-eslint/explicit-function-return-type': 0, 37 | '@typescript-eslint/no-use-before-define': 0, 38 | '@typescript-eslint/no-empty-function': 0, 39 | '@typescript-eslint/camelcase': 0, 40 | '@typescript-eslint/ban-ts-comment': 0, 41 | }, 42 | env: { 43 | jasmine: true, 44 | jest: true, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [nodkz] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: graphql-compose 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | tests: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [14, 16] 14 | fail-fast: false 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - name: Install node_modules 22 | run: yarn 23 | - name: Test – Jest 24 | run: yarn coverage 25 | env: 26 | CI: true 27 | - name: Test – Eslint 28 | if: ${{ always() && matrix.node-version == '14' }} 29 | run: yarn eslint 30 | - name: Test – TSCheck 31 | if: ${{ always() && matrix.node-version == '14' }} 32 | run: yarn tscheck 33 | - name: Publish Test Report 34 | if: ${{ always() && matrix.node-version == '14' }} 35 | uses: mikepenz/action-junit-report@v2 36 | with: 37 | check_name: JUnit Annotations for Node ${{ matrix.node-version }} 38 | report_paths: '**/coverage/junit/**/*.xml' 39 | - name: Send codecov.io stats 40 | if: matrix.node-version == '14' 41 | run: bash <(curl -s https://codecov.io/bash) || echo '' 42 | 43 | publish: 44 | if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/alpha' || github.ref == 'refs/heads/beta' 45 | needs: [tests] 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v2 49 | - name: Use Node.js 14 50 | uses: actions/setup-node@v2 51 | with: 52 | node-version: 14 53 | - name: Install node_modules 54 | run: yarn install 55 | - name: Build 56 | run: yarn build 57 | - name: Semantic Release (publish to npm) 58 | run: yarn semantic-release 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 62 | 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | tmp 14 | 15 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 16 | .grunt 17 | 18 | # node-waf configuration 19 | .lock-wscript 20 | 21 | # Compiled binary addons (http://nodejs.org/api/addons.html) 22 | build/Release 23 | 24 | # IntelliJ Files 25 | *.iml 26 | *.ipr 27 | *.iws 28 | /out/ 29 | .idea/ 30 | .idea_modules/ 31 | 32 | # Dependency directory 33 | node_modules 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | # Transpiled code 42 | lib 43 | packages/mongodb-memory-server-core/lib 44 | *.tsbuildinfo 45 | 46 | coverage 47 | .nyc_output 48 | package-lock.json 49 | 50 | dist -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false, 3 | "no-trailing-punctuation": { 4 | "punctuation": ",;" 5 | }, 6 | "no-inline-html": false, 7 | "ol-prefix": false, 8 | "first-line-h1": false, 9 | "first-heading-h1": false 10 | } 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | src 4 | flow-typed 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "arrowParens": "always", 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "printWidth": 100, 7 | "trailingComma": "es5" 8 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible 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": "Jest", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/node_modules/.bin/jest", 12 | "args": ["--runInBand", "--watch"], 13 | "cwd": "${workspaceFolder}", 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen", 16 | "disableOptimisticBPs": true 17 | }, 18 | { 19 | "name": "Jest Current File", 20 | "type": "node", 21 | "request": "launch", 22 | "program": "${workspaceFolder}/node_modules/.bin/jest", 23 | "args": [ 24 | "${fileBasenameNoExtension}", 25 | "--config", 26 | "jest.config.js" 27 | ], 28 | "console": "integratedTerminal", 29 | "internalConsoleOptions": "neverOpen", 30 | "runtimeArgs": ["--nolazy"], // tells v8 to compile your code ahead of time, so that breakpoints work correctly 31 | "disableOptimisticBPs": true // also helps that breakpoints work correctly 32 | }, 33 | { 34 | "name": "ts-node Current File", 35 | "type": "node", 36 | "request": "launch", 37 | "program": "${workspaceFolder}/node_modules/.bin/ts-node", 38 | "args": [ 39 | "${file}", 40 | ], 41 | "console": "integratedTerminal", 42 | "internalConsoleOptions": "neverOpen", 43 | "runtimeArgs": ["--nolazy"], // tells v8 to compile your code ahead of time, so that breakpoints work correctly 44 | "disableOptimisticBPs": true // also helps that breakpoints work correctly 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["javascript"], 3 | "javascript.validate.enable": false, 4 | "javascript.autoClosingTags": false, 5 | "typescript.tsdk": "node_modules/typescript/lib", 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.eslint": true 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-present Pavel Chertorogov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-compose-modules 2 | 3 | [![npm](https://img.shields.io/npm/v/graphql-compose-modules.svg)](https://www.npmjs.com/package/graphql-compose-modules) 4 | [![Codecov coverage](https://img.shields.io/codecov/c/github/graphql-compose/graphql-compose-modules.svg)](https://codecov.io/github/graphql-compose/graphql-compose-modules) 5 | [![Github Actions](https://github.com/graphql-compose/graphql-compose-modules/workflows/Build%20CI/badge.svg)](https://github.com/graphql-compose/graphql-compose-modules/actions) 6 | [![Trends](https://img.shields.io/npm/dt/graphql-compose-modules.svg)](http://www.npmtrends.com/graphql-compose-modules) 7 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 8 | ![TypeScript compatible](https://img.shields.io/badge/typescript-compatible-brightgreen.svg) 9 | [![Backers on Open Collective](https://opencollective.com/graphql-compose/backers/badge.svg)](#backers) 10 | [![Sponsors on Open Collective](https://opencollective.com/graphql-compose/sponsors/badge.svg)](#sponsors) 11 | 12 | This is a toolkit for creating big GraphQL schemas with code-first approach in JavaScript. 13 | 14 | ## Quick demo 15 | 16 | You may find a simple GraphQL server example in the following folder: [examples/simple](./examples/simple). 17 | 18 | ## GraphQL schema entrypoints from a file structure 19 | 20 | When you are using code-first approach in GraphQL Schema construction you may face problem when you cannot understand which entrypoints your schema has. And where exactly the code is placed which serve this or that entrypoint. 21 | 22 | ![overview](./docs/diagrams/overview.drawio.svg) 23 | 24 | `graphql-compose-modules` uses a file-system based schema entrypoint definition (something like NextJS does with its pages concept for routing). You just create folder `schema/` and put inside it the following sub-folders (root directories): `query`, `mutation` and `subscription`. Inside these folders you may put `.js` or `.ts` files with FieldConfigs which describes entrypoints. Assume you create the following directory structure: 25 | 26 | ```bash 27 | schema/ 28 | query/ 29 | articleById.ts 30 | articlesList.ts 31 | mutation/ 32 | createArticle.ts 33 | updateArticle.ts 34 | removeArticle.ts 35 | subscription/ 36 | onArticleChange.ts 37 | ``` 38 | 39 | With this directory structure `graphql-compose-modules` will use file names as field names for your root types and you get the following GraphQL schema: 40 | 41 | ```graphql 42 | type Query { 43 | articleById: ... 44 | articlesList: ... 45 | } 46 | 47 | type Mutation { 48 | createArticle: ... 49 | updateArticle: ... 50 | removeArticle: ... 51 | } 52 | 53 | type Subscription { 54 | onArticleChange: ... 55 | } 56 | ``` 57 | 58 | If you want rename field `articlesList` to `articles` in your schema just rename `articlesList.ts` file. If you want to add a new field to Schema – just add a new file to `Query`, `Mutation`, `Subscription` folders. **This simple approach helps you understand entrypoints of your schema without launching the GraphQL server – what you see in folders that you get in GraphQL Schema**. 59 | 60 | ## Describing Entrypoints in files 61 | 62 | Every Entrypoint (FieldConfig definition) is described in a separate file. This file contains information about input args, output type, resolve function and additional fields like description, deprecationReason, extensions. As an example let's create `schema/Query/sum.ts` and put inside the following content: 63 | 64 | ```ts 65 | export default { 66 | type: 'Int!', 67 | args: { 68 | a: 'Int!', 69 | b: 'Int!', 70 | }, 71 | resolve: (source, args, context, info) => { 72 | return args.a + args.b; 73 | }, 74 | description: 'This method sums two numbers', 75 | deprecationReason: 'This method is deprecated and will be removed soon.', 76 | extensions: { 77 | someExtraParam: 'Can be used for AST transformers', 78 | }, 79 | }; 80 | ``` 81 | 82 | If you are familiar with [graphql-js FieldConfig definition](https://graphql.org/graphql-js/type/#examples) then you may notice that `type` & `args` properties are defined in SDL format. This syntax sugar is provided by [graphql-compose](https://github.com/graphql-compose/graphql-compose#examples) package. 83 | 84 | ## Entrypoints with namespaces for big schemas 85 | 86 | If your GraphQL Schema has a lot of entrypoints you may create sub-folders for grouping them under Namespaces: 87 | 88 | ```bash 89 | schema/ 90 | query/ 91 | articles/ 92 | byId.ts 93 | list.ts 94 | ... 95 | mutation/ 96 | articles/ 97 | create.ts 98 | update.ts 99 | remove.ts 100 | ... 101 | ``` 102 | 103 | With such structure you will get the following schema – namespace types `QueryArticles` & `MutationArticles` are created automatically: 104 | 105 | ```graphql 106 | type Query { 107 | articles: QueryArticles 108 | } 109 | 110 | type Mutation { 111 | articles: MutationArticles 112 | } 113 | 114 | type QueryArticles { 115 | byId: ... 116 | list: ... 117 | } 118 | 119 | type MutationArticles { 120 | create: ... 121 | update: ... 122 | remove: ... 123 | } 124 | ``` 125 | 126 | You may use namespaces (sub-folders) for `Query` & `Mutation` and all servers supports this feature. But for `Subscription` most current server implementations (eg. [apollo-server](https://www.apollographql.com/docs/apollo-server/data/subscriptions/)) does not support this yet. 127 | 128 | ## GraphQLSchema construction 129 | 130 | In `schema` folder create a file `index.ts` with the following content which traverses `query`, `mutation`, `subscription` folders and creates a `GraphQLSchema` instance for you: 131 | 132 | ```ts 133 | import { buildSchema } from 'graphql-compose-modules'; 134 | 135 | export const schema = buildSchema(module); 136 | ``` 137 | 138 | After that you may create a GraphQL server: 139 | 140 | ```ts 141 | import { ApolloServer } from 'apollo-server'; 142 | import { schema } from './schema'; 143 | 144 | const server = new ApolloServer({ schema }); 145 | 146 | server.listen().then(({ url }) => { 147 | console.log(`🚀 Server ready at ${url}`); 148 | }); 149 | ``` 150 | 151 | ## Advanced GraphQLSchema construction 152 | 153 | If you want transform AST of entrypoints (e.g. for adding authorization, logging, tracing) and for merging with another schemas distributed via npm packages – you may use the following advanced way for schema construction: 154 | 155 | ```ts 156 | import { directoryToAst, astToSchema, astMerge } from 'graphql-compose-modules'; 157 | import { addQueryToMutations } from './transformers/addQueryToMutations'; 158 | import { remoteServiceAST } from '@internal/some-service'; 159 | 160 | // traverse `query`, `mutation`, `subscription` folders placed near this module 161 | let ast = directoryToAst(module); 162 | 163 | // apply transformer which uses astVisitor() method under the hood 164 | addQueryToMutations(ast); 165 | 166 | // merge with other ASTs distributed via npm packages 167 | ast = astMerge(ast, remoteServiceAST); 168 | 169 | // construct SchemaComposer 170 | const sc = astToSchema(ast); 171 | 172 | // construct GraphQLSchema instance and export it 173 | export const schema = sc.buildSchema(); 174 | ``` 175 | 176 | ## Writing own transformer for entrypoints 177 | 178 | For writing your own transformers you need to use `astVisitor()` method. For instance let's implement `addQueryToMutations` transformer which adds `query: Query` field to all your mutations: 179 | 180 | ```ts 181 | import { astVisitor, VISITOR_SKIP_CHILDREN, AstRootNode } from 'graphql-compose-modules'; 182 | import { ObjectTypeComposer, SchemaComposer } from 'graphql-compose'; 183 | 184 | export function addQueryToMutations( 185 | ast: AstRootNode, 186 | schemaComposer: SchemaComposer 187 | ): void { 188 | astVisitor(ast, schemaComposer, { 189 | // skip `query` & `subscriptions` root types 190 | ROOT_TYPE: (info) => { 191 | if (!info.isMutation) { 192 | return VISITOR_SKIP_CHILDREN; 193 | } 194 | return; 195 | }, 196 | // for every file in `mutation` folder try to add `query` field if it does not exists 197 | FILE: (info) => { 198 | // get FieldConfig from loaded file in `schema` folder 199 | const fieldConfig = info.fieldConfig || {}; 200 | 201 | // if `resolve` method does not exist then skip this transformation 202 | const next = fieldConfig.resolve; 203 | if (!next) return; 204 | 205 | // if output type isn't an object then skip this transformation 206 | if (!info.isOutputTypeIsObject()) return; 207 | 208 | const outputTC = info.getOutputUnwrappedOTC(); 209 | if (outputTC.hasField('query')) return; 210 | outputTC.setField('query', { 211 | description: 'Sub-query which have to be executed after mutation.', 212 | type: schemaComposer.Query, 213 | }); 214 | 215 | fieldConfig.resolve = async (s: any, args: any, context: any, i: any) => { 216 | const result = await next(s, args, context, i); 217 | return { 218 | query: {}, 219 | ...result, 220 | }; 221 | }; 222 | }, 223 | }); 224 | } 225 | ``` 226 | 227 | ## API 228 | 229 | ### Main API method: 230 | 231 | - `buildSchema(module: NodeModule, opts: BuildOptions): GraphQLSchema` – use this method for creating graphql schema from directory 232 | 233 | ### Advanced API methods: 234 | 235 | The following methods help to use schema composition, applying middlewares and schema transformation via visitor pattern: 236 | 237 | ![overview](./docs/diagrams/ast-transformation.drawio.svg) 238 | 239 | - `directoryToAst(module: NodeModule, options: DirectoryToAstOptions): AstRootNode` – traverses directories and construct AST for your graphql entrypoints 240 | - `astToSchema(ast: AstRootNode, opts: AstToSchemaOptions): SchemaComposer` – converts AST to GraphQL Schema 241 | - `astMerge(...asts: Array): AstRootNode` – combines several ASTs to one AST (helps compose several graphql schemas which may be distributed via npm packages) 242 | - `astVisitor(ast: AstRootNode, schemaComposer: SchemaComposer, visitor: AstVisitor): void` – modify AST via visitor pattern. This method is used for construction of your AST transformers. 243 | -------------------------------------------------------------------------------- /docs/diagrams/ast-transformation.drawio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 | AST 14 |
15 |
16 |
17 |
18 | 19 | AST 20 | 21 |
22 |
23 | 24 | 25 | 26 | 27 |
28 |
29 |
30 | GraphQL 31 |
32 | Schema 33 |
34 |
35 |
36 |
37 | 38 | GraphQL... 39 | 40 |
41 |
42 | 43 | 44 | 45 | 46 | 47 |
48 |
49 |
50 | directoryToAst() 51 |
52 |
53 |
54 |
55 | 56 | directoryToAst() 57 | 58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 |
66 |
67 |
68 | astToSchema() 69 |
70 |
71 |
72 |
73 | 74 | astToSchema() 75 | 76 |
77 |
78 | 79 | 80 | 81 | 82 |
83 |
84 |
85 | transformer1 86 |
87 |
88 |
89 |
90 | 91 | transformer1 92 | 93 |
94 |
95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 |
103 |
104 | transformerN 105 |
106 |
107 |
108 |
109 | 110 | transformerN 111 | 112 |
113 |
114 | 115 | 116 | 117 | 118 |
119 |
120 |
121 | AST'' 122 |
123 |
124 |
125 |
126 | 127 | AST'' 128 | 129 |
130 |
131 | 132 | 133 | 134 | 135 |
136 |
137 |
138 | Ideas for transformers: 139 |
140 | - logging 141 |
142 | - tracing 143 |
144 | - authorization 145 |
146 | - role-base entrypoint filtration 147 |
148 | - type modification 149 |
150 | - error logging 151 |
152 | - etc. 153 |
154 |
155 |
156 |
157 | 158 | Ideas for transformers:... 159 | 160 |
161 |
162 | 163 | 164 | 165 | 166 |
167 |
168 |
169 | AST' 170 |
171 |
172 |
173 |
174 | 175 | AST' 176 | 177 |
178 |
179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 |
189 |
190 |
191 | astMerge() 192 |
193 |
194 |
195 |
196 | 197 | astMerge() 198 | 199 |
200 |
201 | 202 | 203 | 204 |
205 |
206 |
207 | astVisitor() 208 |
209 |
210 |
211 |
212 | 213 | astVisitor() 214 | 215 |
216 |
217 | 218 | 219 | 220 |
221 |
222 |
223 | astVisitor() 224 |
225 |
226 |
227 |
228 | 229 | astVisitor() 230 | 231 |
232 |
233 | 234 | 235 | 236 | 237 |
238 |
239 |
240 | ASTs 241 |
242 | distributed via npm packages 243 |
244 |
245 |
246 |
247 | 248 | ASTs... 249 | 250 |
251 |
252 | 253 | 254 | 255 | 256 | 257 | 258 |
259 |
260 |
261 |
262 | 263 | 264 | file based entrypoint 265 |
266 | definitions 267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 | 275 | file based en... 276 | 277 |
278 |
279 |
280 | 281 | 282 | 283 | 284 | Viewer does not support full SVG 1.1 285 | 286 | 287 | 288 |
-------------------------------------------------------------------------------- /docs/diagrams/overview.drawio.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 | query 15 |
16 |
17 |
18 |
19 | 20 | query 21 | 22 |
23 |
24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | mutation 32 |
33 |
34 |
35 |
36 | 37 | mutation 38 | 39 |
40 |
41 | 42 | 43 | 44 | 45 |
46 |
47 |
48 | subscription 49 |
50 |
51 |
52 |
53 | 54 | subscription 55 | 56 |
57 |
58 | 59 | 60 | 61 | 62 |
63 |
64 |
65 | 66 | graphql-compose-modules 67 | 68 |
69 | is used for defining resolvers which return entities 70 |
71 |
72 |
73 |
74 | 75 | graphql-compose-modules... 76 | 77 |
78 |
79 | 80 | 81 | 82 | 83 |
84 |
85 |
86 | 87 | graphql-compose 88 | 89 |
90 | is used for type definition 91 |
92 | and creating relations 93 |
94 |
95 |
96 |
97 | 98 | graphql-compose... 99 | 100 |
101 |
102 | 103 | 104 | 105 | 106 |
107 |
108 |
109 | 110 | graphql-js 111 | 112 |
113 | is used for schema construction and 114 |
115 | query execution 116 |
117 |
118 |
119 |
120 | 121 | graphql-js... 122 | 123 |
124 |
125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 |
199 |
200 |
201 | 202 | ENTRYPOINTS 203 | 204 |
205 |
206 |
207 |
208 | 209 | ENTRYPOINTS 210 | 211 |
212 |
213 | 214 | 215 | 216 | 217 |
218 |
219 |
220 | 221 | ENTITIES 222 | 223 |
224 |
225 |
226 |
227 | 228 | ENTITIES 229 | 230 |
231 |
232 | 233 | 234 | 235 | 236 |
237 |
238 |
239 | 240 | ROOT TYPES 241 | 242 |
243 |
244 |
245 |
246 | 247 | ROOT TYPES 248 | 249 |
250 |
251 | 252 | 253 | 254 | 255 |
256 | 257 | 258 | 259 | 260 | Viewer does not support full SVG 1.1 261 | 262 | 263 | 264 |
-------------------------------------------------------------------------------- /examples/simple/index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server'; 2 | import { schema } from './schema'; 3 | 4 | const server = new ApolloServer({ schema }); 5 | 6 | server.listen().then(({ url }) => { 7 | console.log(`🚀 Server ready at ${url}`); 8 | }); 9 | -------------------------------------------------------------------------------- /examples/simple/models/article.ts: -------------------------------------------------------------------------------- 1 | import { schemaComposer } from 'graphql-compose'; 2 | import { AuthorTC, getAuthor } from './author'; 3 | import _ from 'lodash'; 4 | 5 | export let articles = [ 6 | { id: 1, title: 'Article 1', text: 'Text 1', authorId: 1 }, 7 | { id: 2, title: 'Article 2', text: 'Text 2', authorId: 1 }, 8 | { id: 3, title: 'Article 3', text: 'Text 3', authorId: 2 }, 9 | { id: 4, title: 'Article 4', text: 'Text 4', authorId: 3 }, 10 | { id: 5, title: 'Article 5', text: 'Text 5', authorId: 1 }, 11 | ]; 12 | 13 | export const ArticleTC = schemaComposer.createObjectTC({ 14 | name: 'Article', 15 | fields: { 16 | id: 'Int!', 17 | title: 'String', 18 | text: 'String', 19 | authorId: 'Int', 20 | author: { 21 | type: () => AuthorTC, 22 | resolve: (source) => getAuthor(source.authorId), 23 | }, 24 | }, 25 | }); 26 | 27 | export function getArticle(id: number) { 28 | return articles.find((r) => r.id === id); 29 | } 30 | 31 | export function getArticles(opts: { 32 | page?: number; 33 | perPage?: number; 34 | filter?: Partial; 35 | }) { 36 | const { page = 1, perPage = 10, filter } = opts; 37 | return _.filter(articles, filter).slice((page - 1) * perPage, page * perPage); 38 | } 39 | 40 | export function addArticle(data: Partial): typeof articles[0] { 41 | if (!data.id) data.id = articles.reduce((c, { id: p }) => (c > p ? c : p), 0); 42 | articles.push(data as any); 43 | return data as any; 44 | } 45 | 46 | export function removeArticle(id: number) { 47 | articles = articles.filter((d) => d.id !== id); 48 | } 49 | -------------------------------------------------------------------------------- /examples/simple/models/author.ts: -------------------------------------------------------------------------------- 1 | import { schemaComposer } from 'graphql-compose'; 2 | import { ArticleTC, getArticles } from './article'; 3 | import _ from 'lodash'; 4 | 5 | export let authors = [ 6 | { id: 1, name: 'User 1' }, 7 | { id: 2, name: 'User 2' }, 8 | { id: 3, name: 'User 3' }, 9 | ]; 10 | 11 | export const AuthorTC = schemaComposer.createObjectTC(` 12 | type Author { 13 | id: Int 14 | name: String 15 | } 16 | `); 17 | 18 | AuthorTC.addFields({ 19 | articles: { 20 | type: () => [ArticleTC], 21 | args: { page: 'Int', perPage: 'Int' }, 22 | resolve: (source, args) => 23 | getArticles({ 24 | filter: { authorId: source.id }, 25 | page: args.page, 26 | perPage: args.perPage, 27 | }), 28 | }, 29 | }); 30 | 31 | export function getAuthor(id: number) { 32 | return authors.find((r) => r.id === id); 33 | } 34 | 35 | export function getAuthors(opts: { 36 | page?: number; 37 | perPage?: number; 38 | filter?: Partial; 39 | }) { 40 | const { page = 1, perPage = 10, filter } = opts; 41 | return _.filter(authors, filter).slice((page - 1) * perPage, page * perPage); 42 | } 43 | 44 | export function addAuthor(data: Partial): typeof authors[0] { 45 | if (!data.id) data.id = authors.reduce((c, { id: p }) => (c > p ? c : p), 0); 46 | authors.push(data as any); 47 | return data as any; 48 | } 49 | 50 | export function removeAuthor(id: number) { 51 | authors = authors.filter((d) => d.id !== id); 52 | } 53 | -------------------------------------------------------------------------------- /examples/simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-server", 3 | "main": "dist/index.js", 4 | "license": "UNLICENSED", 5 | "scripts": { 6 | "watch": "ts-node-dev --no-notify --watch ./schema ./index.ts" 7 | }, 8 | "dependencies": { 9 | "graphql-compose-modules": "link:../../src" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/simple/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'graphql-compose-modules'; 2 | 3 | export const schema = buildSchema(module); 4 | -------------------------------------------------------------------------------- /examples/simple/schema/mutation/addArticle.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { ArticleTC, addArticle } from '../../models/article'; 3 | 4 | export default { 5 | type: ArticleTC, 6 | args: { 7 | title: 'String!', 8 | text: 'String', 9 | authorId: 'Int', 10 | }, 11 | resolve: (_, args) => addArticle(args), 12 | } as FieldConfig; 13 | -------------------------------------------------------------------------------- /examples/simple/schema/mutation/addAuthor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { AuthorTC, addAuthor } from '../../models/author'; 3 | 4 | export default { 5 | type: AuthorTC, 6 | args: { 7 | name: 'String!', 8 | }, 9 | resolve: (_, args) => addAuthor(args), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simple/schema/mutation/removeArticle.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { ArticleTC, removeArticle } from '../../models/article'; 3 | 4 | export default { 5 | type: ArticleTC, 6 | args: { 7 | id: 'Int!', 8 | }, 9 | resolve: (_, args) => removeArticle(args.id), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simple/schema/mutation/removeAuthor.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { AuthorTC, removeAuthor } from '../../models/author'; 3 | 4 | export default { 5 | type: AuthorTC, 6 | args: { 7 | id: 'Int!', 8 | }, 9 | resolve: (_, args) => removeAuthor(args.id), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simple/schema/query/article.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { ArticleTC, getArticle } from '../../models/article'; 3 | 4 | export default { 5 | type: ArticleTC, 6 | args: { 7 | id: 'Int!', 8 | }, 9 | resolve: (_, args) => getArticle(args.id), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simple/schema/query/articleList.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { getArticles, ArticleTC } from '../../models/article'; 3 | 4 | export default { 5 | type: [ArticleTC], 6 | args: { 7 | page: 'Int', 8 | perPage: { type: 'Int', defaultValue: 3 }, 9 | }, 10 | resolve: (_, args) => getArticles({ page: args.page, perPage: args.perPage }), 11 | } as FieldConfig; 12 | -------------------------------------------------------------------------------- /examples/simple/schema/query/author.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { AuthorTC, getAuthor } from '../../models/author'; 3 | 4 | export default { 5 | type: AuthorTC, 6 | args: { 7 | id: 'Int!', 8 | }, 9 | resolve: (_, args) => getAuthor(args.id), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simple/schema/query/authorList.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { getAuthors, AuthorTC } from '../../models/author'; 3 | 4 | export default { 5 | type: [AuthorTC], 6 | args: { 7 | page: 'Int', 8 | perPage: { type: 'Int', defaultValue: 3 }, 9 | }, 10 | resolve: (_, args) => getAuthors({ page: args.page, perPage: args.perPage }), 11 | } as FieldConfig; 12 | -------------------------------------------------------------------------------- /examples/simple/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "graphql-compose-modules@link:../../src": 6 | version "0.0.0" 7 | uid "" 8 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server'; 2 | import { schema } from './schema'; 3 | 4 | const server = new ApolloServer({ schema }); 5 | 6 | server.listen().then(({ url }) => { 7 | console.log(`🚀 Server ready at ${url}`); 8 | }); 9 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/models/article.ts: -------------------------------------------------------------------------------- 1 | import { schemaComposer } from 'graphql-compose'; 2 | import { AuthorTC, getAuthor } from './author'; 3 | import _ from 'lodash'; 4 | 5 | export let articles = [ 6 | { id: 1, title: 'Article 1', text: 'Text 1', authorId: 1 }, 7 | { id: 2, title: 'Article 2', text: 'Text 2', authorId: 1 }, 8 | { id: 3, title: 'Article 3', text: 'Text 3', authorId: 2 }, 9 | { id: 4, title: 'Article 4', text: 'Text 4', authorId: 3 }, 10 | { id: 5, title: 'Article 5', text: 'Text 5', authorId: 1 }, 11 | ]; 12 | 13 | export const ArticleTC = schemaComposer.createObjectTC({ 14 | name: 'Article', 15 | fields: { 16 | id: 'Int!', 17 | title: 'String', 18 | text: 'String', 19 | authorId: 'Int', 20 | author: { 21 | type: () => AuthorTC, 22 | resolve: (source) => getAuthor(source.authorId), 23 | }, 24 | }, 25 | }); 26 | 27 | export function getArticle(id: number) { 28 | return articles.find((r) => r.id === id); 29 | } 30 | 31 | export function getArticles(opts: { 32 | page?: number; 33 | perPage?: number; 34 | filter?: Partial; 35 | }) { 36 | const { page = 1, perPage = 10, filter } = opts; 37 | return _.filter(articles, filter).slice((page - 1) * perPage, page * perPage); 38 | } 39 | 40 | export function addArticle(data: Partial): typeof articles[0] { 41 | if (!data.id) data.id = articles.reduce((c, { id: p }) => (c > p ? c : p), 0); 42 | articles.push(data as any); 43 | return data as any; 44 | } 45 | 46 | export function removeArticle(id: number) { 47 | articles = articles.filter((d) => d.id !== id); 48 | } 49 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/models/author.ts: -------------------------------------------------------------------------------- 1 | import { schemaComposer } from 'graphql-compose'; 2 | import { ArticleTC, getArticles } from './article'; 3 | import _ from 'lodash'; 4 | 5 | export let authors = [ 6 | { id: 1, name: 'User 1' }, 7 | { id: 2, name: 'User 2' }, 8 | { id: 3, name: 'User 3' }, 9 | ]; 10 | 11 | export const AuthorTC = schemaComposer.createObjectTC(` 12 | type Author { 13 | id: Int 14 | name: String 15 | } 16 | `); 17 | 18 | AuthorTC.addFields({ 19 | articles: { 20 | type: () => [ArticleTC], 21 | args: { page: 'Int', perPage: 'Int' }, 22 | resolve: (source, args) => 23 | getArticles({ 24 | filter: { authorId: source.id }, 25 | page: args.page, 26 | perPage: args.perPage, 27 | }), 28 | }, 29 | }); 30 | 31 | export function getAuthor(id: number) { 32 | return authors.find((r) => r.id === id); 33 | } 34 | 35 | export function getAuthors(opts: { 36 | page?: number; 37 | perPage?: number; 38 | filter?: Partial; 39 | }) { 40 | const { page = 1, perPage = 10, filter } = opts; 41 | return _.filter(authors, filter).slice((page - 1) * perPage, page * perPage); 42 | } 43 | 44 | export function addAuthor(data: Partial): typeof authors[0] { 45 | if (!data.id) data.id = authors.reduce((c, { id: p }) => (c > p ? c : p), 0); 46 | authors.push(data as any); 47 | return data as any; 48 | } 49 | 50 | export function removeAuthor(id: number) { 51 | authors = authors.filter((d) => d.id !== id); 52 | } 53 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-server", 3 | "main": "dist/index.js", 4 | "license": "UNLICENSED", 5 | "scripts": { 6 | "watch": "ts-node-dev --no-notify --watch ./schema ./index.ts" 7 | }, 8 | "dependencies": { 9 | "graphql-compose-modules": "link:../../src" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'graphql-compose-modules'; 2 | 3 | export const schema = buildSchema(module); 4 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/mutation/articles/add.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { ArticleTC, addArticle } from '../../../models/article'; 3 | 4 | export default { 5 | type: ArticleTC, 6 | args: { 7 | title: 'String!', 8 | text: 'String', 9 | authorId: 'Int', 10 | }, 11 | resolve: (_, args) => addArticle(args), 12 | } as FieldConfig; 13 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/mutation/articles/remove.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { ArticleTC, removeArticle } from '../../../models/article'; 3 | 4 | export default { 5 | type: ArticleTC, 6 | args: { 7 | id: 'Int!', 8 | }, 9 | resolve: (_, args) => removeArticle(args.id), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/mutation/authors/add.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { AuthorTC, addAuthor } from '../../../models/author'; 3 | 4 | export default { 5 | type: AuthorTC, 6 | args: { 7 | name: 'String!', 8 | }, 9 | resolve: (_, args) => addAuthor(args), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/mutation/authors/remove.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { AuthorTC, removeAuthor } from '../../../models/author'; 3 | 4 | export default { 5 | type: AuthorTC, 6 | args: { 7 | id: 'Int!', 8 | }, 9 | resolve: (_, args) => removeAuthor(args.id), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/query/articles/byId.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { ArticleTC, getArticle } from '../../../models/article'; 3 | 4 | export default { 5 | type: ArticleTC, 6 | args: { 7 | id: 'Int!', 8 | }, 9 | resolve: (_, args) => getArticle(args.id), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/query/articles/list.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { getArticles, ArticleTC } from '../../../models/article'; 3 | 4 | export default { 5 | type: [ArticleTC], 6 | args: { 7 | page: 'Int', 8 | perPage: { type: 'Int', defaultValue: 3 }, 9 | }, 10 | resolve: (_, args) => getArticles({ page: args.page, perPage: args.perPage }), 11 | } as FieldConfig; 12 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/query/authors/byId.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { AuthorTC, getAuthor } from '../../../models/author'; 3 | 4 | export default { 5 | type: AuthorTC, 6 | args: { 7 | id: 'Int!', 8 | }, 9 | resolve: (_, args) => getAuthor(args.id), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/schema/query/authors/list.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from 'graphql-compose-modules'; 2 | import { getAuthors, AuthorTC } from '../../../models/author'; 3 | 4 | export default { 5 | type: [AuthorTC], 6 | args: { 7 | page: 'Int', 8 | perPage: { type: 'Int', defaultValue: 3 }, 9 | }, 10 | resolve: (_, args) => getAuthors({ page: args.page, perPage: args.perPage }), 11 | } as FieldConfig; 12 | -------------------------------------------------------------------------------- /examples/simpleNamespaces/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "graphql-compose-modules@link:../../src": 6 | version "0.0.0" 7 | uid "" 8 | -------------------------------------------------------------------------------- /examples/testSchema/index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server'; 2 | import { schema } from '../../src/__tests__/__testSchema__'; 3 | 4 | const server = new ApolloServer({ 5 | schema, 6 | }); 7 | 8 | server.listen().then(({ url }) => { 9 | console.log(`🚀 Server ready at ${url}`); 10 | }); 11 | -------------------------------------------------------------------------------- /examples/testSchema/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-server", 3 | "main": "dist/index.js", 4 | "license": "UNLICENSED", 5 | "scripts": { 6 | "watch": "ts-node-dev --no-notify --watch ./schema ./index.ts" 7 | }, 8 | "dependencies": { 9 | "graphql-compose-modules": "link:../../src" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/testSchema/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "graphql-compose-modules@link:../../src": 6 | version "0.0.0" 7 | uid "" 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | globals: { 5 | 'ts-jest': { 6 | tsconfig: '/tsconfig.json', 7 | isolatedModules: true, 8 | diagnostics: false, 9 | }, 10 | }, 11 | moduleFileExtensions: ['ts', 'js'], 12 | transform: { 13 | '^.+\\.(ts|js)$': 'ts-jest', 14 | }, 15 | roots: ['/src'], 16 | testPathIgnorePatterns: ['/node_modules/', '/lib/'], 17 | testMatch: ['**/__tests__/**/*-test.(ts|js)'], 18 | reporters: [ 19 | 'default', 20 | [ 21 | 'jest-junit', 22 | { 23 | outputDirectory: 'coverage/junit/', 24 | outputName: 'jest-junit.xml', 25 | classNameTemplate: '{classname} › {title}', 26 | titleTemplate: '{classname} › {title}', 27 | suiteName: '{filepath}', 28 | addFileAttribute: 'true', 29 | ancestorSeparator: ' › ', 30 | usePathForSuiteName: 'true', 31 | }, 32 | ], 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-compose-modules", 3 | "license": "MIT", 4 | "version": "0.0.0-development", 5 | "description": "A toolkit for construction GraphQL Schema via file structure", 6 | "repository": "https://github.com/graphql-compose/graphql-compose-modules", 7 | "homepage": "https://github.com/graphql-compose/graphql-compose-modules", 8 | "main": "lib/index", 9 | "types": "lib/index.d.ts", 10 | "files": [ 11 | "lib" 12 | ], 13 | "dependencies": { 14 | "dedent": "0.7.0" 15 | }, 16 | "peerDependencies": { 17 | "graphql-compose": "^7.7.0 || ^8.0.0 || ^9.0.0" 18 | }, 19 | "devDependencies": { 20 | "@types/dedent": "0.7.0", 21 | "@types/glob": "7.2.0", 22 | "@types/jest": "27.0.3", 23 | "@types/lodash.sortby": "^4.7.6", 24 | "@types/node": "17.0.0", 25 | "@typescript-eslint/eslint-plugin": "5.7.0", 26 | "@typescript-eslint/parser": "5.7.0", 27 | "apollo-server": "3.5.0", 28 | "eslint": "8.4.1", 29 | "eslint-config-prettier": "8.3.0", 30 | "eslint-plugin-prettier": "4.0.0", 31 | "graphql": "16.1.0", 32 | "graphql-compose": "9.0.5", 33 | "jest": "27.4.5", 34 | "jest-junit": "^13.0.0", 35 | "lodash.sortby": "^4.7.0", 36 | "prettier": "2.5.1", 37 | "rimraf": "3.0.2", 38 | "semantic-release": "18.0.1", 39 | "ts-jest": "27.1.1", 40 | "ts-node": "10.4.0", 41 | "ts-node-dev": "1.1.8", 42 | "typescript": "4.5.4" 43 | }, 44 | "scripts": { 45 | "watch": "jest --watch", 46 | "coverage": "jest --coverage", 47 | "build": "rimraf ./lib && tsc --build tsconfig.build.json", 48 | "lint": "yarn eslint", 49 | "eslint": "eslint --ext .ts ./src", 50 | "test": "yarn lint && yarn tscheck && yarn coverage", 51 | "tscheck": "tsc --noEmit", 52 | "semantic-release": "semantic-release", 53 | "start-example1": "cd ./examples/simple && yarn install && yarn watch", 54 | "start-example2": "cd ./examples/simpleNamespaces && yarn install && yarn watch", 55 | "start-example3": "cd ./examples/testSchema && yarn install && yarn watch" 56 | }, 57 | "collective": { 58 | "type": "opencollective", 59 | "url": "https://opencollective.com/graphql-compose", 60 | "logo": "https://opencollective.com/graphql-compose/logo.txt" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/VisitInfo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComposeNamedOutputType, 3 | ComposeOutputType, 4 | isTypeComposer, 5 | ObjectTypeComposer, 6 | SchemaComposer, 7 | unwrapOutputTC, 8 | upperFirst, 9 | } from 'graphql-compose'; 10 | import { 11 | AstDirNode, 12 | AstFileNode, 13 | AstRootNode, 14 | AstRootTypeNode, 15 | RootTypeNames, 16 | } from './directoryToAst'; 17 | import { FieldConfig } from './typeDefs'; 18 | 19 | interface VisitInfoData { 20 | node: TNode; 21 | nodeParent: AstDirNode | AstRootTypeNode | AstRootNode; 22 | operation: RootTypeNames; 23 | fieldName: string; 24 | fieldPath: string[]; 25 | schemaComposer: SchemaComposer; 26 | } 27 | 28 | export class VisitInfo { 29 | node: TNode; 30 | /** Parent AST node from directoryToAst */ 31 | nodeParent: AstDirNode | AstRootTypeNode | AstRootNode; 32 | /** Brunch of schema under which is working visitor. Can be: query, mutation, subscription */ 33 | operation: RootTypeNames; 34 | /** Name of field for current FieldConfig */ 35 | fieldName: string; 36 | /** List of parent names starting from root */ 37 | fieldPath: string[]; 38 | /** Type registry */ 39 | schemaComposer: SchemaComposer; 40 | 41 | constructor(data: VisitInfoData) { 42 | this.node = data.node; 43 | this.operation = data.operation; 44 | this.nodeParent = data.nodeParent; 45 | this.schemaComposer = data.schemaComposer; 46 | 47 | this.fieldPath = data.fieldPath; 48 | if (data.fieldName.indexOf('.')) { 49 | // if fieldName has dots, then split it 50 | const parts = data.fieldName.split('.').filter(Boolean); 51 | const fieldName = parts.pop() as string; 52 | this.fieldName = fieldName; 53 | this.fieldPath.push(...parts); 54 | } else { 55 | this.fieldName = data.fieldName; 56 | } 57 | } 58 | 59 | /** 60 | * Check that this entrypoint belongs to Query 61 | */ 62 | isQuery(): boolean { 63 | return this.operation === 'query'; 64 | } 65 | 66 | /** 67 | * Check that this entrypoint belongs to Mutation 68 | */ 69 | isMutation(): boolean { 70 | return this.operation === 'mutation'; 71 | } 72 | 73 | /** 74 | * Check that this entrypoint belongs to Subscription 75 | */ 76 | isSubscription(): boolean { 77 | return this.operation === 'subscription'; 78 | } 79 | 80 | /** 81 | * Return array of fieldNames. 82 | * Dotted names will be automatically splitted. 83 | * 84 | * @example 85 | * Assume: 86 | * name: 'ping' 87 | * path: ['query.storage', 'viewer', 'utils.debug'] 88 | * For empty options will be returned: 89 | * ['storage', 'viewer', 'utils', 'debug', 'ping'] 90 | * For `{ includeOperation: true }` will be returned: 91 | * ['query', 'storage', 'viewer', 'utils', 'debug', 'ping'] 92 | */ 93 | getFieldPathArray(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string[] { 94 | const res = [] as string[]; 95 | this.fieldPath.forEach((e) => { 96 | if (e.indexOf('.')) { 97 | res.push(...e.split('.').filter(Boolean)); 98 | } else { 99 | res.push(e); 100 | } 101 | }); 102 | 103 | if (!opts?.omitFieldName) { 104 | res.push(this.fieldName); 105 | } 106 | 107 | // slice(1) - remove first element from array 108 | return opts?.includeOperation ? res : res.slice(1); 109 | } 110 | 111 | /** 112 | * Return dotted path for current field 113 | */ 114 | getFieldPathDotted(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string { 115 | return this.getFieldPathArray(opts).join('.'); 116 | } 117 | 118 | /** 119 | * Return path as CamelCase string. 120 | * 121 | * Useful for getting type name according to path 122 | */ 123 | getFieldPathCamelCase(opts?: { includeOperation?: boolean; omitFieldName?: boolean }): string { 124 | return this.getFieldPathArray(opts) 125 | .map((s) => upperFirst(s)) 126 | .join(''); 127 | } 128 | 129 | /** 130 | * Get FieldConfig for file or dir. 131 | * This is mutable object and is shared between all calls. 132 | */ 133 | get fieldConfig(): FieldConfig { 134 | const node = this.node; 135 | if (node.kind === 'file') { 136 | return node.fieldConfig; 137 | } else if (node.kind === 'dir' || this.node.kind === 'rootType') { 138 | // TODO: think about namespaceConfig (how to do it not null) 139 | return node.namespaceConfig?.fieldConfig as any; 140 | } 141 | throw new Error(`Cannot get fieldConfig. Node has some strange kind: ${node.kind}`); 142 | } 143 | 144 | /** 145 | * Get TypeComposer instance for output type (object, scalar, enum, interface, union). 146 | * It's mutable object. 147 | */ 148 | getOutputAnyTC(): ComposeOutputType { 149 | const fc = this.fieldConfig; 150 | const outputType = fc.type; 151 | if (!outputType) { 152 | throw new Error(`FieldConfig ${this.getFieldPathDotted()} does not have 'type' property`); 153 | } 154 | 155 | // if the type is of any kind of TypeComposer 156 | // then return it directly 157 | // or try to convert it to TypeComposer and save in FieldConfig as prepared type 158 | if (isTypeComposer(outputType)) { 159 | return outputType; 160 | } else { 161 | const outputTC = this.schemaComposer.typeMapper.convertOutputTypeDefinition( 162 | outputType, 163 | this.fieldName, 164 | this.nodeParent?.name 165 | ); 166 | 167 | if (!outputTC) { 168 | throw new Error( 169 | `FieldConfig ${this.getFieldPathDotted()} contains some strange value as output type` 170 | ); 171 | } 172 | 173 | fc.type = outputTC; 174 | return outputTC; 175 | } 176 | } 177 | 178 | /** 179 | * Check that output type is an object 180 | */ 181 | isOutputTypeIsObject(): boolean { 182 | return this.getOutputAnyTC() instanceof ObjectTypeComposer; 183 | } 184 | 185 | /** 186 | * Get TypeComposer instance for output type (object, scalar, enum, interface, union). 187 | * It's mutable object. 188 | */ 189 | getOutputUnwrappedTC(): ComposeNamedOutputType { 190 | return unwrapOutputTC(this.getOutputAnyTC()); 191 | } 192 | 193 | /** 194 | * Get TypeComposer instance for output type (object, scalar, enum, interface, union). 195 | * It's mutable object. 196 | */ 197 | getOutputUnwrappedOTC(): ObjectTypeComposer { 198 | const tc = unwrapOutputTC(this.getOutputAnyTC()); 199 | 200 | if (!(tc instanceof ObjectTypeComposer)) { 201 | throw new Error( 202 | `FieldConfig ${this.getFieldPathDotted()} has non-Object output type. Use 'isOutputTypeIsObject()' before for avoiding this error.` 203 | ); 204 | } 205 | 206 | return tc; 207 | } 208 | 209 | toString(): string { 210 | return `VisitInfo(${this.getFieldPathDotted({ includeOperation: true })})`; 211 | } 212 | 213 | toJSON(): string { 214 | return this.toString(); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/__tests__/VisitInfo-test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ListComposer, 3 | ObjectTypeComposer, 4 | ScalarTypeComposer, 5 | SchemaComposer, 6 | } from 'graphql-compose'; 7 | import { AstFileNode, AstRootNode, VisitInfo } from '..'; 8 | 9 | const schemaComposer = new SchemaComposer(); 10 | const nodeParent = { 11 | absPath: 'schema/query', 12 | children: {}, 13 | kind: 'root', 14 | name: 'query', 15 | } as AstRootNode; 16 | const node = { 17 | absPath: 'schema/query/some_endpoint.ts', 18 | code: {}, 19 | fieldConfig: { 20 | type: 'String', 21 | resolve: () => 'Hello!', 22 | } as any, 23 | kind: 'file', 24 | name: 'some_endpoint', 25 | } as AstFileNode; 26 | 27 | beforeEach(() => { 28 | schemaComposer.clear(); 29 | }); 30 | 31 | describe('VisitInfo', () => { 32 | it('getFieldPathArray()', () => { 33 | const info = new VisitInfo({ 34 | operation: 'query', 35 | fieldName: 'ping', 36 | fieldPath: ['query.storage', 'viewer', 'utils.debug'], 37 | node, 38 | nodeParent, 39 | schemaComposer, 40 | }); 41 | 42 | expect(info.getFieldPathArray()).toEqual(['storage', 'viewer', 'utils', 'debug', 'ping']); 43 | expect(info.getFieldPathArray({ omitFieldName: true })).toEqual([ 44 | 'storage', 45 | 'viewer', 46 | 'utils', 47 | 'debug', 48 | ]); 49 | expect(info.getFieldPathArray({ includeOperation: true })).toEqual([ 50 | 'query', 51 | 'storage', 52 | 'viewer', 53 | 'utils', 54 | 'debug', 55 | 'ping', 56 | ]); 57 | 58 | const info2 = new VisitInfo({ 59 | operation: 'query', 60 | fieldName: 'namespace.ping', 61 | fieldPath: ['query.storage'], 62 | node, 63 | nodeParent, 64 | schemaComposer, 65 | }); 66 | expect(info2.getFieldPathArray()).toEqual(['storage', 'namespace', 'ping']); 67 | expect(info2.getFieldPathArray({ omitFieldName: true })).toEqual(['storage', 'namespace']); 68 | }); 69 | 70 | it('getFieldPathDotted()', () => { 71 | const info = new VisitInfo({ 72 | operation: 'query', 73 | fieldName: 'ping', 74 | fieldPath: ['query.storage', 'viewer', 'utils.debug'], 75 | node, 76 | nodeParent, 77 | schemaComposer, 78 | }); 79 | 80 | expect(info.getFieldPathDotted()).toEqual('storage.viewer.utils.debug.ping'); 81 | expect(info.getFieldPathDotted({ omitFieldName: true })).toEqual('storage.viewer.utils.debug'); 82 | expect(info.getFieldPathDotted({ includeOperation: true })).toEqual( 83 | 'query.storage.viewer.utils.debug.ping' 84 | ); 85 | }); 86 | 87 | it('getFieldPathCamelCase()', () => { 88 | const info = new VisitInfo({ 89 | operation: 'query', 90 | fieldName: 'ping', 91 | fieldPath: ['query.storage', 'viewer', 'utils.debug'], 92 | node, 93 | nodeParent, 94 | schemaComposer, 95 | }); 96 | 97 | expect(info.getFieldPathCamelCase()).toEqual('StorageViewerUtilsDebugPing'); 98 | expect(info.getFieldPathCamelCase({ omitFieldName: true })).toEqual('StorageViewerUtilsDebug'); 99 | expect(info.getFieldPathCamelCase({ includeOperation: true })).toEqual( 100 | 'QueryStorageViewerUtilsDebugPing' 101 | ); 102 | }); 103 | 104 | it('get fieldConfig', () => { 105 | const info = new VisitInfo({ 106 | operation: 'query', 107 | fieldName: 'ping', 108 | fieldPath: ['query.storage', 'viewer', 'utils.debug'], 109 | node, 110 | nodeParent, 111 | schemaComposer, 112 | }); 113 | 114 | const { fieldConfig } = info; 115 | expect(fieldConfig).toEqual({ resolve: expect.anything(), type: 'String' }); 116 | }); 117 | 118 | describe('methods for output type', () => { 119 | const info = new VisitInfo({ 120 | operation: 'query', 121 | fieldName: 'ping', 122 | fieldPath: ['query.storage', 'viewer', 'utils.debug'], 123 | node: { ...node, fieldConfig: { ...node.fieldConfig } }, 124 | nodeParent, 125 | schemaComposer, 126 | }); 127 | 128 | it('getOutputAnyTC() with Scalar', () => { 129 | const tc = info.getOutputAnyTC(); 130 | expect(tc instanceof ScalarTypeComposer).toBeTruthy(); 131 | expect(tc.getTypeName()).toEqual('String'); 132 | }); 133 | 134 | it('getOutputAnyTC() with List', () => { 135 | info.fieldConfig.type = '[String!]'; 136 | const tc = info.getOutputAnyTC(); 137 | expect(tc instanceof ListComposer).toBeTruthy(); 138 | expect(tc.getTypeName()).toEqual('[String!]'); 139 | }); 140 | 141 | it('isOutputTypeIsObject()', () => { 142 | info.fieldConfig.type = 'String'; 143 | expect(info.isOutputTypeIsObject()).toBeFalsy(); 144 | info.fieldConfig.type = '[String!]'; 145 | expect(info.isOutputTypeIsObject()).toBeFalsy(); 146 | info.fieldConfig.type = 'type MyObj { a: Int }'; 147 | expect(info.isOutputTypeIsObject()).toBeTruthy(); 148 | }); 149 | 150 | it('getOutputUnwrappedTC()', () => { 151 | info.fieldConfig.type = 'String'; 152 | expect(info.getOutputUnwrappedTC() instanceof ScalarTypeComposer).toBeTruthy(); 153 | expect(info.getOutputUnwrappedTC().getTypeName()).toBe('String'); 154 | info.fieldConfig.type = '[String!]'; 155 | expect(info.getOutputUnwrappedTC() instanceof ScalarTypeComposer).toBeTruthy(); 156 | expect(info.getOutputUnwrappedTC().getTypeName()).toBe('String'); 157 | info.fieldConfig.type = ['type MyObj { a: Int }']; 158 | expect(info.getOutputUnwrappedTC() instanceof ObjectTypeComposer).toBeTruthy(); 159 | expect(info.getOutputUnwrappedTC().getTypeName()).toBe('MyObj'); 160 | }); 161 | 162 | it('getOutputUnwrappedTC()', () => { 163 | info.fieldConfig.type = 'String'; 164 | expect(() => info.getOutputUnwrappedOTC()).toThrowError(/has non-Object output type/); 165 | 166 | info.fieldConfig.type = ['type MyObj { a: Int }']; 167 | expect(info.getOutputUnwrappedOTC() instanceof ObjectTypeComposer).toBeTruthy(); 168 | expect(info.getOutputUnwrappedOTC().getTypeName()).toBe('MyObj'); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/merge/schema1/mutation/createTask.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'String', 3 | description: 'A.mutation.createTask', 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/merge/schema1/query/me.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'String', 3 | description: 'A.query.me', 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/merge/schema1/query/tasks/byId.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'String', 3 | description: 'A.query.tasks.byId', 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/merge/schema1/query/tasks/list.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'String', 3 | description: 'A.query.tasks.list', 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/merge/schema2/query/me.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'String', 3 | description: 'B.query.me', 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/merge/schema2/query/tasks/byId.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'String', 3 | description: 'B.query.tasks.byId', 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/merge/schema2/query/tasks/byIds.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'String', 3 | description: 'B.query.tasks.byIds', 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/__fixtures__/merge/schema2/subscription/events.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | type: 'String', 3 | description: 'B.subscription.events', 4 | }; 5 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/astToSchema-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`astToSchema() Schema ./__testSchema__ schema 1`] = ` 4 | "type Query { 5 | field: String 6 | me(arg: Int): QueryMe 7 | some: QuerySome 8 | user: UserAwesomeType 9 | auth: NamespaceCustomTypeName 10 | } 11 | 12 | type QueryMe { 13 | address: QueryMeAddress 14 | name: String 15 | } 16 | 17 | type QueryMeAddress { 18 | city: Boolean 19 | street: Boolean 20 | } 21 | 22 | type QuerySome { 23 | index: SomeIndexFileType 24 | nested: Int 25 | } 26 | 27 | type SomeIndexFileType { 28 | awesomeValue: String 29 | } 30 | 31 | type UserAwesomeType { 32 | firstName: String 33 | lastName: String 34 | extendedData: UserExtendedData 35 | roles: [String] 36 | } 37 | 38 | type UserExtendedData { 39 | starsCount: Int 40 | } 41 | 42 | type NamespaceCustomTypeName { 43 | isLoggedIn: Boolean 44 | nested: QueryAuthNested 45 | } 46 | 47 | type QueryAuthNested { 48 | method: Boolean 49 | } 50 | 51 | type Mutation { 52 | logs: MutationLogs 53 | auth: MutationAuth 54 | user: MutationUser 55 | } 56 | 57 | type MutationLogs { 58 | nested: MutationLogsNested 59 | } 60 | 61 | type MutationLogsNested { 62 | list: Boolean 63 | } 64 | 65 | type MutationAuth { 66 | \\"\\"\\"Login operation\\"\\"\\" 67 | login(email: String, password: String): Boolean 68 | logout: Boolean 69 | nested: MutationAuthNested 70 | } 71 | 72 | type MutationAuthNested { 73 | method: Boolean 74 | } 75 | 76 | type MutationUser { 77 | create: Boolean 78 | update: Boolean 79 | }" 80 | `; 81 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/directoryToAst-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`directoryToAst() Schema ./__testSchema__ mutation has proper values 1`] = ` 4 | Object { 5 | "absPath": Any, 6 | "children": Object { 7 | "auth": Object { 8 | "absPath": Any, 9 | "children": Object { 10 | "login": ObjectContaining { 11 | "kind": "file", 12 | }, 13 | "logout": ObjectContaining { 14 | "kind": "file", 15 | }, 16 | "nested": ObjectContaining { 17 | "kind": "dir", 18 | }, 19 | }, 20 | "kind": "dir", 21 | "name": "auth", 22 | "namespaceConfig": ObjectContaining { 23 | "kind": "file", 24 | }, 25 | }, 26 | "logs.nested": Object { 27 | "absPath": Any, 28 | "children": Object { 29 | "list": ObjectContaining { 30 | "kind": "file", 31 | }, 32 | }, 33 | "kind": "dir", 34 | "name": "logs.nested", 35 | }, 36 | "user": Object { 37 | "absPath": Any, 38 | "children": Object { 39 | "create": ObjectContaining { 40 | "kind": "file", 41 | }, 42 | "update": ObjectContaining { 43 | "kind": "file", 44 | }, 45 | }, 46 | "kind": "dir", 47 | "name": "user", 48 | }, 49 | }, 50 | "kind": "rootType", 51 | "name": "mutation", 52 | } 53 | `; 54 | 55 | exports[`directoryToAst() Schema ./__testSchema__ query has proper values 1`] = ` 56 | Object { 57 | "absPath": Any, 58 | "children": Object { 59 | "auth": Object { 60 | "absPath": Any, 61 | "children": Object { 62 | "isLoggedIn": ObjectContaining { 63 | "kind": "file", 64 | }, 65 | "nested": ObjectContaining { 66 | "kind": "dir", 67 | }, 68 | }, 69 | "kind": "dir", 70 | "name": "auth", 71 | "namespaceConfig": ObjectContaining { 72 | "kind": "file", 73 | }, 74 | }, 75 | "field": ObjectContaining { 76 | "kind": "file", 77 | }, 78 | "me": Object { 79 | "absPath": Any, 80 | "children": Object { 81 | "address.city": ObjectContaining { 82 | "kind": "file", 83 | }, 84 | "address.street": ObjectContaining { 85 | "kind": "file", 86 | }, 87 | "name": ObjectContaining { 88 | "kind": "file", 89 | }, 90 | }, 91 | "kind": "dir", 92 | "name": "me", 93 | "namespaceConfig": Object { 94 | "absPath": Any, 95 | "code": Any, 96 | "fieldConfig": Any, 97 | "kind": "file", 98 | "name": "index", 99 | }, 100 | }, 101 | "some.index": Any, 102 | "some.nested": ObjectContaining { 103 | "kind": "file", 104 | }, 105 | "user": Object { 106 | "absPath": Any, 107 | "children": Object { 108 | "extendedData": Any, 109 | "roles": Any, 110 | }, 111 | "kind": "dir", 112 | "name": "user", 113 | "namespaceConfig": Any, 114 | }, 115 | }, 116 | "kind": "rootType", 117 | "name": "query", 118 | "namespaceConfig": Any, 119 | } 120 | `; 121 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/index.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from '../../../src'; 2 | import { SchemaComposer } from 'graphql-compose'; 3 | 4 | const schemaComposer = new SchemaComposer(); 5 | schemaComposer.Query.addFields({ 6 | time: { 7 | type: 'String', 8 | resolve: () => Date.now(), 9 | }, 10 | }); 11 | 12 | export const schema = buildSchema(module, { schemaComposer }); 13 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/mutation.auth/index.ts: -------------------------------------------------------------------------------- 1 | import { NamespaceConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | resolve: () => ({}), 5 | } as NamespaceConfig; 6 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/mutation.auth/login.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | description: 'Login operation', 6 | args: { email: 'String', password: 'String' }, 7 | resolve: () => true, 8 | } as FieldConfig; 9 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/mutation.auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/mutation.auth/nested/method.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/mutation.user/create.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/mutation.user/update.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/mutation/logs.nested/list.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query.auth/index.ts: -------------------------------------------------------------------------------- 1 | import { NamespaceConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: 'NamespaceCustomTypeName', 5 | } as NamespaceConfig; 6 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query.auth/isLoggedIn.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: (sc) => sc.get('Boolean'), 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query.auth/nested/method.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '../../../../.eslintrc', 3 | }; 4 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/__skip/field.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: 'String', 5 | resolve: () => 'text', 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/field.spec.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: 'String', 5 | description: 'This file should be skipped', 6 | resolve: () => 'ok', 7 | } as FieldConfig; 8 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/field.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: 'String', 5 | resolve: () => 'ok', 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/index.ts: -------------------------------------------------------------------------------- 1 | import { NamespaceConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | resolve: () => ({}), 5 | } as NamespaceConfig; 6 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/me/address.city.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/me/address.street.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Boolean', 5 | resolve: () => true, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/me/index.ts: -------------------------------------------------------------------------------- 1 | import { NamespaceConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | args: { 5 | arg: 'Int', 6 | }, 7 | resolve: (_, __, context) => { 8 | if (!context?.isAdmin) throw new Error('You should be the ADMIN'); 9 | return {}; 10 | }, 11 | } as NamespaceConfig; 12 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/me/name.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: 'String', 5 | resolve: () => 'nodkz', 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/skip.d.ts: -------------------------------------------------------------------------------- 1 | export type Some = string; 2 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/skip.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;AAAA,mEAAkE;AAazD,iCAbA,+CAAsB,CAaA;AAZ/B,2DAA0D;AAYzB,6BAZxB,uCAAkB,CAYwB;AAVnD,SAAgB,WAAW,CAAC,MAAkB;IAC5C,OAAO,kBAAkB,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAC;AAClD,CAAC;AAFD,kCAEC;AAED,SAAgB,kBAAkB,CAAC,MAAkB;IACnD,IAAM,GAAG,GAAG,+CAAsB,CAAC,MAAM,CAAC,CAAC;IAC3C,IAAM,EAAE,GAAG,uCAAkB,CAAC,GAAG,CAAC,CAAC;IACnC,OAAO,EAAE,CAAC;AACZ,CAAC;AAJD,gDAIC"} -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/skip.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;AAAA,mEAAkE;AAazD,iCAbA,+CAAsB,CAaA;AAZ/B,2DAA0D;AAYzB,6BAZxB,uCAAkB,CAYwB;AAVnD,SAAgB,WAAW,CAAC,MAAkB;IAC5C,OAAO,kBAAkB,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAC;AAClD,CAAC;AAFD,kCAEC;AAED,SAAgB,kBAAkB,CAAC,MAAkB;IACnD,IAAM,GAAG,GAAG,+CAAsB,CAAC,MAAM,CAAC,CAAC;IAC3C,IAAM,EAAE,GAAG,uCAAkB,CAAC,GAAG,CAAC,CAAC;IACnC,OAAO,EAAE,CAAC;AACZ,CAAC;AAJD,gDAIC"} -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/some.index.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: ` 5 | type SomeIndexFileType { 6 | awesomeValue: String 7 | } 8 | `, 9 | resolve: () => ({ awesomeValue: 'awesomeValue' }), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/some.nested.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../typeDefs'; 2 | 3 | export default { 4 | type: 'Int', 5 | resolve: () => 123, 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/user/extendedData.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: ` 5 | type UserExtendedData { 6 | starsCount: Int 7 | } 8 | `, 9 | resolve: () => ({ starsCount: 10 }), 10 | } as FieldConfig; 11 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/user/index.ts: -------------------------------------------------------------------------------- 1 | import { NamespaceConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: ` 5 | type UserAwesomeType { 6 | firstName: String 7 | lastName: String 8 | } 9 | `, 10 | args: {}, 11 | resolve: () => { 12 | return { 13 | firstName: 'Awesome', 14 | lastName: 'User', 15 | }; 16 | }, 17 | } as NamespaceConfig; 18 | -------------------------------------------------------------------------------- /src/__tests__/__testSchema__/query/user/roles.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig } from '../../../../typeDefs'; 2 | 3 | export default { 4 | type: ['String'], 5 | resolve: () => ['ADMIN', 'USER'], 6 | } as FieldConfig; 7 | -------------------------------------------------------------------------------- /src/__tests__/astMerge-test.ts: -------------------------------------------------------------------------------- 1 | import { astMerge } from '../astMerge'; 2 | import { directoryToAst, AstRootNode } from '../directoryToAst'; 3 | import { astToSchema } from '../astToSchema'; 4 | 5 | describe('astMerge', () => { 6 | let ast1: AstRootNode; 7 | let ast2: AstRootNode; 8 | 9 | beforeEach(() => { 10 | ast1 = directoryToAst(module, { rootDir: './__fixtures__/merge/schema1' }); 11 | ast2 = directoryToAst(module, { rootDir: './__fixtures__/merge/schema2' }); 12 | }); 13 | 14 | it('should merge two schemas', () => { 15 | const mergedAst = astMerge(ast1, ast2); 16 | const schema = astToSchema(mergedAst); 17 | 18 | expect(schema.toSDL()).toMatchInlineSnapshot(` 19 | "type Query { 20 | \\"\\"\\"B.query.me\\"\\"\\" 21 | me: String 22 | tasks: QueryTasks 23 | } 24 | 25 | type Mutation { 26 | \\"\\"\\"A.mutation.createTask\\"\\"\\" 27 | createTask: String 28 | } 29 | 30 | type Subscription { 31 | \\"\\"\\"B.subscription.events\\"\\"\\" 32 | events: String 33 | } 34 | 35 | \\"\\"\\" 36 | The \`String\` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. 37 | \\"\\"\\" 38 | scalar String 39 | 40 | type QueryTasks { 41 | \\"\\"\\"B.query.tasks.byId\\"\\"\\" 42 | byId: String 43 | 44 | \\"\\"\\"A.query.tasks.list\\"\\"\\" 45 | list: String 46 | 47 | \\"\\"\\"B.query.tasks.byIds\\"\\"\\" 48 | byIds: String 49 | }" 50 | `); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/__tests__/astToSchema-test.ts: -------------------------------------------------------------------------------- 1 | import { directoryToAst, getAstForDir, getAstForFile } from '../directoryToAst'; 2 | import { astToSchema, createFields } from '../astToSchema'; 3 | import { SchemaComposer } from 'graphql-compose'; 4 | import { printSchema } from 'graphql/utilities'; 5 | import path from 'path'; 6 | import dedent from 'dedent'; 7 | 8 | describe('astToSchema()', () => { 9 | describe('Schema ./__testSchema__', () => { 10 | const ast = directoryToAst(module, { rootDir: './__testSchema__' }); 11 | const sc = astToSchema(ast); 12 | 13 | it('should return schema composer', () => { 14 | expect(sc).toBeInstanceOf(SchemaComposer); 15 | }); 16 | 17 | it('schema', async () => { 18 | const printedSchema = await printSchema(sc.buildSchema()); 19 | expect(printedSchema).toMatchSnapshot(); 20 | }); 21 | }); 22 | 23 | describe('createFields', () => { 24 | let sc: SchemaComposer; 25 | beforeEach(() => { 26 | sc = new SchemaComposer(); 27 | }); 28 | 29 | it('file: query/field.ts', () => { 30 | createFields( 31 | sc, 32 | getAstForFile(module, path.resolve(__dirname, './__testSchema__/query/field.ts')), 33 | sc.Query, 34 | 'Query' 35 | ); 36 | expect(sc.Query.hasField('field')).toBeTruthy(); 37 | expect(sc.Query.getFieldTypeName('field')).toBe('String'); 38 | }); 39 | 40 | it('file: query/some.nested.ts', () => { 41 | createFields( 42 | sc, 43 | getAstForFile(module, path.resolve(__dirname, './__testSchema__/query/some.nested.ts')), 44 | sc.Query, 45 | 'Query' 46 | ); 47 | expect(sc.Query.hasField('some')).toBeTruthy(); 48 | expect(sc.Query.getFieldTypeName('some')).toBe('QuerySome'); 49 | expect(sc.Query.getFieldOTC('some').hasField('nested')).toBeTruthy(); 50 | expect(sc.Query.getFieldOTC('some').getFieldTypeName('nested')).toBe('Int'); 51 | }); 52 | 53 | it('file: query/some.index.ts should be as regular field', () => { 54 | createFields( 55 | sc, 56 | getAstForFile(module, path.resolve(__dirname, './__testSchema__/query/some.index.ts')), 57 | sc.Query, 58 | 'Query' 59 | ); 60 | expect(sc.Query.hasField('some')).toBeTruthy(); 61 | expect(sc.Query.getFieldTypeName('some')).toBe('QuerySome'); 62 | expect(sc.Query.getFieldOTC('some').hasField('index')).toBeTruthy(); 63 | expect(sc.Query.getFieldOTC('some').getFieldTypeName('index')).toBe('SomeIndexFileType'); 64 | expect((sc.Query.getFieldOTC('some').getFieldConfig('index') as any).resolve()).toEqual({ 65 | awesomeValue: 'awesomeValue', 66 | }); 67 | }); 68 | 69 | it('dir: query/me/', () => { 70 | createFields( 71 | sc, 72 | getAstForDir(module, path.resolve(__dirname, './__testSchema__/query/me')), 73 | sc.Query, 74 | 'Query' 75 | ); 76 | expect(sc.Query.hasField('me')).toBeTruthy(); 77 | expect(sc.Query.getFieldTypeName('me')).toBe('QueryMe'); 78 | 79 | // check query/me/index.ts 80 | // should provide args for `me` field 81 | expect(sc.Query.getFieldArgTypeName('me', 'arg')).toBe('Int'); 82 | expect(() => (sc.Query.getFieldConfig('me') as any).resolve()).toThrow( 83 | 'You should be the ADMIN' 84 | ); 85 | // check that fields from sibling files was added 86 | expect(sc.Query.getFieldOTC('me').getFieldTypeName('name')).toBe('String'); 87 | expect((sc.Query.getFieldOTC('me').getFieldConfig('name') as any).resolve()).toBe('nodkz'); 88 | expect(sc.Query.getFieldOTC('me').getFieldOTC('address').getTypeName()).toBe( 89 | 'QueryMeAddress' 90 | ); 91 | expect(sc.Query.getFieldOTC('me').getFieldOTC('address').getFieldNames().sort()).toEqual([ 92 | 'city', 93 | 'street', 94 | ]); 95 | }); 96 | }); 97 | 98 | it('should properly set name for nested fields with dot notation', () => { 99 | const ast = directoryToAst(module, { 100 | rootDir: './__testSchema__', 101 | include: /query\.auth$|query\.auth\/nested/, 102 | }); 103 | const sc = astToSchema(ast); 104 | expect(sc.Query.getFieldOTC('auth').getFieldTypeName('nested')).toBe('QueryAuthNested'); 105 | expect(sc.Query.toSDL({ deep: true })).toBe(dedent` 106 | type Query { 107 | auth: QueryAuth 108 | } 109 | 110 | type QueryAuth { 111 | nested: QueryAuthNested 112 | } 113 | 114 | type QueryAuthNested { 115 | method: Boolean 116 | } 117 | 118 | """The \`Boolean\` scalar type represents \`true\` or \`false\`.""" 119 | scalar Boolean 120 | `); 121 | }); 122 | 123 | describe('astToSchema()', () => { 124 | it('should properly add prefix for created TypeNames', () => { 125 | const ast = directoryToAst(module, { 126 | rootDir: './__testSchema__', 127 | include: /query\.auth$|query\.auth\/nested/, 128 | }); 129 | const sc = astToSchema(ast, { prefix: 'Corp', suffix: 'Old' }); 130 | expect(sc.Query.toSDL({ deep: true })).toBe(dedent` 131 | type Query { 132 | auth: CorpQueryAuthOld 133 | } 134 | 135 | type CorpQueryAuthOld { 136 | nested: CorpQueryAuthNestedOld 137 | } 138 | 139 | type CorpQueryAuthNestedOld { 140 | method: Boolean 141 | } 142 | 143 | """The \`Boolean\` scalar type represents \`true\` or \`false\`.""" 144 | scalar Boolean 145 | `); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/__tests__/astVisitor-test.ts: -------------------------------------------------------------------------------- 1 | import { astVisitor, VISITOR_REMOVE_NODE, VISITOR_SKIP_CHILDREN } from '../astVisitor'; 2 | import { directoryToAst, AstRootNode } from '../directoryToAst'; 3 | import { astToSchema } from '../astToSchema'; 4 | import { graphql } from 'graphql'; 5 | import sortBy from 'lodash.sortby'; 6 | import { SchemaComposer } from 'graphql-compose'; 7 | 8 | describe('astVisitor', () => { 9 | let ast: AstRootNode; 10 | const schemaComposer = new SchemaComposer(); 11 | 12 | beforeEach(() => { 13 | ast = directoryToAst(module, { rootDir: './__testSchema__' }); 14 | schemaComposer.clear(); 15 | }); 16 | 17 | it('should visit all ROOT_TYPEs', () => { 18 | const names: string[] = []; 19 | astVisitor(ast, schemaComposer, { 20 | ROOT_TYPE: (info) => { 21 | names.push(info.fieldName); 22 | expect(info.nodeParent).toBe(ast); 23 | }, 24 | }); 25 | expect(names.sort()).toEqual(['query', 'mutation'].sort()); 26 | }); 27 | 28 | it('should visit all DIRs', () => { 29 | const dirs: string[] = []; 30 | astVisitor(ast, schemaComposer, { 31 | DIR: (info) => { 32 | dirs.push(`${info.fieldPath.join('.')}.${info.fieldName}`); 33 | }, 34 | }); 35 | expect(dirs.sort()).toEqual( 36 | [ 37 | 'mutation.auth', 38 | 'mutation.auth.nested', 39 | 'mutation.logs.nested', 40 | 'mutation.user', 41 | 'query.auth', 42 | 'query.auth.nested', 43 | 'query.me', 44 | 'query.user', 45 | ].sort() 46 | ); 47 | }); 48 | 49 | it('should visit all FILEs', () => { 50 | const files: string[] = []; 51 | astVisitor(ast, schemaComposer, { 52 | FILE: (info) => { 53 | files.push(`${info.fieldPath.join('.')}.${info.fieldName}`); 54 | }, 55 | }); 56 | expect(files.sort()).toEqual( 57 | [ 58 | 'mutation.auth.login', 59 | 'mutation.auth.logout', 60 | 'mutation.auth.nested.method', 61 | 'mutation.logs.nested.list', 62 | 'mutation.user.create', 63 | 'mutation.user.update', 64 | 'query.auth.isLoggedIn', 65 | 'query.auth.nested.method', 66 | 'query.field', 67 | 'query.me.address.city', 68 | 'query.me.address.street', 69 | 'query.me.name', 70 | 'query.some.index', 71 | 'query.some.nested', 72 | 'query.user.extendedData', 73 | 'query.user.roles', 74 | ].sort() 75 | ); 76 | }); 77 | 78 | it('`null` should remove nodes from ast', () => { 79 | astVisitor(ast, schemaComposer, { 80 | DIR: () => { 81 | return VISITOR_REMOVE_NODE; 82 | }, 83 | FILE: () => { 84 | return VISITOR_REMOVE_NODE; 85 | }, 86 | }); 87 | expect(ast.children.query?.children).toEqual({}); 88 | }); 89 | 90 | it('`false` should not traverse children', () => { 91 | const files: string[] = []; 92 | astVisitor(ast, schemaComposer, { 93 | ROOT_TYPE: (info) => { 94 | // skip all from `query` 95 | if (info.isQuery()) return VISITOR_SKIP_CHILDREN; 96 | }, 97 | DIR: (info) => { 98 | // skip all except `auth` dir 99 | if (info.fieldName !== 'auth') return VISITOR_SKIP_CHILDREN; 100 | }, 101 | FILE: (info) => { 102 | files.push(`${info.fieldPath.join('.')}.${info.fieldName}`); 103 | }, 104 | }); 105 | expect(files.sort()).toEqual(['mutation.auth.login', 'mutation.auth.logout'].sort()); 106 | }); 107 | 108 | it('`any_node` should replace current node', () => { 109 | astVisitor(ast, schemaComposer, { 110 | ROOT_TYPE: () => { 111 | return { absPath: '', children: {}, kind: 'rootType', name: 'MOCK' }; 112 | }, 113 | }); 114 | 115 | expect(ast.children).toEqual({ 116 | mutation: { absPath: '', children: {}, kind: 'rootType', name: 'MOCK' }, 117 | query: { absPath: '', children: {}, kind: 'rootType', name: 'MOCK' }, 118 | }); 119 | }); 120 | 121 | describe('visitFn should have path & operation & name properties', () => { 122 | it('check ROOT_TYPE', () => { 123 | const nodes = [] as Array; 124 | astVisitor(ast, schemaComposer, { 125 | ROOT_TYPE: (info) => { 126 | nodes.push({ operation: info.operation, name: info.fieldName, path: info.fieldPath }); 127 | }, 128 | }); 129 | expect(sortBy(nodes, ['operation', 'name'])).toEqual([ 130 | { name: 'mutation', operation: 'mutation', path: [] }, 131 | { name: 'query', operation: 'query', path: [] }, 132 | ]); 133 | }); 134 | 135 | it('check DIR & FILE elements', () => { 136 | const nodes = [] as Array; 137 | astVisitor(ast, schemaComposer, { 138 | DIR: (info) => { 139 | nodes.push({ operation: info.operation, name: info.fieldName, path: info.fieldPath }); 140 | }, 141 | FILE: (info) => { 142 | nodes.push({ operation: info.operation, name: info.fieldName, path: info.fieldPath }); 143 | }, 144 | }); 145 | expect(sortBy(nodes, ['operation', 'name'])).toEqual([ 146 | { operation: 'mutation', name: 'auth', path: ['mutation'] }, 147 | { operation: 'mutation', name: 'create', path: ['mutation', 'user'] }, 148 | { operation: 'mutation', name: 'list', path: ['mutation', 'logs', 'nested'] }, 149 | { operation: 'mutation', name: 'login', path: ['mutation', 'auth'] }, 150 | { operation: 'mutation', name: 'logout', path: ['mutation', 'auth'] }, 151 | { operation: 'mutation', name: 'method', path: ['mutation', 'auth', 'nested'] }, 152 | { operation: 'mutation', name: 'nested', path: ['mutation', 'logs'] }, 153 | { operation: 'mutation', name: 'nested', path: ['mutation', 'auth'] }, 154 | { operation: 'mutation', name: 'update', path: ['mutation', 'user'] }, 155 | { operation: 'mutation', name: 'user', path: ['mutation'] }, 156 | { operation: 'query', name: 'auth', path: ['query'] }, 157 | { operation: 'query', name: 'city', path: ['query', 'me', 'address'] }, 158 | { operation: 'query', name: 'extendedData', path: ['query', 'user'] }, 159 | { operation: 'query', name: 'field', path: ['query'] }, 160 | { operation: 'query', name: 'index', path: ['query', 'some'] }, 161 | { operation: 'query', name: 'isLoggedIn', path: ['query', 'auth'] }, 162 | { operation: 'query', name: 'me', path: ['query'] }, 163 | { operation: 'query', name: 'method', path: ['query', 'auth', 'nested'] }, 164 | { operation: 'query', name: 'name', path: ['query', 'me'] }, 165 | { operation: 'query', name: 'nested', path: ['query', 'some'] }, 166 | { operation: 'query', name: 'nested', path: ['query', 'auth'] }, 167 | { operation: 'query', name: 'roles', path: ['query', 'user'] }, 168 | { operation: 'query', name: 'street', path: ['query', 'me', 'address'] }, 169 | { operation: 'query', name: 'user', path: ['query'] }, 170 | ]); 171 | }); 172 | }); 173 | 174 | it('try to wrap all mutations', async () => { 175 | const logs: any[] = []; 176 | astVisitor(ast, schemaComposer, { 177 | ROOT_TYPE: (node) => { 178 | if (!node.isMutation()) { 179 | return VISITOR_SKIP_CHILDREN; 180 | } 181 | }, 182 | FILE: (node) => { 183 | const currentResolve = node.fieldConfig?.resolve; 184 | if (currentResolve) { 185 | const description = node.fieldConfig?.description; 186 | node.fieldConfig.resolve = (s: any, a: any, c: any, i: any) => { 187 | logs.push({ 188 | description, 189 | args: a, 190 | }); 191 | return currentResolve(s, a, c, i); 192 | }; 193 | } 194 | }, 195 | }); 196 | const schema = astToSchema(ast).buildSchema(); 197 | const result = await graphql({ 198 | schema, 199 | source: ` 200 | mutation { 201 | auth { 202 | login(email: "a@b.c", password: "123") 203 | } 204 | } 205 | `, 206 | }); 207 | expect(result).toEqual({ data: { auth: { login: true } } }); 208 | expect(logs).toEqual([ 209 | { 210 | description: 'Login operation', 211 | args: { email: 'a@b.c', password: '123' }, 212 | }, 213 | ]); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /src/__tests__/directoryToAst-test.ts: -------------------------------------------------------------------------------- 1 | import { directoryToAst } from '../directoryToAst'; 2 | 3 | describe('directoryToAst()', () => { 4 | describe('Schema ./__testSchema__', () => { 5 | it('should return root types', () => { 6 | const ast = directoryToAst(module, { rootDir: './__testSchema__' }); 7 | expect(Object.keys(ast.children)).toEqual(expect.arrayContaining(['query', 'mutation'])); 8 | }); 9 | 10 | it('query has proper values', () => { 11 | const ast = directoryToAst(module, { rootDir: './__testSchema__' }); 12 | expect(ast.children.query).toMatchSnapshot({ 13 | name: 'query', 14 | kind: 'rootType', 15 | absPath: expect.any(String), 16 | children: { 17 | auth: { 18 | kind: 'dir', 19 | name: 'auth', 20 | absPath: expect.any(String), 21 | children: { 22 | isLoggedIn: expect.objectContaining({ kind: 'file' }), 23 | nested: expect.objectContaining({ kind: 'dir' }), 24 | }, 25 | namespaceConfig: expect.objectContaining({ kind: 'file' }), 26 | }, 27 | field: expect.objectContaining({ kind: 'file' }), 28 | me: { 29 | absPath: expect.any(String), 30 | children: { 31 | 'address.city': expect.objectContaining({ kind: 'file' }), 32 | 'address.street': expect.objectContaining({ kind: 'file' }), 33 | name: expect.objectContaining({ kind: 'file' }), 34 | }, 35 | namespaceConfig: { 36 | kind: 'file', 37 | name: 'index', 38 | absPath: expect.any(String), 39 | code: expect.any(Object), 40 | fieldConfig: expect.any(Object), 41 | }, 42 | }, 43 | 'some.nested': expect.objectContaining({ kind: 'file' }), 44 | 'some.index': expect.any(Object), 45 | user: { 46 | absPath: expect.any(String), 47 | children: { 48 | extendedData: expect.any(Object), 49 | roles: expect.any(Object), 50 | }, 51 | namespaceConfig: expect.any(Object), 52 | }, 53 | }, 54 | namespaceConfig: expect.any(Object), 55 | }); 56 | }); 57 | 58 | it('mutation has proper values', () => { 59 | const ast = directoryToAst(module, { rootDir: './__testSchema__' }); 60 | expect(ast.children.mutation).toMatchSnapshot({ 61 | name: 'mutation', 62 | kind: 'rootType', 63 | absPath: expect.any(String), 64 | children: { 65 | auth: { 66 | absPath: expect.any(String), 67 | children: { 68 | login: expect.objectContaining({ kind: 'file' }), 69 | logout: expect.objectContaining({ kind: 'file' }), 70 | nested: expect.objectContaining({ kind: 'dir' }), 71 | }, 72 | namespaceConfig: expect.objectContaining({ kind: 'file' }), 73 | }, 74 | user: { 75 | absPath: expect.any(String), 76 | children: { 77 | create: expect.objectContaining({ kind: 'file' }), 78 | update: expect.objectContaining({ kind: 'file' }), 79 | }, 80 | }, 81 | 'logs.nested': { 82 | absPath: expect.any(String), 83 | children: { 84 | list: expect.objectContaining({ kind: 'file' }), 85 | }, 86 | }, 87 | }, 88 | }); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/astMerge.ts: -------------------------------------------------------------------------------- 1 | import { AstRootNode, AstRootTypeNode, RootTypeNames, AstDirChildren } from './directoryToAst'; 2 | 3 | export function astMerge(...asts: Array): AstRootNode { 4 | const mergedAST = { 5 | kind: 'root', 6 | name: 'merged', 7 | absPath: 'merged', 8 | children: {}, 9 | } as AstRootNode; 10 | 11 | asts.forEach((ast) => { 12 | mergedAST.name += `; ${ast.name}`; 13 | mergedAST.absPath += `; ${ast.absPath}`; 14 | 15 | // merge rootTypes 16 | Object.keys(ast.children).forEach((key) => { 17 | const rootName = key as RootTypeNames; 18 | const rootTypeNode = ast.children[rootName] as AstRootTypeNode; 19 | 20 | let mergedRootTypeAST = mergedAST.children[rootName]; 21 | if (!mergedRootTypeAST) { 22 | mergedRootTypeAST = { 23 | kind: 'rootType', 24 | name: rootTypeNode.name, 25 | absPath: 'merged', 26 | children: {}, 27 | } as AstRootTypeNode; 28 | mergedAST.children[rootName] = mergedRootTypeAST; 29 | } 30 | mergedRootTypeAST.absPath += `; ${rootTypeNode.absPath}`; 31 | 32 | // maybe in future namespaceConfig will be refactored 33 | // but now it just take last one 34 | if (rootTypeNode.namespaceConfig) { 35 | mergedRootTypeAST.namespaceConfig = rootTypeNode.namespaceConfig; 36 | } 37 | 38 | mergedRootTypeAST.children = mergeChildren(mergedRootTypeAST.children, rootTypeNode.children); 39 | }); 40 | }); 41 | 42 | return mergedAST; 43 | } 44 | 45 | function mergeChildren(target: AstDirChildren, source: AstDirChildren): AstDirChildren { 46 | const result = { ...target }; 47 | Object.keys(source).forEach((key) => { 48 | const targetChild = target[key]; 49 | const sourceChild = source[key]; 50 | if (!targetChild) { 51 | // add new key from source 52 | result[key] = sourceChild; 53 | } else if (targetChild.kind === 'dir') { 54 | if (sourceChild.kind === 'dir') { 55 | // merge dirs 56 | const mergedDirNode = { 57 | ...targetChild, 58 | absPath: `merged; ${targetChild.absPath}; ${sourceChild.absPath}`, 59 | children: mergeChildren(targetChild.children, sourceChild.children), 60 | }; 61 | if (sourceChild.namespaceConfig) { 62 | mergedDirNode.namespaceConfig = sourceChild.namespaceConfig; 63 | } 64 | result[key] = mergedDirNode; 65 | } else if (sourceChild.kind === 'file') { 66 | // replace dir by file from source 67 | result[key] = sourceChild; 68 | } 69 | } else if (targetChild.kind === 'file') { 70 | // replace file by any source type 71 | result[key] = sourceChild; 72 | } 73 | }); 74 | 75 | return result; 76 | } 77 | -------------------------------------------------------------------------------- /src/astToSchema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SchemaComposer, 3 | ObjectTypeComposer, 4 | upperFirst, 5 | ObjectTypeComposerFieldConfig, 6 | isOutputTypeDefinitionString, 7 | isWrappedTypeNameString, 8 | inspect, 9 | } from 'graphql-compose'; 10 | import { AstRootNode, AstRootTypeNode, AstDirNode, AstFileNode } from './directoryToAst'; 11 | import dedent from 'dedent'; 12 | import { GraphQLObjectType } from 'graphql'; 13 | 14 | export interface AstToSchemaOptions { 15 | /** 16 | * Pass here already existed SchemaComposer instance 17 | * which already contains some types (eg with custom Scalars). 18 | * 19 | * Or new SchemaComposer instance will be created. 20 | */ 21 | schemaComposer?: SchemaComposer; 22 | prefix?: string; 23 | suffix?: string; 24 | } 25 | 26 | /** 27 | * Transform AST to GraphQL Schema. 28 | * 29 | * @example 30 | * // Scan some directory for getting Schema entrypoints as AST nodes. 31 | * const ast = directoryToAst(module); 32 | * 33 | * // [Optional] Combining severals ast into source 34 | * // Useful if some sub-schemas are delivered via packages 35 | * // or created n separate directories. 36 | * const newAST = astMerge(ast, ast1, ast2, ...); 37 | * 38 | * // [Optional] Some `ast` modifications 39 | * // Useful for writing some middlewares 40 | * // which transform FieldConfigs entrypoints. 41 | * astVisitor(newAST, visitorFns); 42 | * 43 | * // Create SchemaComposer instance with all populated types & fields 44 | * // It provides declarative programmatic access to modify you schema 45 | * const schemaComposer = astToSchema(newAST, opts); 46 | * 47 | * // Create GraphQLSchema instance which is ready for runtime. 48 | * const schema = schemaComposer.buildSchema();; 49 | */ 50 | export function astToSchema( 51 | ast: AstRootNode, 52 | opts: AstToSchemaOptions = {} 53 | ): SchemaComposer { 54 | let sc: SchemaComposer; 55 | 56 | if (opts?.schemaComposer) { 57 | if (!opts.schemaComposer) { 58 | throw new Error(dedent` 59 | Provided option 'schemaComposer' should be an instance of SchemaComposer class from 'graphql-compose' package. 60 | Received: 61 | ${inspect(opts.schemaComposer)} 62 | `); 63 | } 64 | sc = opts.schemaComposer; 65 | } else { 66 | sc = new SchemaComposer(); 67 | } 68 | 69 | if (ast.children.query) populateRoot(sc, 'Query', ast.children.query, opts); 70 | if (ast.children.mutation) populateRoot(sc, 'Mutation', ast.children.mutation, opts); 71 | if (ast.children.subscription) populateRoot(sc, 'Subscription', ast.children.subscription, opts); 72 | 73 | return sc; 74 | } 75 | 76 | function populateRoot( 77 | sc: SchemaComposer, 78 | rootName: 'Query' | 'Mutation' | 'Subscription', 79 | astRootNode: AstRootTypeNode, 80 | opts?: AstToSchemaOptions 81 | ) { 82 | const tc = sc[rootName]; 83 | Object.keys(astRootNode.children).forEach((key) => { 84 | createFields(sc, astRootNode.children[key], tc, rootName, opts || {}); 85 | }); 86 | } 87 | 88 | export function createFields( 89 | sc: SchemaComposer, 90 | ast: AstDirNode | AstFileNode | void, 91 | parent: ObjectTypeComposer, 92 | pathPrefix: string, 93 | opts: AstToSchemaOptions = {} 94 | ): void { 95 | if (!ast) return; 96 | 97 | const name = ast.name; 98 | if (!/^[._a-zA-Z0-9]+$/.test(name)) { 99 | throw new Error( 100 | `You provide incorrect ${ 101 | ast.kind === 'file' ? 'file' : 'directory' 102 | } name '${name}', it should meet RegExp(/^[._a-zA-Z0-9]+$/) for '${ast.absPath}'` 103 | ); 104 | } 105 | 106 | if (ast.kind === 'file') { 107 | parent.addNestedFields({ 108 | [name]: ast.fieldConfig, 109 | }); 110 | return; 111 | } 112 | 113 | if (ast.kind === 'dir') { 114 | const typename = getTypename(ast, pathPrefix, opts); 115 | let fc: ObjectTypeComposerFieldConfig; 116 | if (ast.namespaceConfig) { 117 | fc = prepareNamespaceFieldConfig(sc, ast.namespaceConfig, typename); 118 | } else { 119 | fc = { type: sc.createObjectTC(typename) }; 120 | } 121 | 122 | parent.addNestedFields({ 123 | [name]: { 124 | resolve: (source) => source, 125 | ...fc, 126 | }, 127 | }); 128 | 129 | const pathPrefixForChild = getTypename(ast, pathPrefix, {}); 130 | Object.keys(ast.children).forEach((key) => { 131 | createFields(sc, ast.children[key], fc.type as any, pathPrefixForChild, opts); 132 | }); 133 | } 134 | } 135 | 136 | function getTypename( 137 | ast: AstDirNode | AstFileNode, 138 | pathPrefix: string, 139 | opts: AstToSchemaOptions 140 | ): string { 141 | const name = ast.name; 142 | 143 | let typename = pathPrefix; 144 | if (name.indexOf('.') !== -1) { 145 | const namesArray = name.split('.'); 146 | 147 | if (namesArray.some((n) => !n)) { 148 | throw new Error( 149 | `Field name '${ast.name}' contains dots in the wrong place for '${ast.absPath}'!` 150 | ); 151 | } 152 | 153 | typename += namesArray.reduce((prev, current) => { 154 | return prev + upperFirst(current); 155 | }, ''); 156 | } else { 157 | typename += upperFirst(name); 158 | } 159 | 160 | if (opts.prefix) typename = `${opts.prefix}${typename}`; 161 | if (opts.suffix) typename += opts.suffix; 162 | return typename; 163 | } 164 | 165 | function prepareNamespaceFieldConfig( 166 | sc: SchemaComposer, 167 | ast: AstFileNode, 168 | typename: string 169 | ): ObjectTypeComposerFieldConfig { 170 | const fc = ast.fieldConfig as any; 171 | 172 | if (!fc.type) { 173 | fc.type = sc.createObjectTC(typename); 174 | } else { 175 | if (typeof fc.type === 'string') { 176 | if (!isOutputTypeDefinitionString(fc.type) && !isWrappedTypeNameString(fc.type)) { 177 | throw new Error(dedent` 178 | You provide incorrect output type definition: 179 | ${fc.type} 180 | It must be valid TypeName or output type SDL definition: 181 | 182 | Eg. 183 | type Payload { me: String } 184 | OR 185 | Payload 186 | `); 187 | } 188 | } else if ( 189 | !(fc.type instanceof ObjectTypeComposer) && 190 | !(fc.type instanceof GraphQLObjectType) 191 | ) { 192 | throw new Error(dedent` 193 | You provide some strange value as 'type': 194 | ${inspect(fc.type)} 195 | `); 196 | } 197 | fc.type = sc.createObjectTC(fc.type); 198 | } 199 | 200 | if (!fc.resolve) { 201 | fc.resolve = () => ({}); 202 | } 203 | 204 | return fc; 205 | } 206 | -------------------------------------------------------------------------------- /src/astVisitor.ts: -------------------------------------------------------------------------------- 1 | import { SchemaComposer } from 'graphql-compose'; 2 | import { AstRootTypeNode, AstDirNode, AstFileNode, AstRootNode } from './directoryToAst'; 3 | import { VisitInfo } from './VisitInfo'; 4 | 5 | /** 6 | * Do not traverse children, and go to next sibling. 7 | */ 8 | export const VISITOR_SKIP_CHILDREN = false; 9 | 10 | /** 11 | * Remove Node from AST and do not traverse children. 12 | */ 13 | export const VISITOR_REMOVE_NODE = null; 14 | 15 | export type VisitorEmptyResult = 16 | | void // just move further 17 | | typeof VISITOR_REMOVE_NODE 18 | | typeof VISITOR_SKIP_CHILDREN; 19 | 20 | export type VisitKindFn = ( 21 | /** Info & helper functions from visitor during traversing AST tree */ 22 | info: VisitInfo 23 | ) => VisitorEmptyResult | TNode; 24 | 25 | /** 26 | * Functions for every type of AST nodes which will be called by visitor. 27 | */ 28 | export type AstVisitor = { 29 | DIR?: VisitKindFn; 30 | FILE?: VisitKindFn; 31 | ROOT_TYPE?: VisitKindFn; 32 | }; 33 | 34 | /** 35 | * Traverse AST for applying modifications to DIRs, FILEs & ROOT_TYPEs 36 | * Useful for writing middlewares which transform FieldConfigs entrypoints. 37 | * 38 | * @example 39 | * const ast = directoryToAst(module); 40 | * astVisitor(ast, schemaComposer, { 41 | * ROOT_TYPE: () => {}, // run for query, mutation, subscription 42 | * DIR: () => {}, // executes on visiting DIR node 43 | * FILE: () => {}, // executes on visiting FILE node 44 | * }); 45 | */ 46 | export function astVisitor( 47 | ast: AstRootNode, 48 | schemaComposer: SchemaComposer, 49 | visitor: AstVisitor 50 | ): void { 51 | (Object.keys(ast.children) as Array).forEach((operation) => { 52 | const node = ast.children[operation]; 53 | if (!node) return; 54 | 55 | visitNode( 56 | new VisitInfo({ 57 | node, 58 | nodeParent: ast, 59 | fieldName: operation, 60 | fieldPath: [], 61 | operation, 62 | schemaComposer, 63 | }), 64 | visitor 65 | ); 66 | }); 67 | } 68 | 69 | export function visitNode( 70 | info: VisitInfo, 71 | visitor: AstVisitor 72 | ): void { 73 | let result: VisitorEmptyResult | AstDirNode | AstFileNode | AstRootTypeNode; 74 | if (info.node.kind === 'dir') { 75 | if (visitor.DIR) result = visitor.DIR(info as VisitInfo); 76 | } else if (info.node.kind === 'file') { 77 | if (visitor.FILE) result = visitor.FILE(info as VisitInfo); 78 | } else if (info.node.kind === 'rootType') { 79 | if (visitor.ROOT_TYPE) result = visitor.ROOT_TYPE(info as VisitInfo); 80 | } 81 | 82 | if (result === VISITOR_REMOVE_NODE) { 83 | // `null` - means remove node from Ast and do not traverse children 84 | delete (info.nodeParent.children as any)[info.node.name]; 85 | return; 86 | } else if (result === VISITOR_SKIP_CHILDREN) { 87 | // `false` - do not traverse children 88 | return; 89 | } else if (result) { 90 | // replace node 91 | (info.nodeParent.children as any)[info.node.name] = result; 92 | } else { 93 | // `undefined` - just move further 94 | result = info.node; 95 | } 96 | 97 | if (result.kind === 'dir' || result.kind === 'rootType') { 98 | forEachKey(result.children, (childNode: AstDirNode | AstFileNode, fieldName) => { 99 | visitNode( 100 | new VisitInfo({ 101 | node: childNode, 102 | nodeParent: result as AstDirNode, 103 | fieldName, 104 | fieldPath: [...info.fieldPath, info.fieldName], 105 | operation: info.operation, 106 | schemaComposer: info.schemaComposer, 107 | }), 108 | visitor 109 | ); 110 | }); 111 | } 112 | } 113 | 114 | export function forEachKey( 115 | obj: Record, 116 | callback: (value: V, key: string) => void 117 | ): void { 118 | Object.keys(obj).forEach((key) => { 119 | callback(obj[key], key); 120 | }); 121 | } 122 | -------------------------------------------------------------------------------- /src/directoryToAst.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { 3 | dedent, 4 | isComposeOutputType, 5 | isFunction, 6 | isSomeOutputTypeDefinitionString, 7 | isWrappedTypeNameString, 8 | Resolver, 9 | } from 'graphql-compose'; 10 | import { join, resolve, dirname, basename } from 'path'; 11 | import { FieldConfig, NamespaceConfig } from './typeDefs'; 12 | 13 | export type AstNodeKinds = 'rootType' | 'dir' | 'file' | 'root'; 14 | 15 | export interface AstBaseNode { 16 | kind: AstNodeKinds; 17 | name: string; 18 | absPath: string; 19 | } 20 | 21 | export interface AstRootTypeNode extends AstBaseNode { 22 | kind: 'rootType'; 23 | children: AstDirChildren; 24 | namespaceConfig?: AstFileNode; 25 | } 26 | 27 | export type AstDirChildren = { 28 | [key: string]: AstDirNode | AstFileNode; 29 | }; 30 | 31 | export interface AstDirNode extends AstBaseNode { 32 | kind: 'dir'; 33 | children: AstDirChildren; 34 | namespaceConfig?: AstFileNode; 35 | } 36 | 37 | export interface AstFileNode extends AstBaseNode { 38 | kind: 'file'; 39 | code: { 40 | default?: FieldConfig | NamespaceConfig; 41 | }; 42 | /** 43 | * This FieldConfig loaded from `code` and validated. 44 | * This property is used by ast transformers and stores the last version of modified config. 45 | * This value will be used by astToSchema method. 46 | */ 47 | fieldConfig: FieldConfig; 48 | } 49 | 50 | export type RootTypeNames = 'query' | 'mutation' | 'subscription'; 51 | 52 | export interface AstRootNode extends AstBaseNode { 53 | kind: 'root'; 54 | children: Record; 55 | } 56 | 57 | export const defaultOptions: DirectoryToAstOptions = { 58 | extensions: ['js', 'ts'], 59 | }; 60 | 61 | export interface DirectoryToAstOptions { 62 | /** Scan relative directory to `module` which was provided as a first arg to directoryToAst method. By default `.` */ 63 | rootDir?: string; 64 | /** Which file extensions should be loaded. By default: .js, .ts */ 65 | extensions?: string[]; 66 | /** Regexp or custom function which determines should be loaded file/dir or not */ 67 | include?: RegExp | ((path: string, kind: 'dir' | 'file', filename: string) => boolean); 68 | /** Regexp or custom function which determines should file/dir be skipped. It take precedence over `include` option. */ 69 | exclude?: RegExp | ((path: string, kind: 'dir' | 'file', filename: string) => boolean); 70 | } 71 | 72 | /** 73 | * Traverses directories and construct AST for your graphql entrypoints. 74 | * 75 | * @param m – is a NodeJS Module which provides a way to load modules from scanned dir in the regular nodejs way 76 | * @param options – set of options which helps to customize rules of what files/dirs should be loaded or not 77 | */ 78 | export function directoryToAst( 79 | m: NodeModule, 80 | options: DirectoryToAstOptions = defaultOptions 81 | ): AstRootNode { 82 | // @ts-ignore 83 | if (options?.relativePath) { 84 | throw new Error( 85 | 'graphql-compose-modules: `relativePath` option is deprecated use `rootDir` instead' 86 | ); 87 | } 88 | 89 | // if no path was passed in, assume the equivalent of __dirname from caller 90 | // otherwise, resolve path relative to the equivalent of __dirname 91 | const schemaPath = options?.rootDir 92 | ? resolve(dirname(m.filename), options.rootDir) 93 | : dirname(m.filename); 94 | 95 | // setup default options 96 | Object.keys(defaultOptions).forEach((prop) => { 97 | if (typeof (options as any)[prop] === 'undefined') { 98 | (options as any)[prop] = (defaultOptions as any)[prop]; 99 | } 100 | }); 101 | 102 | const result = { 103 | kind: 'root', 104 | name: basename(schemaPath), 105 | absPath: schemaPath, 106 | children: {}, 107 | } as AstRootNode; 108 | 109 | fs.readdirSync(schemaPath).forEach((filename) => { 110 | const absPath = join(schemaPath, filename); 111 | 112 | if (fs.statSync(absPath).isDirectory()) { 113 | const dirName = filename; 114 | const re = /^(query|mutation|subscription)(\.(.*))?$/i; 115 | const found = dirName.match(re); 116 | if (found) { 117 | const opType = found[1].toLowerCase() as keyof AstRootNode['children']; 118 | let rootTypeAst = result.children[opType]; 119 | if (!rootTypeAst) 120 | rootTypeAst = { 121 | kind: 'rootType', 122 | name: opType, 123 | absPath, 124 | children: {}, 125 | } as AstRootTypeNode; 126 | 127 | const astDir = getAstForDir(m, absPath, options); 128 | if (astDir) { 129 | const subField = found[3]; // any part after dot (eg for `query.me` will be `me`) 130 | if (subField) { 131 | rootTypeAst.children[subField] = { 132 | ...astDir, 133 | name: subField, 134 | absPath, 135 | }; 136 | } else { 137 | rootTypeAst.children = astDir.children; 138 | if (astDir.namespaceConfig) { 139 | rootTypeAst.namespaceConfig = astDir.namespaceConfig; 140 | } 141 | } 142 | result.children[opType] = rootTypeAst; 143 | } 144 | } 145 | } 146 | }); 147 | 148 | return result; 149 | } 150 | 151 | export function getAstForDir( 152 | m: NodeModule, 153 | absPath: string, 154 | options: DirectoryToAstOptions = defaultOptions 155 | ): AstDirNode | void { 156 | const name = basename(absPath); 157 | 158 | if (!checkInclusion(absPath, 'dir', name, options)) return; 159 | 160 | const result: AstDirNode = { 161 | kind: 'dir', 162 | absPath, 163 | name, 164 | children: {}, 165 | }; 166 | 167 | // get the path of each file in specified directory, append to current tree node, recurse 168 | fs.readdirSync(absPath).forEach((filename) => { 169 | const absFilePath = join(absPath, filename); 170 | 171 | const stat = fs.statSync(absFilePath); 172 | if (stat.isDirectory()) { 173 | // this node is a directory; recurse 174 | if (result.children[filename]) { 175 | throw new Error( 176 | `You have a folder and file with same name "${filename}" by the following path ${absPath}. Please remove one of them.` 177 | ); 178 | } 179 | const astDir = getAstForDir(m, absFilePath, options); 180 | if (astDir) { 181 | result.children[filename] = astDir; 182 | } 183 | } else if (stat.isFile()) { 184 | // this node is a file 185 | const fileAst = getAstForFile(m, absFilePath, options); 186 | if (fileAst) { 187 | if (fileAst.name === 'index') { 188 | result.namespaceConfig = fileAst; 189 | } else if (result.children[fileAst.name]) { 190 | throw new Error( 191 | `You have a folder and file with same name "${fileAst.name}" by the following path ${absPath}. Please remove one of them.` 192 | ); 193 | } else { 194 | result.children[fileAst.name] = fileAst; 195 | } 196 | } 197 | } 198 | }); 199 | 200 | return result; 201 | } 202 | 203 | export function getAstForFile( 204 | m: NodeModule, 205 | absPath: string, 206 | options: DirectoryToAstOptions = defaultOptions 207 | ): AstFileNode | void { 208 | const filename = basename(absPath); 209 | if (absPath !== m.filename && checkInclusion(absPath, 'file', filename, options)) { 210 | // module name shouldn't include file extension 211 | const moduleName = filename.substring(0, filename.lastIndexOf('.')); 212 | // namespace configs may not have `type` property 213 | const checkType = moduleName !== 'index'; 214 | const code = m.require(absPath); 215 | const fieldConfig = prepareFieldConfig(code, absPath, checkType); 216 | return { 217 | kind: 'file', 218 | name: moduleName, 219 | absPath, 220 | code, 221 | fieldConfig, 222 | }; 223 | } 224 | } 225 | 226 | function checkInclusion( 227 | absPath: string, 228 | kind: 'dir' | 'file', 229 | filename: string, 230 | options: DirectoryToAstOptions 231 | ): boolean { 232 | // Skip dir/files started from double underscore 233 | if (/^__.*/i.test(filename)) { 234 | return false; 235 | } 236 | 237 | // Skip dir/files started from dot 238 | if (/^\..*/i.test(filename)) { 239 | return false; 240 | } 241 | 242 | if (kind === 'file') { 243 | if ( 244 | // Verify file has valid extension 245 | !new RegExp('\\.(' + (options?.extensions || ['js', 'ts']).join('|') + ')$', 'i').test( 246 | filename 247 | ) || 248 | // Hardcoded skip file extensions 249 | // typescript definition files 250 | new RegExp('(\\.d\\.ts)$', 'i').test(filename) || 251 | // test files 252 | new RegExp('(\\.spec\\.(ts|js))$', 'i').test(filename) 253 | ) { 254 | return false; 255 | } 256 | } 257 | 258 | if (options.include) { 259 | if (options.include instanceof RegExp) { 260 | // if options.include is a RegExp, evaluate it and make sure the path passes 261 | if (!options.include.test(absPath)) return false; 262 | } else if (typeof options.include === 'function') { 263 | // if options.include is a function, evaluate it and make sure the path passes 264 | if (!options.include(absPath, kind, filename)) return false; 265 | } 266 | } 267 | 268 | if (options.exclude) { 269 | if (options.exclude instanceof RegExp) { 270 | // if options.exclude is a RegExp, evaluate it and make sure the path doesn't pass 271 | if (options.exclude.test(absPath)) return false; 272 | } else if (typeof options.exclude === 'function') { 273 | // if options.exclude is a function, evaluate it and make sure the path doesn't pass 274 | if (options.exclude(absPath, kind, filename)) return false; 275 | } 276 | } 277 | 278 | return true; 279 | } 280 | 281 | function prepareFieldConfig(code: any, absPath: string, checkType = true): FieldConfig { 282 | const _fc = code?.default; 283 | if (!_fc || typeof _fc !== 'object') { 284 | throw new Error(dedent` 285 | GraphQL entrypoint MUST return FieldConfig as default export in '${absPath}'. 286 | Eg: 287 | export default { 288 | type: 'String', 289 | resolve: () => Date.now(), 290 | }; 291 | `); 292 | } 293 | 294 | let fc: FieldConfig; 295 | if (code.default instanceof Resolver) { 296 | fc = (code.default as Resolver).getFieldConfig() as any; 297 | } else { 298 | // recreate object for immutability purposes (do not change object in module definition) 299 | // NB. I don't know should we here recreate (args, extensions) but let's keep them as is for now. 300 | fc = { ...code.default }; 301 | } 302 | 303 | if (checkType) { 304 | if (!fc.type || !isSomeOutputTypeDefinition(fc.type)) { 305 | throw new Error(dedent` 306 | Module MUST return FieldConfig with correct 'type: xxx' property in '${absPath}'. 307 | Eg: 308 | export default { 309 | type: 'String' 310 | }; 311 | `); 312 | } 313 | } 314 | 315 | if (fc.resolve && typeof fc.resolve !== 'function') { 316 | throw new Error( 317 | `Cannot load entrypoint config from ${absPath}. 'resolve' property must be a function or undefined.` 318 | ); 319 | } 320 | 321 | return fc; 322 | } 323 | 324 | function isSomeOutputTypeDefinition(type: any): boolean { 325 | if (typeof type === 'string') { 326 | // type: 'String' 327 | return isSomeOutputTypeDefinitionString(type) || isWrappedTypeNameString(type); 328 | } else if (Array.isArray(type)) { 329 | // type: ['String'] 330 | return isSomeOutputTypeDefinition(type[0]); 331 | } else if (isFunction(type)) { 332 | // pass thunked type without internal checks 333 | return true; 334 | } else { 335 | // type: 'type User { name: String }' 336 | return isComposeOutputType(type); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { directoryToAst, DirectoryToAstOptions } from './directoryToAst'; 2 | import { astToSchema, AstToSchemaOptions } from './astToSchema'; 3 | import { SchemaComposer } from 'graphql-compose'; 4 | import { GraphQLSchema } from 'graphql'; 5 | 6 | export interface BuildOptions extends DirectoryToAstOptions, AstToSchemaOptions {} 7 | 8 | /** 9 | * Traverses directories and return GraphQLSchema instance from `graphql-js`. 10 | * 11 | * @param m – is a NodeJS Module which provides a way to load modules from scanned dir in the regular nodejs way 12 | * @param options – set of options which helps to customize rules of what files/dirs should be loaded or not 13 | */ 14 | export function buildSchema(module: NodeModule, opts: BuildOptions = {}): GraphQLSchema { 15 | return loadSchemaComposer(module, opts).buildSchema(); 16 | } 17 | 18 | /** 19 | * Traverses directories and return SchemaComposer instance from `graphql-compose`. 20 | * 21 | * @param m – is a NodeJS Module which provides a way to load modules from scanned dir in the regular nodejs way 22 | * @param options – set of options which helps to customize rules of what files/dirs should be loaded or not 23 | */ 24 | export function loadSchemaComposer( 25 | module: NodeModule, 26 | opts: BuildOptions 27 | ): SchemaComposer { 28 | const ast = directoryToAst(module, opts); 29 | const sc = astToSchema(ast, opts); 30 | return sc; 31 | } 32 | 33 | export { 34 | directoryToAst, 35 | DirectoryToAstOptions, 36 | AstNodeKinds, 37 | AstBaseNode, 38 | AstRootTypeNode, 39 | AstDirNode, 40 | AstFileNode, 41 | AstRootNode, 42 | } from './directoryToAst'; 43 | export { astToSchema, AstToSchemaOptions } from './astToSchema'; 44 | export * from './testHelpers'; 45 | export * from './typeDefs'; 46 | 47 | export { astVisitor, VISITOR_REMOVE_NODE, VISITOR_SKIP_CHILDREN, AstVisitor } from './astVisitor'; 48 | 49 | export { VisitInfo } from './VisitInfo'; 50 | 51 | export { astMerge } from './astMerge'; 52 | -------------------------------------------------------------------------------- /src/testHelpers.ts: -------------------------------------------------------------------------------- 1 | import { graphql, GraphQLSchema, ExecutionResult, GraphQLError } from 'graphql'; 2 | import { 3 | SchemaComposer, 4 | Resolver, 5 | ObjectTypeComposerFieldConfigAsObjectDefinition, 6 | inspect, 7 | SchemaPrinterOptions, 8 | } from 'graphql-compose'; 9 | 10 | const FIELD = 'field'; 11 | 12 | interface RunQueryOpts { 13 | fc: ObjectTypeComposerFieldConfigAsObjectDefinition | Resolver; 14 | operation: string; 15 | variables?: Record; 16 | source?: Record; 17 | context?: Record; 18 | schemaComposer?: SchemaComposer; 19 | } 20 | 21 | export function testBuildSchema( 22 | fc: ObjectTypeComposerFieldConfigAsObjectDefinition | Resolver, 23 | schemaComposer?: SchemaComposer 24 | ): GraphQLSchema { 25 | const sc = schemaComposer || new SchemaComposer(); 26 | sc.Query.setField(FIELD, fc); 27 | return sc.buildSchema(); 28 | } 29 | 30 | function _getArgsForQuery( 31 | fc: ObjectTypeComposerFieldConfigAsObjectDefinition | Resolver, 32 | variables: Record = {}, 33 | schemaComposer?: SchemaComposer 34 | ): { 35 | queryVars: string; 36 | fieldVars: string; 37 | } { 38 | const sc = schemaComposer || new SchemaComposer(); 39 | sc.Query.setField(FIELD, fc); 40 | 41 | const varNames = Object.keys(variables); 42 | 43 | const argNames = sc.Query.getFieldArgNames(FIELD); 44 | if (argNames.length === 0 && varNames.length > 0) { 45 | throw new Error( 46 | `FieldConfig does not have any arguments. But in test you provided the following variables: ${inspect( 47 | variables 48 | )}` 49 | ); 50 | } 51 | 52 | varNames.forEach((varName) => { 53 | if (!argNames.includes(varName)) { 54 | throw new Error( 55 | `FieldConfig does not have '${varName}' argument. Available arguments: '${argNames.join( 56 | "', '" 57 | )}'.` 58 | ); 59 | } 60 | }); 61 | 62 | argNames.forEach((argName) => { 63 | if (sc.Query.isFieldArgNonNull(FIELD, argName)) { 64 | const val = variables[argName]; 65 | if (val === null || val === undefined) { 66 | throw new Error( 67 | `FieldConfig has required argument '${argName}'. But you did not provide it in your test via variables: '${inspect( 68 | variables 69 | )}'.` 70 | ); 71 | } 72 | } 73 | }); 74 | 75 | const queryVars = varNames 76 | .map((n) => `$${n}: ${String(sc.Query.getFieldArgType(FIELD, n))}`) 77 | .join(' '); 78 | const fieldVars = varNames.map((n) => `${n}: $${n}`).join(' '); 79 | 80 | return { 81 | queryVars: queryVars ? `(${queryVars})` : '', 82 | fieldVars: fieldVars ? `(${fieldVars})` : '', 83 | }; 84 | } 85 | 86 | export async function testOperation(opts: RunQueryOpts): Promise { 87 | const schema = testBuildSchema(opts.fc, opts.schemaComposer); 88 | 89 | const res = await graphql({ 90 | schema, 91 | source: opts.operation, 92 | rootValue: opts?.source, 93 | contextValue: opts?.context, 94 | variableValues: opts?.variables, 95 | }); 96 | return res; 97 | } 98 | 99 | export async function testOperationData( 100 | opts: Omit & { selectionSet: string } 101 | ): Promise | null> { 102 | const { selectionSet, ...restOpts } = opts; 103 | 104 | const ac = _getArgsForQuery(opts.fc, opts.variables, opts.schemaComposer); 105 | const res = await testOperation({ 106 | operation: ` 107 | query ${ac.queryVars} { 108 | field${ac.fieldVars} ${selectionSet.trim()} 109 | } 110 | `, 111 | ...restOpts, 112 | }); 113 | 114 | if (res.errors) { 115 | throw new Error((res?.errors?.[0] as any) || 'GraphQL Error'); 116 | } 117 | 118 | return res?.data?.field as any; 119 | } 120 | 121 | export async function testOperationErrors( 122 | opts: RunQueryOpts 123 | ): Promise { 124 | const res = await testOperation(opts); 125 | return res?.errors; 126 | } 127 | 128 | export function testSDL( 129 | opts: { 130 | fc: ObjectTypeComposerFieldConfigAsObjectDefinition | Resolver; 131 | schemaComposer?: SchemaComposer; 132 | deep?: boolean; 133 | } & SchemaPrinterOptions 134 | ): string { 135 | const sc = opts.schemaComposer || new SchemaComposer(); 136 | sc.Query.setField(FIELD, opts.fc); 137 | sc.buildSchema(); 138 | return sc.Query.toSDL({ 139 | ...opts, 140 | deep: opts.deep ?? true, 141 | omitDescriptions: true, 142 | omitSpecifiedByUrl: true, 143 | }); 144 | } 145 | -------------------------------------------------------------------------------- /src/typeDefs.ts: -------------------------------------------------------------------------------- 1 | import { ObjectTypeComposerFieldConfigAsObjectDefinition } from 'graphql-compose'; 2 | 3 | /** 4 | * General type annotation for default export of your modules inside schema directory. 5 | * 6 | * @example schema/query/ping.ts 7 | * import { FieldConfig } from 'graphql-compose-modules'; 8 | * 9 | * export default { 10 | * type: 'String', 11 | * resolve: () => 'pong', 12 | * } as FieldConfig; 13 | */ 14 | export type FieldConfig< 15 | TContext = any, 16 | TArgs = any, 17 | TSource = any 18 | > = ObjectTypeComposerFieldConfigAsObjectDefinition; 19 | 20 | /** 21 | * Specific type annotation for index.ts files in you folders. 22 | * index.ts files have specific purpose to extend or override 23 | * automatically generated ObjectType for directory. 24 | * 25 | * For example you have the following structure in `schema` folder: 26 | * query/ 27 | * viewer/ 28 | * index.ts <--- will extend/override definition of `viewer/` type 29 | * articles.ts <--- just add a field with name `articles` to `viewer/` type 30 | * 31 | * So the next code extends logic of `viewer/` type 32 | * - provide custom name for generated type (instead of `Viewer` will be `AuthorizedUser`) 33 | * - if `context.user` is empty then return error in runtime to the client 34 | * @example query/viewer/index.ts 35 | * import { NamespaceConfig } from 'graphql-compose-modules'; 36 | * 37 | * export default { 38 | * type: 'AuthorizedUser', 39 | * resolve: (parent, args, context, info) => { 40 | * if (!context.user) throw new Error('You should be authenticated user to access `viewer` field'); 41 | * return {}; 42 | * }, 43 | * } as NamespaceConfig; 44 | */ 45 | export type NamespaceConfig = Partial< 46 | ObjectTypeComposerFieldConfigAsObjectDefinition 47 | >; 48 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "./src", 4 | "outDir": "./lib", 5 | "noEmit": false, 6 | "target": "es5", 7 | "module": "commonjs", 8 | "sourceMap": true, 9 | "declaration": true, 10 | "declarationMap": true, 11 | "skipLibCheck": false, 12 | "strict": false, // <----- FIX ME 13 | "lib": ["es2017"], 14 | "moduleResolution": "node", 15 | "esModuleInterop": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "types": ["node"] 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["**/__tests__"] 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "rootDir": "./", 9 | "lib": ["es2017"], 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "baseUrl": "./", 13 | "sourceMap": true, 14 | "declaration": true, 15 | "declarationMap": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "types": ["node", "jest"], 18 | "paths": { 19 | "graphql-compose-modules": ["./", "./src"] 20 | }, 21 | }, 22 | "include": ["src/**/*", "examples/**/*"], 23 | "exclude": ["./node_modules"] 24 | } 25 | --------------------------------------------------------------------------------