├── .mocharc.yml ├── .prettierrc ├── .prettierignore ├── .eslintignore ├── resources ├── eslint-internal-rules │ ├── package.json │ ├── index.js │ ├── README.md │ └── no-dir-import.js ├── register.js ├── load-statically-from-npm.js ├── checkgit.sh ├── build-npm.js ├── utils.js └── gen-changelog.js ├── codecov.yml ├── .nycrc.yml ├── .gitignore ├── tsconfig.json ├── src ├── __tests__ │ ├── helpers │ │ └── koa-multer.ts │ ├── usage-test.ts │ └── http-test.ts ├── renderGraphiQL.ts └── index.ts ├── cspell.json ├── examples ├── index.ts ├── schema.ts ├── index_subscription.ts └── index_subscription_legacy.ts ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── package.json ├── README.md └── .eslintrc.yml /.mocharc.yml: -------------------------------------------------------------------------------- 1 | check-leaks: true 2 | require: 3 | - './resources/register.js' 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Copied from '.gitignore', please keep it in sync. 2 | /node_modules 3 | /coverage 4 | /npmDist -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Copied from '.gitignore', please keep it in sync. 2 | /node_modules 3 | /coverage 4 | /npmDist 5 | -------------------------------------------------------------------------------- /resources/eslint-internal-rules/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-graphql-internal", 3 | "version": "0.0.0" 4 | } 5 | -------------------------------------------------------------------------------- /resources/eslint-internal-rules/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | rules: { 5 | 'no-dir-import': require('./no-dir-import'), 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | require_ci_to_pass: yes 4 | 5 | parsers: 6 | javascript: 7 | enable_partials: yes 8 | 9 | comment: no 10 | coverage: 11 | status: 12 | project: 13 | default: 14 | target: auto 15 | -------------------------------------------------------------------------------- /.nycrc.yml: -------------------------------------------------------------------------------- 1 | all: true 2 | include: 3 | - 'src/' 4 | exclude: [] 5 | clean: true 6 | temp-directory: 'coverage' 7 | report-dir: 'coverage' 8 | skip-full: true 9 | reporter: [json, html, text] 10 | check-coverage: true 11 | branches: 80 12 | lines: 80 13 | functions: 80 14 | statements: 80 15 | -------------------------------------------------------------------------------- /resources/register.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { 4 | transformLoadFileStaticallyFromNPM, 5 | } = require('./load-statically-from-npm'); 6 | 7 | require('ts-node').register({ 8 | logError: true, 9 | transformers: () => ({ 10 | after: [transformLoadFileStaticallyFromNPM], 11 | }), 12 | }); 13 | -------------------------------------------------------------------------------- /resources/eslint-internal-rules/README.md: -------------------------------------------------------------------------------- 1 | # Custom ESLint Rules 2 | 3 | This is a dummy npm package that allows us to treat it as an `eslint-plugin-graphql-internal`. 4 | It's not actually published, nor are the rules here useful for users of graphql. 5 | 6 | **If you modify this rule, you must re-run `npm install` for it to take effect.** 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This .gitignore only ignores files specific to this repository. 2 | # If you see other files generated by your OS or tools you use, consider 3 | # creating a global .gitignore file. 4 | # 5 | # https://help.github.com/articles/ignoring-files/#create-a-global-gitignore 6 | # https://www.gitignore.io/ 7 | 8 | /node_modules 9 | /coverage 10 | /npmDist -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "isolatedModules": true, 5 | "lib": ["es2018"], 6 | "module": "commonjs", 7 | "target": "es2018", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "moduleResolution": "node", 11 | "noFallthroughCasesInSwitch": true, 12 | "noImplicitReturns": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "importsNotUsedAsValues": "error", 18 | "newLine": "LF", 19 | "preserveConstEnums": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/__tests__/helpers/koa-multer.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | 3 | import multer from 'multer'; 4 | import type Koa from 'koa'; 5 | 6 | export default function multerWrapper(options?: multer.Options | undefined) { 7 | const upload = multer(options); 8 | const uploadSingle = upload.single.bind(upload); 9 | 10 | return { 11 | ...upload, 12 | single(...args: Parameters): Koa.Middleware { 13 | return async function (ctx: Koa.Context, next: Koa.Next) { 14 | const promisifiedUploadSingle = promisify(uploadSingle(...args)); 15 | 16 | await promisifiedUploadSingle(ctx.req as any, ctx.res as any); 17 | return next(); 18 | }; 19 | }, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "en", 3 | "ignorePaths": [ 4 | // Copied from '.gitignore', please keep it in sync. 5 | ".eslintcache", 6 | "node_modules", 7 | "coverage", 8 | "npmDist", 9 | "__tests__", 10 | 11 | // Excluded from spelling check 12 | "cspell.json", 13 | "package.json", 14 | "package-lock.json", 15 | "tsconfig.json" 16 | ], 17 | "words": [ 18 | "graphiql", 19 | "unfetch", 20 | "noindex", 21 | "codecov", 22 | "recognise", 23 | "serializable", 24 | "subcommand", 25 | "charsets", 26 | "downlevel", 27 | 28 | // TODO: remove bellow words 29 | "Graphi", // GraphiQL 30 | "QL's", // GraphQL's 31 | "graphql's" // express-graphql's 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /examples/index.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import mount from 'koa-mount'; 3 | import { buildSchema } from 'graphql'; 4 | 5 | import { graphqlHTTP } from '../src/index'; 6 | 7 | // Construct a schema, using GraphQL schema language 8 | const schema = buildSchema(` 9 | type Query { 10 | hello: String 11 | } 12 | `); 13 | 14 | // The root provides a resolver function for each API endpoint 15 | const root = { 16 | hello: () => 'Hello world!', 17 | }; 18 | 19 | const app = new Koa(); 20 | 21 | app.use( 22 | mount( 23 | '/graphql', 24 | graphqlHTTP({ 25 | schema, 26 | rootValue: root, 27 | graphiql: { headerEditorEnabled: true }, 28 | }), 29 | ), 30 | ); 31 | 32 | app.listen(4000, () => { 33 | console.log('Running a GraphQL API server at http://localhost:4000/graphql'); 34 | }); 35 | -------------------------------------------------------------------------------- /resources/load-statically-from-npm.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | 5 | const ts = require('typescript'); 6 | 7 | /** 8 | * Transforms: 9 | * 10 | * loadFileStaticallyFromNPM() 11 | * 12 | * to: 13 | * 14 | * "" 15 | */ 16 | module.exports.transformLoadFileStaticallyFromNPM = function (context) { 17 | return function visit(node) { 18 | if (ts.isCallExpression(node)) { 19 | if ( 20 | ts.isIdentifier(node.expression) && 21 | node.expression.text === 'loadFileStaticallyFromNPM' 22 | ) { 23 | const npmPath = node.arguments[0].text; 24 | const filePath = require.resolve(npmPath); 25 | const content = fs.readFileSync(filePath, 'utf-8'); 26 | return ts.createStringLiteral(content); 27 | } 28 | } 29 | return ts.visitEachChild(node, visit, context); 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /examples/schema.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'graphql'; 2 | 3 | function sleep(ms: number) { 4 | return new Promise((resolve) => { 5 | setTimeout(resolve, ms); 6 | }); 7 | } 8 | 9 | export const schema = buildSchema(` 10 | type Query { 11 | hello: String 12 | } 13 | type Subscription { 14 | countDown: Int 15 | } 16 | `); 17 | 18 | export const roots = { 19 | Query: { 20 | hello: () => 'Hello World!', 21 | }, 22 | subscription: { 23 | /* eslint no-await-in-loop: "off" */ 24 | 25 | countDown: async function* fiveToOne() { 26 | for (const number of [5, 4, 3, 2, 1]) { 27 | await sleep(1000); // slow down a bit so user can see the count down on GraphiQL 28 | yield { countDown: number }; 29 | } 30 | }, 31 | }, 32 | }; 33 | 34 | export const rootValue = { 35 | hello: roots.Query.hello, 36 | countDown: roots.subscription.countDown, 37 | }; 38 | -------------------------------------------------------------------------------- /resources/eslint-internal-rules/no-dir-import.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | module.exports = function (context) { 7 | return { 8 | ImportDeclaration: checkImportPath, 9 | ExportNamedDeclaration: checkImportPath, 10 | }; 11 | 12 | function checkImportPath(node) { 13 | const { source } = node; 14 | 15 | // bail if the declaration doesn't have a source, e.g. "export { foo };" 16 | if (!source) { 17 | return; 18 | } 19 | 20 | const importPath = source.value; 21 | if (importPath.startsWith('./') || importPath.startsWith('../')) { 22 | const baseDir = path.dirname(context.getFilename()); 23 | const resolvedPath = path.resolve(baseDir, importPath); 24 | 25 | if ( 26 | fs.existsSync(resolvedPath) && 27 | fs.statSync(resolvedPath).isDirectory() 28 | ) { 29 | context.report({ 30 | node: source, 31 | message: 'It is not allowed to import from directory', 32 | }); 33 | } 34 | } 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) GraphQL Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /resources/checkgit.sh: -------------------------------------------------------------------------------- 1 | # Exit immediately if any subcommand terminated 2 | trap "exit 1" ERR 3 | 4 | # 5 | # This script determines if current git state is the up to date main. If so 6 | # it exits normally. If not it prompts for an explicit continue. This script 7 | # intends to protect from versioning for NPM without first pushing changes 8 | # and including any changes on main. 9 | # 10 | 11 | # First fetch to ensure git is up to date. Fail-fast if this fails. 12 | git fetch; 13 | if [[ $? -ne 0 ]]; then exit 1; fi; 14 | 15 | # Extract useful information. 16 | GIT_BRANCH=$(git branch -v 2> /dev/null | sed '/^[^*]/d'); 17 | GIT_BRANCH_NAME=$(echo "$GIT_BRANCH" | sed 's/* \([A-Za-z0-9_\-]*\).*/\1/'); 18 | GIT_BRANCH_SYNC=$(echo "$GIT_BRANCH" | sed 's/* [^[]*.\([^]]*\).*/\1/'); 19 | 20 | # Check if main is checked out 21 | if [ "$GIT_BRANCH_NAME" != "main" ]; then 22 | read -p "Git not on main but $GIT_BRANCH_NAME. Continue? (y|N) " yn; 23 | if [ "$yn" != "y" ]; then exit 1; fi; 24 | fi; 25 | 26 | # Check if branch is synced with remote 27 | if [ "$GIT_BRANCH_SYNC" != "" ]; then 28 | read -p "Git not up to date but $GIT_BRANCH_SYNC. Continue? (y|N) " yn; 29 | if [ "$yn" != "y" ]; then exit 1; fi; 30 | fi; -------------------------------------------------------------------------------- /examples/index_subscription.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http'; 2 | 3 | import Koa from 'koa'; 4 | import mount from 'koa-mount'; 5 | import { execute, subscribe } from 'graphql'; 6 | import ws from 'ws'; 7 | import { useServer } from 'graphql-ws/lib/use/ws'; 8 | 9 | import { graphqlHTTP } from '../src'; 10 | 11 | import { schema, roots, rootValue } from './schema'; 12 | 13 | const PORT = 4000; 14 | const subscriptionEndpoint = `ws://localhost:${PORT}/subscriptions`; 15 | 16 | const app = new Koa(); 17 | app.use( 18 | mount( 19 | '/graphql', 20 | graphqlHTTP({ 21 | schema, 22 | rootValue, 23 | graphiql: { 24 | subscriptionEndpoint, 25 | websocketClient: 'v1', 26 | }, 27 | }), 28 | ), 29 | ); 30 | 31 | const server = createServer(app.callback()); 32 | 33 | const wsServer = new ws.Server({ 34 | server, 35 | path: '/subscriptions', 36 | }); 37 | 38 | server.listen(PORT, () => { 39 | useServer( 40 | { 41 | schema, 42 | roots, 43 | execute, 44 | subscribe, 45 | }, 46 | wsServer, 47 | ); 48 | console.info( 49 | `Running a GraphQL API server with subscriptions at http://localhost:${PORT}/graphql`, 50 | ); 51 | }); 52 | -------------------------------------------------------------------------------- /examples/index_subscription_legacy.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'http'; 2 | 3 | import Koa from 'koa'; 4 | import mount from 'koa-mount'; 5 | import { execute, subscribe } from 'graphql'; 6 | import { SubscriptionServer } from 'subscriptions-transport-ws'; 7 | 8 | import { graphqlHTTP } from '../src'; 9 | 10 | import { schema, rootValue } from './schema'; 11 | 12 | const PORT = 4000; 13 | const subscriptionEndpoint = `ws://localhost:${PORT}/subscriptions`; 14 | 15 | const app = new Koa(); 16 | app.use( 17 | mount( 18 | '/graphql', 19 | graphqlHTTP({ 20 | schema, 21 | rootValue, 22 | graphiql: { subscriptionEndpoint }, 23 | }), 24 | ), 25 | ); 26 | 27 | const ws = createServer(app.callback()); 28 | 29 | ws.listen(PORT, () => { 30 | console.log( 31 | `Running a GraphQL API server with subscriptions at http://localhost:${PORT}/graphql`, 32 | ); 33 | }); 34 | 35 | const onConnect = (_: any, __: any) => { 36 | console.log('connecting ....'); 37 | }; 38 | 39 | const onDisconnect = (_: any) => { 40 | console.log('disconnecting ...'); 41 | }; 42 | 43 | SubscriptionServer.create( 44 | { 45 | schema, 46 | rootValue, 47 | execute, 48 | subscribe, 49 | onConnect, 50 | onDisconnect, 51 | }, 52 | { 53 | server: ws, 54 | path: '/subscriptions', 55 | }, 56 | ); 57 | -------------------------------------------------------------------------------- /resources/build-npm.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const assert = require('assert'); 6 | 7 | const ts = require('typescript'); 8 | const { main: downlevel } = require('downlevel-dts'); 9 | 10 | // eslint-disable-next-line import/extensions 11 | const tsConfig = require('../tsconfig.json'); 12 | 13 | const { 14 | transformLoadFileStaticallyFromNPM, 15 | } = require('./load-statically-from-npm'); 16 | const { rmdirRecursive, readdirRecursive, showDirStats } = require('./utils'); 17 | 18 | if (require.main === module) { 19 | rmdirRecursive('./npmDist'); 20 | fs.mkdirSync('./npmDist'); 21 | 22 | const srcFiles = readdirRecursive('./src', { ignoreDir: /^__.*__$/ }); 23 | const { options } = ts.convertCompilerOptionsFromJson( 24 | { ...tsConfig.compilerOptions, outDir: 'npmDist' }, 25 | process.cwd(), 26 | ); 27 | const program = ts.createProgram({ 28 | rootNames: srcFiles.map((filepath) => path.join('./src', filepath)), 29 | options, 30 | }); 31 | program.emit(undefined, undefined, undefined, undefined, { 32 | after: [transformLoadFileStaticallyFromNPM], 33 | }); 34 | downlevel('./npmDist', './npmDist/ts3.4', '3.4.0'); 35 | 36 | fs.copyFileSync('./LICENSE', './npmDist/LICENSE'); 37 | fs.copyFileSync('./README.md', './npmDist/README.md'); 38 | 39 | // Should be done as the last step so only valid packages can be published 40 | const packageJSON = buildPackageJSON(); 41 | fs.writeFileSync( 42 | './npmDist/package.json', 43 | JSON.stringify(packageJSON, null, 2), 44 | ); 45 | 46 | showDirStats('./npmDist'); 47 | } 48 | 49 | function buildPackageJSON() { 50 | // eslint-disable-next-line import/extensions 51 | const packageJSON = require('../package.json'); 52 | delete packageJSON.private; 53 | delete packageJSON.scripts; 54 | delete packageJSON.devDependencies; 55 | 56 | const { version } = packageJSON; 57 | const versionMatch = /^\d+\.\d+\.\d+-?(?.*)?$/.exec(version); 58 | if (!versionMatch) { 59 | throw new Error('Version does not match semver spec: ' + version); 60 | } 61 | 62 | const { preReleaseTag } = versionMatch.groups; 63 | 64 | if (preReleaseTag != null) { 65 | const [tag] = preReleaseTag.split('.'); 66 | assert( 67 | tag.startsWith('experimental-') || ['alpha', 'beta', 'rc'].includes(tag), 68 | `"${tag}" tag is supported.`, 69 | ); 70 | 71 | assert(!packageJSON.publishConfig, 'Can not override "publishConfig".'); 72 | packageJSON.publishConfig = { tag: tag || 'latest' }; 73 | } 74 | 75 | return packageJSON; 76 | } 77 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | env: 4 | NODE_VERSION_USED_FOR_DEVELOPMENT: 16 5 | jobs: 6 | lint: 7 | name: Lint source files 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repo 11 | uses: actions/checkout@v2 12 | 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ env.NODE_VERSION_USED_FOR_DEVELOPMENT }} 17 | 18 | - name: Cache Node.js modules 19 | uses: actions/cache@v2 20 | with: 21 | path: ~/.npm 22 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 23 | restore-keys: | 24 | ${{ runner.OS }}-node- 25 | - name: Install Dependencies 26 | run: npm ci 27 | 28 | - name: Lint ESLint 29 | run: npm run lint 30 | 31 | - name: Lint TypeScript 32 | run: npm run check 33 | 34 | - name: Lint Prettier 35 | run: npm run prettier:check 36 | 37 | - name: Spellcheck 38 | run: npm run check:spelling 39 | 40 | checkForCommonlyIgnoredFiles: 41 | name: Check for commonly ignored files 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Checkout repo 45 | uses: actions/checkout@v2 46 | 47 | - name: Check if commit contains files that should be ignored 48 | run: | 49 | git clone --depth 1 https://github.com/github/gitignore.git && 50 | cat gitignore/Node.gitignore $(find gitignore/Global -name "*.gitignore" | grep -v ModelSim) > all.gitignore && 51 | if [[ "$(git ls-files -iX all.gitignore)" != "" ]]; then 52 | echo "::error::Please remove these files:" 53 | git ls-files -iX all.gitignore 54 | exit 1 55 | fi 56 | 57 | coverage: 58 | name: Measure test coverage 59 | runs-on: ubuntu-latest 60 | steps: 61 | - name: Checkout repo 62 | uses: actions/checkout@v2 63 | 64 | - name: Setup Node.js 65 | uses: actions/setup-node@v1 66 | with: 67 | node-version: ${{ env.NODE_VERSION_USED_FOR_DEVELOPMENT }} 68 | 69 | - name: Cache Node.js modules 70 | uses: actions/cache@v2 71 | with: 72 | path: ~/.npm 73 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 74 | restore-keys: | 75 | ${{ runner.OS }}-node- 76 | - name: Install Dependencies 77 | run: npm ci 78 | 79 | - name: Run tests and measure code coverage 80 | run: npm run testonly:cover 81 | 82 | - name: Upload coverage to Codecov 83 | if: ${{ always() }} 84 | uses: codecov/codecov-action@v2 85 | with: 86 | file: ./coverage/coverage-final.json 87 | fail_ci_if_error: true 88 | 89 | test: 90 | name: Run tests on Node v${{ matrix.node_version_to_setup }} 91 | runs-on: ubuntu-latest 92 | strategy: 93 | matrix: 94 | node_version_to_setup: [10, 12, 14, 16, 18] 95 | steps: 96 | - name: Checkout repo 97 | uses: actions/checkout@v2 98 | 99 | - name: Setup Node.js v${{ matrix.node_version_to_setup }} 100 | uses: actions/setup-node@v1 101 | with: 102 | node-version: ${{ matrix.node_version_to_setup }} 103 | 104 | - name: Cache Node.js modules 105 | uses: actions/cache@v2 106 | with: 107 | path: ~/.npm 108 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 109 | restore-keys: | 110 | ${{ runner.OS }}-node- 111 | - name: Install Dependencies 112 | run: npm ci 113 | 114 | - name: Run Tests 115 | run: npm run testonly 116 | -------------------------------------------------------------------------------- /resources/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const util = require('util'); 5 | const path = require('path'); 6 | const childProcess = require('child_process'); 7 | 8 | function exec(command, options) { 9 | const output = childProcess.execSync(command, { 10 | maxBuffer: 10 * 1024 * 1024, // 10MB 11 | encoding: 'utf-8', 12 | ...options, 13 | }); 14 | return removeTrailingNewLine(output); 15 | } 16 | 17 | const childProcessExec = util.promisify(childProcess.exec); 18 | async function execAsync(command, options) { 19 | const output = await childProcessExec(command, { 20 | maxBuffer: 10 * 1024 * 1024, // 10MB 21 | encoding: 'utf-8', 22 | ...options, 23 | }); 24 | return removeTrailingNewLine(output.stdout); 25 | } 26 | 27 | function removeTrailingNewLine(str) { 28 | if (str == null) { 29 | return str; 30 | } 31 | 32 | return str.split('\n').slice(0, -1).join('\n'); 33 | } 34 | 35 | function rmdirRecursive(dirPath) { 36 | if (fs.existsSync(dirPath)) { 37 | for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) { 38 | const fullPath = path.join(dirPath, dirent.name); 39 | if (dirent.isDirectory()) { 40 | rmdirRecursive(fullPath); 41 | } else { 42 | fs.unlinkSync(fullPath); 43 | } 44 | } 45 | fs.rmdirSync(dirPath); 46 | } 47 | } 48 | 49 | function readdirRecursive(dirPath, opts = {}) { 50 | const { ignoreDir } = opts; 51 | const result = []; 52 | for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) { 53 | const name = dirent.name; 54 | if (!dirent.isDirectory()) { 55 | result.push(dirent.name); 56 | continue; 57 | } 58 | 59 | if (ignoreDir && ignoreDir.test(name)) { 60 | continue; 61 | } 62 | const list = readdirRecursive(path.join(dirPath, name), opts).map((f) => 63 | path.join(name, f), 64 | ); 65 | result.push(...list); 66 | } 67 | return result; 68 | } 69 | 70 | function showDirStats(dirPath) { 71 | const fileTypes = {}; 72 | let totalSize = 0; 73 | 74 | for (const filepath of readdirRecursive(dirPath)) { 75 | const name = filepath.split(path.sep).pop(); 76 | const [base, ...splitExt] = name.split('.'); 77 | const ext = splitExt.join('.'); 78 | 79 | const filetype = ext ? '*.' + ext : base; 80 | fileTypes[filetype] = fileTypes[filetype] || { filepaths: [], size: 0 }; 81 | 82 | const { size } = fs.lstatSync(path.join(dirPath, filepath)); 83 | totalSize += size; 84 | fileTypes[filetype].size += size; 85 | fileTypes[filetype].filepaths.push(filepath); 86 | } 87 | 88 | let stats = []; 89 | for (const [filetype, typeStats] of Object.entries(fileTypes)) { 90 | const numFiles = typeStats.filepaths.length; 91 | 92 | if (numFiles > 1) { 93 | stats.push([filetype + ' x' + numFiles, typeStats.size]); 94 | } else { 95 | stats.push([typeStats.filepaths[0], typeStats.size]); 96 | } 97 | } 98 | stats.sort((a, b) => b[1] - a[1]); 99 | stats = stats.map(([type, size]) => [type, (size / 1024).toFixed(2) + ' KB']); 100 | 101 | const typeMaxLength = Math.max(...stats.map((x) => x[0].length)); 102 | const sizeMaxLength = Math.max(...stats.map((x) => x[1].length)); 103 | for (const [type, size] of stats) { 104 | console.log( 105 | type.padStart(typeMaxLength) + ' | ' + size.padStart(sizeMaxLength), 106 | ); 107 | } 108 | 109 | console.log('-'.repeat(typeMaxLength + 3 + sizeMaxLength)); 110 | const totalMB = (totalSize / 1024 / 1024).toFixed(2) + ' MB'; 111 | console.log( 112 | 'Total'.padStart(typeMaxLength) + ' | ' + totalMB.padStart(sizeMaxLength), 113 | ); 114 | } 115 | 116 | module.exports = { 117 | exec, 118 | execAsync, 119 | rmdirRecursive, 120 | readdirRecursive, 121 | showDirStats, 122 | }; 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-graphql", 3 | "version": "0.12.0", 4 | "description": "Production ready GraphQL Koa middleware.", 5 | "contributors": [ 6 | "Lee Byron (https://leebyron.com/)", 7 | "Daniel Schafer ", 8 | "C.T. Lin " 9 | ], 10 | "license": "MIT", 11 | "private": true, 12 | "main": "index.js", 13 | "types": "index.d.ts", 14 | "typesVersions": { 15 | "<3.8": { 16 | "*": [ 17 | "ts3.4/*" 18 | ] 19 | } 20 | }, 21 | "sideEffects": false, 22 | "homepage": "https://github.com/graphql-community/koa-graphql", 23 | "bugs": { 24 | "url": "https://github.com/graphql-community/koa-graphql/issues" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/graphql-community/koa-graphql.git" 29 | }, 30 | "keywords": [ 31 | "koa", 32 | "http", 33 | "graphql", 34 | "middleware", 35 | "api" 36 | ], 37 | "engines": { 38 | "node": ">= 10.x" 39 | }, 40 | "scripts": { 41 | "preversion": ". ./resources/checkgit.sh && npm ci", 42 | "version": "npm test", 43 | "changelog": "node resources/gen-changelog.js", 44 | "test": "npm run lint && npm run check && npm run testonly:cover && npm run prettier:check && npm run check:spelling && npm run build:npm", 45 | "lint": "eslint .", 46 | "check": "tsc --noEmit", 47 | "testonly": "mocha --exit src/**/__tests__/**/*.ts", 48 | "testonly:cover": "nyc npm run testonly", 49 | "prettier": "prettier --write --list-different .", 50 | "prettier:check": "prettier --check .", 51 | "check:spelling": "cspell '**/*'", 52 | "build:npm": "node resources/build-npm.js", 53 | "start": "node -r ./resources/register.js examples/index.ts", 54 | "start:subscription": "node -r ./resources/register.js examples/index_subscription.ts", 55 | "start:subscription_legacy": "node -r ./resources/register.js examples/index_subscription_legacy.ts" 56 | }, 57 | "dependencies": { 58 | "@types/koa": "^2.13.4", 59 | "express-graphql": "0.12.0", 60 | "http-errors": "^1.7.3" 61 | }, 62 | "devDependencies": { 63 | "@graphiql/toolkit": "^0.1.0", 64 | "@types/chai": "^4.2.21", 65 | "@types/co-body": "^6.1.0", 66 | "@types/http-errors": "^1.8.1", 67 | "@types/koa-mount": "^4.0.0", 68 | "@types/koa-session": "^5.10.4", 69 | "@types/mocha": "^9.0.0", 70 | "@types/multer": "^1.4.7", 71 | "@types/sinon": "^10.0.2", 72 | "@types/supertest": "^2.0.11", 73 | "@types/ws": "^5.1.2", 74 | "@typescript-eslint/eslint-plugin": "^4.29.0", 75 | "@typescript-eslint/parser": "^4.29.0", 76 | "chai": "^4.2.0", 77 | "co-body": "^6.0.0", 78 | "codemirror": "^5.62.2", 79 | "cspell": "^4.2.2", 80 | "downlevel-dts": "^0.7.0", 81 | "eslint": "^7.31.0", 82 | "eslint-plugin-import": "^2.23.4", 83 | "eslint-plugin-internal-rules": "file:./resources/eslint-internal-rules", 84 | "eslint-plugin-istanbul": "^0.1.2", 85 | "eslint-plugin-node": "^11.1.0", 86 | "eslint-plugin-prettier": "^3.1.3", 87 | "graphiql": "^1.4.2", 88 | "graphiql-subscriptions-fetcher": "^0.0.2", 89 | "graphql": "^15.7.2", 90 | "graphql-ws": "4.1.2", 91 | "koa": "^2.11.0", 92 | "koa-mount": "^4.0.0", 93 | "koa-session": "^5.13.1", 94 | "mocha": "^8.2.1", 95 | "multer": "^1.4.2", 96 | "nyc": "^15.1.0", 97 | "prettier": "^2.3.2", 98 | "promise-polyfill": "^8.2.0", 99 | "raw-body": "^2.4.1", 100 | "react": "^17.0.2", 101 | "react-dom": "^17.0.2", 102 | "sinon": "^11.1.2", 103 | "subscriptions-transport-ws": "^0.9.18", 104 | "supertest": "^4.0.2", 105 | "ts-node": "^10.1.0", 106 | "typescript": "^4.3.5", 107 | "unfetch": "^4.2.0", 108 | "ws": "^5.2.2" 109 | }, 110 | "peerDependencies": { 111 | "graphql": "^14.7.0 || ^15.3.0" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/__tests__/usage-test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { describe, it } from 'mocha'; 3 | import request from 'supertest'; 4 | import Koa from 'koa'; 5 | import mount from 'koa-mount'; 6 | import { GraphQLSchema } from 'graphql'; 7 | 8 | import { graphqlHTTP } from '../index'; 9 | 10 | describe('Useful errors when incorrectly used', () => { 11 | it('requires an option factory function', () => { 12 | expect(() => { 13 | // @ts-expect-error 14 | graphqlHTTP(); 15 | }).to.throw('GraphQL middleware requires options.'); 16 | }); 17 | 18 | it('requires option factory function to return object', async () => { 19 | const app = new Koa(); 20 | 21 | app.use( 22 | mount( 23 | '/graphql', 24 | // @ts-expect-error 25 | graphqlHTTP(() => null), 26 | ), 27 | ); 28 | 29 | const response = await request(app.listen()).get('/graphql?query={test}'); 30 | 31 | expect(response.status).to.equal(500); 32 | expect(JSON.parse(response.text)).to.deep.equal({ 33 | errors: [ 34 | { 35 | message: 36 | 'GraphQL middleware option function must return an options object or a promise which will be resolved to an options object.', 37 | }, 38 | ], 39 | }); 40 | }); 41 | 42 | it('requires option factory function to return object or promise of object', async () => { 43 | const app = new Koa(); 44 | 45 | app.use( 46 | mount( 47 | '/graphql', 48 | // @ts-expect-error 49 | graphqlHTTP(() => Promise.resolve(null)), 50 | ), 51 | ); 52 | 53 | const response = await request(app.listen()).get('/graphql?query={test}'); 54 | 55 | expect(response.status).to.equal(500); 56 | expect(JSON.parse(response.text)).to.deep.equal({ 57 | errors: [ 58 | { 59 | message: 60 | 'GraphQL middleware option function must return an options object or a promise which will be resolved to an options object.', 61 | }, 62 | ], 63 | }); 64 | }); 65 | 66 | it('requires option factory function to return object with schema', async () => { 67 | const app = new Koa(); 68 | 69 | app.use( 70 | mount( 71 | '/graphql', 72 | // @ts-expect-error 73 | graphqlHTTP(() => ({})), 74 | ), 75 | ); 76 | 77 | const response = await request(app.listen()).get('/graphql?query={test}'); 78 | 79 | expect(response.status).to.equal(500); 80 | expect(JSON.parse(response.text)).to.deep.equal({ 81 | errors: [ 82 | { message: 'GraphQL middleware options must contain a schema.' }, 83 | ], 84 | }); 85 | }); 86 | 87 | it('requires option factory function to return object or promise of object with schema', async () => { 88 | const app = new Koa(); 89 | 90 | app.use( 91 | mount( 92 | '/graphql', 93 | // @ts-expect-error 94 | graphqlHTTP(() => Promise.resolve({})), 95 | ), 96 | ); 97 | 98 | const response = await request(app.listen()).get('/graphql?query={test}'); 99 | 100 | expect(response.status).to.equal(500); 101 | expect(JSON.parse(response.text)).to.deep.equal({ 102 | errors: [ 103 | { message: 'GraphQL middleware options must contain a schema.' }, 104 | ], 105 | }); 106 | }); 107 | 108 | it('validates schema before executing request', async () => { 109 | // @ts-expect-error 110 | const schema = new GraphQLSchema({ directives: [null] }); 111 | 112 | const app = new Koa(); 113 | 114 | app.use( 115 | mount( 116 | '/graphql', 117 | graphqlHTTP(() => Promise.resolve({ schema })), 118 | ), 119 | ); 120 | 121 | const response = await request(app.listen()).get('/graphql?query={test}'); 122 | 123 | expect(response.status).to.equal(500); 124 | expect(JSON.parse(response.text)).to.deep.equal({ 125 | errors: [ 126 | { message: 'Query root type must be provided.' }, 127 | { message: 'Expected directive but got: null.' }, 128 | ], 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /resources/gen-changelog.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const util = require('util'); 4 | const https = require('https'); 5 | 6 | // eslint-disable-next-line import/extensions 7 | const packageJSON = require('../package.json'); 8 | 9 | const { exec } = require('./utils'); 10 | 11 | const graphqlRequest = util.promisify(graphqlRequestImpl); 12 | const labelsConfig = { 13 | 'PR: breaking change 💥': { 14 | section: 'Breaking Change 💥', 15 | }, 16 | 'PR: feature 🚀': { 17 | section: 'New Feature 🚀', 18 | }, 19 | 'PR: bug fix 🐞': { 20 | section: 'Bug Fix 🐞', 21 | }, 22 | 'PR: docs 📝': { 23 | section: 'Docs 📝', 24 | fold: true, 25 | }, 26 | 'PR: polish 💅': { 27 | section: 'Polish 💅', 28 | fold: true, 29 | }, 30 | 'PR: internal 🏠': { 31 | section: 'Internal 🏠', 32 | fold: true, 33 | }, 34 | 'PR: dependency 📦': { 35 | section: 'Dependency 📦', 36 | fold: true, 37 | }, 38 | }; 39 | const { GH_TOKEN } = process.env; 40 | 41 | if (!GH_TOKEN) { 42 | console.error('Must provide GH_TOKEN as environment variable!'); 43 | process.exit(1); 44 | } 45 | 46 | if (!packageJSON.repository || typeof packageJSON.repository.url !== 'string') { 47 | console.error('package.json is missing repository.url string!'); 48 | process.exit(1); 49 | } 50 | 51 | const repoURLMatch = 52 | /https:\/\/github.com\/(?[^/]+)\/(?[^/]+).git/.exec( 53 | packageJSON.repository.url, 54 | ); 55 | if (repoURLMatch == null) { 56 | console.error('Cannot extract organization and repo name from repo URL!'); 57 | process.exit(1); 58 | } 59 | const { githubOrg, githubRepo } = repoURLMatch.groups; 60 | 61 | getChangeLog() 62 | .then((changelog) => process.stdout.write(changelog)) 63 | .catch((error) => { 64 | console.error(error); 65 | process.exit(1); 66 | }); 67 | 68 | function getChangeLog() { 69 | const { version } = packageJSON; 70 | 71 | let tag = null; 72 | let commitsList = exec(`git rev-list --reverse v${version}..`); 73 | if (commitsList === '') { 74 | const parentPackageJSON = exec('git cat-file blob HEAD~1:package.json'); 75 | const parentVersion = JSON.parse(parentPackageJSON).version; 76 | commitsList = exec(`git rev-list --reverse v${parentVersion}..HEAD~1`); 77 | tag = `v${version}`; 78 | } 79 | 80 | const date = exec('git log -1 --format=%cd --date=short'); 81 | return getCommitsInfo(commitsList.split('\n')) 82 | .then((commitsInfo) => getPRsInfo(commitsInfoToPRs(commitsInfo))) 83 | .then((prsInfo) => genChangeLog(tag, date, prsInfo)); 84 | } 85 | 86 | function genChangeLog(tag, date, allPRs) { 87 | const byLabel = {}; 88 | const committersByLogin = {}; 89 | 90 | for (const pr of allPRs) { 91 | const labels = pr.labels.nodes 92 | .map((label) => label.name) 93 | .filter((label) => label.startsWith('PR: ')); 94 | 95 | if (labels.length === 0) { 96 | throw new Error(`PR is missing label. See ${pr.url}`); 97 | } 98 | if (labels.length > 1) { 99 | throw new Error( 100 | `PR has conflicting labels: ${labels.join('\n')}\nSee ${pr.url}`, 101 | ); 102 | } 103 | 104 | const label = labels[0]; 105 | if (!labelsConfig[label]) { 106 | throw new Error(`Unknown label: ${label}. See ${pr.url}`); 107 | } 108 | byLabel[label] = byLabel[label] || []; 109 | byLabel[label].push(pr); 110 | committersByLogin[pr.author.login] = pr.author; 111 | } 112 | 113 | let changelog = `## ${tag || 'Unreleased'} (${date})\n`; 114 | for (const [label, config] of Object.entries(labelsConfig)) { 115 | const prs = byLabel[label]; 116 | if (prs) { 117 | const shouldFold = config.fold && prs.length > 1; 118 | 119 | changelog += `\n#### ${config.section}\n`; 120 | if (shouldFold) { 121 | changelog += '
\n'; 122 | changelog += ` ${prs.length} PRs were merged \n\n`; 123 | } 124 | 125 | for (const pr of prs) { 126 | const { number, url, author } = pr; 127 | changelog += `* [#${number}](${url}) ${pr.title} ([@${author.login}](${author.url}))\n`; 128 | } 129 | 130 | if (shouldFold) { 131 | changelog += '
\n'; 132 | } 133 | } 134 | } 135 | 136 | const committers = Object.values(committersByLogin).sort((a, b) => 137 | (a.name || a.login).localeCompare(b.name || b.login), 138 | ); 139 | changelog += `\n#### Committers: ${committers.length}\n`; 140 | for (const committer of committers) { 141 | changelog += `* ${committer.name}([@${committer.login}](${committer.url}))\n`; 142 | } 143 | 144 | return changelog; 145 | } 146 | 147 | function graphqlRequestImpl(query, variables, cb) { 148 | const resultCB = typeof variables === 'function' ? variables : cb; 149 | 150 | const req = https.request('https://api.github.com/graphql', { 151 | method: 'POST', 152 | headers: { 153 | Authorization: 'bearer ' + GH_TOKEN, 154 | 'Content-Type': 'application/json', 155 | 'User-Agent': 'gen-changelog', 156 | }, 157 | }); 158 | 159 | req.on('response', (res) => { 160 | let responseBody = ''; 161 | 162 | res.setEncoding('utf8'); 163 | res.on('data', (d) => (responseBody += d)); 164 | res.on('error', (error) => resultCB(error)); 165 | 166 | res.on('end', () => { 167 | if (res.statusCode !== 200) { 168 | return resultCB( 169 | new Error( 170 | `GitHub responded with ${res.statusCode}: ${res.statusMessage}\n` + 171 | responseBody, 172 | ), 173 | ); 174 | } 175 | 176 | let json; 177 | try { 178 | json = JSON.parse(responseBody); 179 | } catch (error) { 180 | return resultCB(error); 181 | } 182 | 183 | if (json.errors) { 184 | return resultCB( 185 | new Error('Errors: ' + JSON.stringify(json.errors, null, 2)), 186 | ); 187 | } 188 | 189 | resultCB(undefined, json.data); 190 | }); 191 | }); 192 | 193 | req.on('error', (error) => resultCB(error)); 194 | req.write(JSON.stringify({ query, variables })); 195 | req.end(); 196 | } 197 | 198 | async function batchCommitInfo(commits) { 199 | let commitsSubQuery = ''; 200 | for (const oid of commits) { 201 | commitsSubQuery += ` 202 | commit_${oid}: object(oid: "${oid}") { 203 | ... on Commit { 204 | oid 205 | message 206 | associatedPullRequests(first: 10) { 207 | nodes { 208 | number 209 | repository { 210 | nameWithOwner 211 | } 212 | } 213 | } 214 | } 215 | } 216 | `; 217 | } 218 | 219 | const response = await graphqlRequest(` 220 | { 221 | repository(owner: "${githubOrg}", name: "${githubRepo}") { 222 | ${commitsSubQuery} 223 | } 224 | } 225 | `); 226 | 227 | const commitsInfo = []; 228 | for (const oid of commits) { 229 | commitsInfo.push(response.repository['commit_' + oid]); 230 | } 231 | return commitsInfo; 232 | } 233 | 234 | async function batchPRInfo(prs) { 235 | let prsSubQuery = ''; 236 | for (const number of prs) { 237 | prsSubQuery += ` 238 | pr_${number}: pullRequest(number: ${number}) { 239 | number 240 | title 241 | url 242 | author { 243 | login 244 | url 245 | ... on User { 246 | name 247 | } 248 | } 249 | labels(first: 10) { 250 | nodes { 251 | name 252 | } 253 | } 254 | } 255 | `; 256 | } 257 | 258 | const response = await graphqlRequest(` 259 | { 260 | repository(owner: "${githubOrg}", name: "${githubRepo}") { 261 | ${prsSubQuery} 262 | } 263 | } 264 | `); 265 | 266 | const prsInfo = []; 267 | for (const number of prs) { 268 | prsInfo.push(response.repository['pr_' + number]); 269 | } 270 | return prsInfo; 271 | } 272 | 273 | function commitsInfoToPRs(commits) { 274 | const prs = {}; 275 | for (const commit of commits) { 276 | const associatedPRs = commit.associatedPullRequests.nodes.filter( 277 | (pr) => pr.repository.nameWithOwner === `${githubOrg}/${githubRepo}`, 278 | ); 279 | if (associatedPRs.length === 0) { 280 | const match = / \(#(?[0-9]+)\)$/m.exec(commit.message); 281 | if (match) { 282 | prs[parseInt(match.groups.prNumber, 10)] = true; 283 | continue; 284 | } 285 | throw new Error( 286 | `Commit ${commit.oid} has no associated PR: ${commit.message}`, 287 | ); 288 | } 289 | if (associatedPRs.length > 1) { 290 | throw new Error( 291 | `Commit ${commit.oid} is associated with multiple PRs: ${commit.message}`, 292 | ); 293 | } 294 | 295 | prs[associatedPRs[0].number] = true; 296 | } 297 | 298 | return Object.keys(prs); 299 | } 300 | 301 | async function getPRsInfo(commits) { 302 | // Split pr into batches of 50 to prevent timeouts 303 | const prInfoPromises = []; 304 | for (let i = 0; i < commits.length; i += 50) { 305 | const batch = commits.slice(i, i + 50); 306 | prInfoPromises.push(batchPRInfo(batch)); 307 | } 308 | 309 | return (await Promise.all(prInfoPromises)).flat(); 310 | } 311 | 312 | async function getCommitsInfo(commits) { 313 | // Split commits into batches of 50 to prevent timeouts 314 | const commitInfoPromises = []; 315 | for (let i = 0; i < commits.length; i += 50) { 316 | const batch = commits.slice(i, i + 50); 317 | commitInfoPromises.push(batchCommitInfo(batch)); 318 | } 319 | 320 | return (await Promise.all(commitInfoPromises)).flat(); 321 | } 322 | -------------------------------------------------------------------------------- /src/renderGraphiQL.ts: -------------------------------------------------------------------------------- 1 | import type { FormattedExecutionResult } from 'graphql'; 2 | 3 | export interface GraphiQLData { 4 | query?: string | null; 5 | variables?: { readonly [name: string]: unknown } | null; 6 | operationName?: string | null; 7 | result?: FormattedExecutionResult; 8 | } 9 | 10 | export interface GraphiQLOptions { 11 | /** 12 | * An optional GraphQL string to use when no query is provided and no stored 13 | * query exists from a previous session. If undefined is provided, GraphiQL 14 | * will use its own default query. 15 | */ 16 | defaultQuery?: string; 17 | 18 | /** 19 | * An optional boolean which enables the header editor when true. 20 | * Defaults to false. 21 | */ 22 | headerEditorEnabled?: boolean; 23 | 24 | /** 25 | * An optional boolean which enables headers to be saved to local 26 | * storage when true. 27 | * Defaults to false. 28 | */ 29 | shouldPersistHeaders?: boolean; 30 | 31 | /** 32 | * A websocket endpoint for subscription 33 | */ 34 | subscriptionEndpoint?: string; 35 | 36 | /** 37 | * websocket client option for subscription, defaults to v0 38 | * v0: subscriptions-transport-ws 39 | * v1: graphql-ws 40 | */ 41 | websocketClient?: string; 42 | 43 | /** 44 | * By passing an object you may change the theme of GraphiQL. 45 | */ 46 | editorTheme?: EditorThemeParam; 47 | } 48 | 49 | type EditorThemeParam = 50 | | { 51 | name: string; 52 | url: string; 53 | } 54 | | string; 55 | 56 | type EditorTheme = { 57 | name: string; 58 | link: string; 59 | }; 60 | 61 | // Current latest version of codeMirror. 62 | const CODE_MIRROR_VERSION = '5.53.2'; 63 | 64 | // Ensures string values are safe to be used within a 139 | 144 | `; 145 | } else { 146 | subscriptionScripts = ` 147 | 152 | 157 | 162 | `; 163 | } 164 | } 165 | 166 | return ` 173 | 174 | 175 | 176 | 177 | GraphiQL 178 | 179 | 180 | 181 | 190 | 194 | ${editorTheme ? editorTheme.link : ''} 195 | 199 | 203 | 207 | 211 | 215 | ${subscriptionScripts} 216 | 217 | 218 |
Loading...
219 | 332 | 333 | `; 334 | } 335 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ASTVisitor, 3 | DocumentNode, 4 | ExecutionArgs, 5 | ExecutionResult, 6 | FormattedExecutionResult, 7 | GraphQLSchema, 8 | GraphQLFieldResolver, 9 | GraphQLTypeResolver, 10 | GraphQLFormattedError, 11 | ValidationContext, 12 | } from 'graphql'; 13 | import type { GraphQLParams, RequestInfo } from 'express-graphql'; 14 | import httpError from 'http-errors'; 15 | import { 16 | Source, 17 | GraphQLError, 18 | validateSchema, 19 | parse, 20 | validate, 21 | execute, 22 | formatError, 23 | getOperationAST, 24 | specifiedRules, 25 | } from 'graphql'; 26 | import { getGraphQLParams } from 'express-graphql'; 27 | 28 | import type { Context, Request, Response } from 'koa'; 29 | 30 | import { renderGraphiQL } from './renderGraphiQL'; 31 | import type { GraphiQLOptions, GraphiQLData } from './renderGraphiQL'; 32 | 33 | type MaybePromise = Promise | T; 34 | 35 | /** 36 | * Used to configure the graphqlHTTP middleware by providing a schema 37 | * and other configuration options. 38 | * 39 | * Options can be provided as an Object, a Promise for an Object, or a Function 40 | * that returns an Object or a Promise for an Object. 41 | */ 42 | export type Options = 43 | | (( 44 | request: Request, 45 | response: Response, 46 | ctx: Context, 47 | params?: GraphQLParams, 48 | ) => OptionsResult) 49 | | OptionsResult; 50 | export type OptionsResult = MaybePromise; 51 | 52 | export interface OptionsData { 53 | /** 54 | * A GraphQL schema from graphql-js. 55 | */ 56 | schema: GraphQLSchema; 57 | 58 | /** 59 | * A value to pass as the context to this middleware. 60 | */ 61 | context?: unknown; 62 | 63 | /** 64 | * An object to pass as the rootValue to the graphql() function. 65 | */ 66 | rootValue?: unknown; 67 | 68 | /** 69 | * A boolean to configure whether the output should be pretty-printed. 70 | */ 71 | pretty?: boolean; 72 | 73 | /** 74 | * An optional array of validation rules that will be applied on the document 75 | * in additional to those defined by the GraphQL spec. 76 | */ 77 | validationRules?: ReadonlyArray<(ctx: ValidationContext) => ASTVisitor>; 78 | 79 | /** 80 | * An optional function which will be used to validate instead of default `validate` 81 | * from `graphql-js`. 82 | */ 83 | customValidateFn?: ( 84 | schema: GraphQLSchema, 85 | documentAST: DocumentNode, 86 | rules: ReadonlyArray, 87 | ) => ReadonlyArray; 88 | 89 | /** 90 | * An optional function which will be used to execute instead of default `execute` 91 | * from `graphql-js`. 92 | */ 93 | customExecuteFn?: (args: ExecutionArgs) => MaybePromise; 94 | 95 | /** 96 | * An optional function which will be used to format any errors produced by 97 | * fulfilling a GraphQL operation. If no function is provided, GraphQL's 98 | * default spec-compliant `formatError` function will be used. 99 | */ 100 | customFormatErrorFn?: (error: GraphQLError) => GraphQLFormattedError; 101 | 102 | /** 103 | * An optional function which will be used to create a document instead of 104 | * the default `parse` from `graphql-js`. 105 | */ 106 | customParseFn?: (source: Source) => DocumentNode; 107 | 108 | /** 109 | * `formatError` is deprecated and replaced by `customFormatErrorFn`. It will 110 | * be removed in version 1.0.0. 111 | */ 112 | formatError?: (error: GraphQLError, context?: any) => GraphQLFormattedError; 113 | 114 | /** 115 | * An optional function for adding additional metadata to the GraphQL response 116 | * as a key-value object. The result will be added to "extensions" field in 117 | * the resulting JSON. This is often a useful place to add development time 118 | * info such as the runtime of a query or the amount of resources consumed. 119 | * 120 | * Information about the request is provided to be used. 121 | * 122 | * This function may be async. 123 | */ 124 | extensions?: ( 125 | info: RequestInfo, 126 | ) => MaybePromise; 127 | 128 | /** 129 | * A boolean to optionally enable GraphiQL mode. 130 | * Alternatively, instead of `true` you can pass in an options object. 131 | */ 132 | graphiql?: boolean | GraphiQLOptions; 133 | 134 | /** 135 | * A resolver function to use when one is not provided by the schema. 136 | * If not provided, the default field resolver is used (which looks for a 137 | * value or method on the source value with the field's name). 138 | */ 139 | fieldResolver?: GraphQLFieldResolver; 140 | 141 | /** 142 | * A type resolver function to use when none is provided by the schema. 143 | * If not provided, the default type resolver is used (which looks for a 144 | * `__typename` field or alternatively calls the `isTypeOf` method). 145 | */ 146 | typeResolver?: GraphQLTypeResolver; 147 | } 148 | 149 | type Middleware = (ctx: Context) => Promise; 150 | 151 | /** 152 | * Middleware for express; takes an options object or function as input to 153 | * configure behavior, and returns an express middleware. 154 | */ 155 | export function graphqlHTTP(options: Options): Middleware { 156 | devAssertIsNonNullable(options, 'GraphQL middleware requires options.'); 157 | 158 | return async function middleware(ctx): Promise { 159 | const req = ctx.req; 160 | const request = ctx.request; 161 | const response = ctx.response; 162 | 163 | // Higher scoped variables are referred to at various stages in the 164 | // asynchronous state machine below. 165 | let params: GraphQLParams | undefined; 166 | let showGraphiQL = false; 167 | let graphiqlOptions: GraphiQLOptions | undefined; 168 | let formatErrorFn = formatError; 169 | let pretty = false; 170 | let result: ExecutionResult; 171 | 172 | try { 173 | // Parse the Request to get GraphQL request parameters. 174 | try { 175 | // Use request.body when req.body is undefined. 176 | const expressReq = req as any; 177 | expressReq.body = expressReq.body ?? request.body; 178 | 179 | params = await getGraphQLParams(expressReq); 180 | } catch (error: unknown) { 181 | // When we failed to parse the GraphQL parameters, we still need to get 182 | // the options object, so make an options call to resolve just that. 183 | const optionsData = await resolveOptions(); 184 | pretty = optionsData.pretty ?? false; 185 | formatErrorFn = 186 | optionsData.customFormatErrorFn ?? 187 | optionsData.formatError ?? 188 | formatErrorFn; 189 | throw error; 190 | } 191 | 192 | // Then, resolve the Options to get OptionsData. 193 | const optionsData = await resolveOptions(params); 194 | 195 | // Collect information from the options data object. 196 | const schema = optionsData.schema; 197 | const rootValue = optionsData.rootValue; 198 | const validationRules = optionsData.validationRules ?? []; 199 | const fieldResolver = optionsData.fieldResolver; 200 | const typeResolver = optionsData.typeResolver; 201 | const graphiql = optionsData.graphiql ?? false; 202 | const extensionsFn = optionsData.extensions; 203 | const context = optionsData.context ?? ctx; 204 | const parseFn = optionsData.customParseFn ?? parse; 205 | const executeFn = optionsData.customExecuteFn ?? execute; 206 | const validateFn = optionsData.customValidateFn ?? validate; 207 | 208 | pretty = optionsData.pretty ?? false; 209 | 210 | formatErrorFn = 211 | optionsData.customFormatErrorFn ?? 212 | optionsData.formatError ?? 213 | formatErrorFn; 214 | 215 | devAssertIsObject( 216 | schema, 217 | 'GraphQL middleware options must contain a schema.', 218 | ); 219 | 220 | // GraphQL HTTP only supports GET and POST methods. 221 | if (request.method !== 'GET' && request.method !== 'POST') { 222 | throw httpError(405, 'GraphQL only supports GET and POST requests.', { 223 | headers: { Allow: 'GET, POST' }, 224 | }); 225 | } 226 | 227 | // Get GraphQL params from the request and POST body data. 228 | const { query, variables, operationName } = params; 229 | showGraphiQL = canDisplayGraphiQL(request, params) && graphiql !== false; 230 | if (typeof graphiql !== 'boolean') { 231 | graphiqlOptions = graphiql; 232 | } 233 | 234 | // If there is no query, but GraphiQL will be displayed, do not produce 235 | // a result, otherwise return a 400: Bad Request. 236 | if (query == null) { 237 | if (showGraphiQL) { 238 | return respondWithGraphiQL(response, graphiqlOptions); 239 | } 240 | throw httpError(400, 'Must provide query string.'); 241 | } 242 | 243 | // Validate Schema 244 | const schemaValidationErrors = validateSchema(schema); 245 | if (schemaValidationErrors.length > 0) { 246 | // Return 500: Internal Server Error if invalid schema. 247 | throw httpError(500, 'GraphQL schema validation error.', { 248 | graphqlErrors: schemaValidationErrors, 249 | }); 250 | } 251 | 252 | // Parse source to AST, reporting any syntax error. 253 | let documentAST: DocumentNode; 254 | 255 | try { 256 | documentAST = parseFn(new Source(query, 'GraphQL request')); 257 | } catch (syntaxError: unknown) { 258 | // Return 400: Bad Request if any syntax errors errors exist. 259 | throw httpError(400, 'GraphQL syntax error.', { 260 | graphqlErrors: [syntaxError], 261 | }); 262 | } 263 | 264 | // Validate AST, reporting any errors. 265 | const validationErrors = validateFn(schema, documentAST, [ 266 | ...specifiedRules, 267 | ...validationRules, 268 | ]); 269 | 270 | if (validationErrors.length > 0) { 271 | // Return 400: Bad Request if any validation errors exist. 272 | throw httpError(400, 'GraphQL validation error.', { 273 | graphqlErrors: validationErrors, 274 | }); 275 | } 276 | 277 | // Only query operations are allowed on GET requests. 278 | if (request.method === 'GET') { 279 | // Determine if this GET request will perform a non-query. 280 | const operationAST = getOperationAST(documentAST, operationName); 281 | if (operationAST && operationAST.operation !== 'query') { 282 | // If GraphiQL can be shown, do not perform this query, but 283 | // provide it to GraphiQL so that the requester may perform it 284 | // themselves if desired. 285 | if (showGraphiQL) { 286 | return respondWithGraphiQL(response, graphiqlOptions, params); 287 | } 288 | 289 | // Otherwise, report a 405: Method Not Allowed error. 290 | throw httpError( 291 | 405, 292 | `Can only perform a ${operationAST.operation} operation from a POST request.`, 293 | { headers: { Allow: 'POST' } }, 294 | ); 295 | } 296 | } 297 | 298 | // Perform the execution, reporting any errors creating the context. 299 | try { 300 | result = await executeFn({ 301 | schema, 302 | document: documentAST, 303 | rootValue, 304 | contextValue: context, 305 | variableValues: variables, 306 | operationName, 307 | fieldResolver, 308 | typeResolver, 309 | }); 310 | response.status = 200; 311 | } catch (contextError: unknown) { 312 | // Return 400: Bad Request if any execution context errors exist. 313 | throw httpError(400, 'GraphQL execution context error.', { 314 | graphqlErrors: [contextError], 315 | }); 316 | } 317 | 318 | // Collect and apply any metadata extensions if a function was provided. 319 | // https://graphql.github.io/graphql-spec/#sec-Response-Format 320 | if (extensionsFn) { 321 | const extensions = await extensionsFn({ 322 | document: documentAST, 323 | variables, 324 | operationName, 325 | result, 326 | context, 327 | }); 328 | 329 | if (extensions != null) { 330 | result = { ...result, extensions }; 331 | } 332 | } 333 | } catch (rawError: unknown) { 334 | // If an error was caught, report the httpError status, or 500. 335 | const error = httpError( 336 | 500, 337 | /* istanbul ignore next: Thrown by underlying library. */ 338 | rawError instanceof Error ? rawError : String(rawError), 339 | ); 340 | response.status = error.status; 341 | 342 | const { headers } = error; 343 | if (headers != null) { 344 | for (const [key, value] of Object.entries(headers)) { 345 | response.set(key, value); 346 | } 347 | } 348 | 349 | if (error.graphqlErrors == null) { 350 | const graphqlError = new GraphQLError( 351 | error.message, 352 | undefined, 353 | undefined, 354 | undefined, 355 | undefined, 356 | error, 357 | ); 358 | result = { data: undefined, errors: [graphqlError] }; 359 | } else { 360 | result = { data: undefined, errors: error.graphqlErrors }; 361 | } 362 | } 363 | 364 | // If no data was included in the result, that indicates a runtime query 365 | // error, indicate as such with a generic status code. 366 | // Note: Information about the error itself will still be contained in 367 | // the resulting JSON payload. 368 | // https://graphql.github.io/graphql-spec/#sec-Data 369 | if (response.status === 200 && result.data == null) { 370 | response.status = 500; 371 | } 372 | 373 | // Format any encountered errors. 374 | const formattedResult: FormattedExecutionResult = { 375 | ...result, 376 | errors: result.errors?.map(formatErrorFn), 377 | }; 378 | 379 | // If allowed to show GraphiQL, present it instead of JSON. 380 | if (showGraphiQL) { 381 | return respondWithGraphiQL( 382 | response, 383 | graphiqlOptions, 384 | params, 385 | formattedResult, 386 | ); 387 | } 388 | 389 | // Otherwise, present JSON directly. 390 | const payload = pretty 391 | ? JSON.stringify(formattedResult, null, 2) 392 | : formattedResult; 393 | response.type = 'application/json'; 394 | response.body = payload; 395 | 396 | async function resolveOptions( 397 | requestParams?: GraphQLParams, 398 | ): Promise { 399 | const optionsResult = await Promise.resolve( 400 | typeof options === 'function' 401 | ? options(request, response, ctx, requestParams) 402 | : options, 403 | ); 404 | 405 | devAssertIsObject( 406 | optionsResult, 407 | 'GraphQL middleware option function must return an options object or a promise which will be resolved to an options object.', 408 | ); 409 | 410 | if (optionsResult.formatError) { 411 | // eslint-disable-next-line no-console 412 | console.warn( 413 | '`formatError` is deprecated and replaced by `customFormatErrorFn`. It will be removed in version 1.0.0.', 414 | ); 415 | } 416 | 417 | return optionsResult; 418 | } 419 | }; 420 | } 421 | 422 | function respondWithGraphiQL( 423 | response: Response, 424 | options?: GraphiQLOptions, 425 | params?: GraphQLParams, 426 | result?: FormattedExecutionResult, 427 | ): void { 428 | const data: GraphiQLData = { 429 | query: params?.query, 430 | variables: params?.variables, 431 | operationName: params?.operationName, 432 | result, 433 | }; 434 | const payload = renderGraphiQL(data, options); 435 | 436 | response.type = 'text/html'; 437 | response.body = payload; 438 | } 439 | 440 | /** 441 | * Helper function to determine if GraphiQL can be displayed. 442 | */ 443 | function canDisplayGraphiQL(request: Request, params: GraphQLParams): boolean { 444 | // If `raw` false, GraphiQL mode is not enabled. 445 | // Allowed to show GraphiQL if not requested as raw and this request prefers HTML over JSON. 446 | return !params.raw && request.accepts(['json', 'html']) === 'html'; 447 | } 448 | 449 | function devAssertIsObject(value: unknown, message: string): void { 450 | devAssert(value != null && typeof value === 'object', message); 451 | } 452 | 453 | function devAssertIsNonNullable(value: unknown, message: string): void { 454 | devAssert(value != null, message); 455 | } 456 | 457 | function devAssert(condition: unknown, message: string): void { 458 | const booleanCondition = Boolean(condition); 459 | if (!booleanCondition) { 460 | throw new TypeError(message); 461 | } 462 | } 463 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Koa Middleware 2 | 3 | [![npm version](https://badge.fury.io/js/koa-graphql.svg)](https://badge.fury.io/js/koa-graphql) 4 | [![Build Status](https://github.com/graphql-community/koa-graphql/workflows/CI/badge.svg?branch=main)](https://github.com/graphql-community/koa-graphql/actions?query=branch%3Amain) 5 | [![Coverage Status](https://codecov.io/gh/graphql-community/koa-graphql/branch/main/graph/badge.svg)](https://codecov.io/gh/graphql-community/koa-graphql) 6 | 7 | Create a GraphQL HTTP server with [Koa](https://koajs.com/). 8 | 9 | Port from [express-graphql](https://github.com/graphql/express-graphql). 10 | 11 | ## Installation 12 | 13 | ``` 14 | npm install --save koa-graphql 15 | ``` 16 | 17 | ### TypeScript 18 | 19 | This module includes a [TypeScript](https://www.typescriptlang.org/) 20 | declaration file to enable auto complete in compatible editors and type 21 | information for TypeScript projects. 22 | 23 | ## Simple Setup 24 | 25 | Mount `koa-graphql` as a route handler: 26 | 27 | ```js 28 | const Koa = require('koa'); 29 | const mount = require('koa-mount'); 30 | const { graphqlHTTP } = require('koa-graphql'); 31 | 32 | const app = new Koa(); 33 | 34 | app.use( 35 | mount( 36 | '/graphql', 37 | graphqlHTTP({ 38 | schema: MyGraphQLSchema, 39 | graphiql: true, 40 | }), 41 | ), 42 | ); 43 | 44 | app.listen(4000); 45 | ``` 46 | 47 | ## Setup with Koa Router 48 | 49 | With `@koa/router`: 50 | 51 | ```js 52 | const Koa = require('koa'); 53 | const Router = require('@koa/router'); 54 | const { graphqlHTTP } = require('koa-graphql'); 55 | 56 | const app = new Koa(); 57 | const router = new Router(); 58 | 59 | router.all( 60 | '/graphql', 61 | graphqlHTTP({ 62 | schema: MyGraphQLSchema, 63 | graphiql: true, 64 | }), 65 | ); 66 | 67 | app.use(router.routes()).use(router.allowedMethods()); 68 | ``` 69 | 70 | ## Setup with Koa v1 71 | 72 | For Koa 1, use [koa-convert](https://github.com/koajs/convert) to convert the middleware: 73 | 74 | ```js 75 | const koa = require('koa'); 76 | const mount = require('koa-mount'); // koa-mount@1.x 77 | const convert = require('koa-convert'); 78 | const { graphqlHTTP } = require('koa-graphql'); 79 | 80 | const app = koa(); 81 | 82 | app.use( 83 | mount( 84 | '/graphql', 85 | convert.back( 86 | graphqlHTTP({ 87 | schema: MyGraphQLSchema, 88 | graphiql: true, 89 | }), 90 | ), 91 | ), 92 | ); 93 | ``` 94 | 95 | ## Setup with Subscription Support 96 | 97 | ```js 98 | const Koa = require('koa'); 99 | const mount = require('koa-mount'); 100 | const { graphqlHTTP } = require('koa-graphql'); 101 | const typeDefs = require('./schema'); 102 | const resolvers = require('./resolvers'); 103 | const { makeExecutableSchema } = require('graphql-tools'); 104 | const schema = makeExecutableSchema({ 105 | typeDefs: typeDefs, 106 | resolvers: resolvers, 107 | }); 108 | const { execute, subscribe } = require('graphql'); 109 | const { createServer } = require('http'); 110 | const { SubscriptionServer } = require('subscriptions-transport-ws'); 111 | const PORT = 4000; 112 | const app = new Koa(); 113 | app.use( 114 | mount( 115 | '/graphql', 116 | graphqlHTTP({ 117 | schema: schema, 118 | graphiql: { 119 | subscriptionEndpoint: `ws://localhost:${PORT}/subscriptions`, 120 | }, 121 | }), 122 | ), 123 | ); 124 | const ws = createServer(app.callback()); 125 | ws.listen(PORT, () => { 126 | // Set up the WebSocket for handling GraphQL subscriptions. 127 | new SubscriptionServer( 128 | { 129 | execute, 130 | subscribe, 131 | schema, 132 | }, 133 | { 134 | server: ws, 135 | path: '/subscriptions', 136 | }, 137 | ); 138 | }); 139 | ``` 140 | 141 | ## Options 142 | 143 | The `graphqlHTTP` function accepts the following options: 144 | 145 | - **`schema`**: A `GraphQLSchema` instance from [`graphql-js`][]. 146 | A `schema` _must_ be provided. 147 | 148 | - **`graphiql`**: If `true`, presents [GraphiQL][] when the GraphQL endpoint is 149 | loaded in a browser. We recommend that you set `graphiql` to `true` when your 150 | app is in development, because it's quite useful. You may or may not want it 151 | in production. 152 | Alternatively, instead of `true` you can pass in an options object: 153 | 154 | - **`defaultQuery`**: An optional GraphQL string to use when no query 155 | is provided and no stored query exists from a previous session. 156 | If `undefined` is provided, GraphiQL will use its own default query. 157 | 158 | - **`headerEditorEnabled`**: An optional boolean which enables the header editor when true. 159 | Defaults to `false`. 160 | 161 | - **`subscriptionEndpoint`**: An optional GraphQL string contains the WebSocket server url for subscription. 162 | 163 | - **`websocketClient`**: An optional GraphQL string for websocket client used for subscription, `v0`: subscriptions-transport-ws, `v1`: graphql-ws. Defaults to `v0` if not provided 164 | 165 | - **`shouldPersistHeaders`** 166 | 167 | - **`editorTheme`**: By passing an object you may change the theme of GraphiQL. 168 | Details are below in the [Custom GraphiQL themes](#custom-graphiql-themes) section. 169 | 170 | - **`rootValue`**: A value to pass as the `rootValue` to the `execute()` 171 | function from [`graphql-js/src/execute.js`](https://github.com/graphql/graphql-js/blob/main/src/execution/execute.js#L129). 172 | 173 | - **`context`**: A value to pass as the `context` to the `execute()` 174 | function from [`graphql-js/src/execute.js`](https://github.com/graphql/graphql-js/blob/main/src/execution/execute.js#L130). If `context` is not provided, the 175 | `ctx` object is passed as the context. 176 | 177 | - **`pretty`**: If `true`, any JSON response will be pretty-printed. 178 | 179 | - **`extensions`**: An optional function for adding additional metadata to the 180 | GraphQL response as a key-value object. The result will be added to the 181 | `"extensions"` field in the resulting JSON. This is often a useful place to 182 | add development time metadata such as the runtime of a query or the amount 183 | of resources consumed. This may be an async function. The function is 184 | given one object as an argument: `{ document, variables, operationName, result, context }`. 185 | 186 | - **`validationRules`**: Optional additional validation rules that queries must 187 | satisfy in addition to those defined by the GraphQL spec. 188 | 189 | - **`customValidateFn`**: An optional function which will be used to validate 190 | instead of default `validate` from `graphql-js`. 191 | 192 | - **`customExecuteFn`**: An optional function which will be used to execute 193 | instead of default `execute` from `graphql-js`. 194 | 195 | - **`customFormatErrorFn`**: An optional function which will be used to format any 196 | errors produced by fulfilling a GraphQL operation. If no function is 197 | provided, GraphQL's default spec-compliant [`formatError`][] function will be used. 198 | 199 | - **`customParseFn`**: An optional function which will be used to create a document 200 | instead of the default `parse` from `graphql-js`. 201 | 202 | - **`formatError`**: is deprecated and replaced by `customFormatErrorFn`. It will be 203 | removed in version 1.0.0. 204 | 205 | - **`fieldResolver`** 206 | 207 | - **`typeResolver`** 208 | 209 | In addition to an object defining each option, options can also be provided as 210 | a function (or async function) which returns this options object. This function 211 | is provided the arguments `(request, response, graphQLParams)` and is called 212 | after the request has been parsed. 213 | 214 | The `graphQLParams` is provided as the object `{ query, variables, operationName, raw }`. 215 | 216 | ```js 217 | app.use( 218 | mount( 219 | '/graphql', 220 | graphqlHTTP(async (request, response, ctx, graphQLParams) => ({ 221 | schema: MyGraphQLSchema, 222 | rootValue: await someFunctionToGetRootValue(request), 223 | graphiql: true, 224 | })), 225 | ), 226 | ); 227 | ``` 228 | 229 | ## HTTP Usage 230 | 231 | Once installed at a path, `koa-graphql` will accept requests with 232 | the parameters: 233 | 234 | - **`query`**: A string GraphQL document to be executed. 235 | 236 | - **`variables`**: The runtime values to use for any GraphQL query variables 237 | as a JSON object. 238 | 239 | - **`operationName`**: If the provided `query` contains multiple named 240 | operations, this specifies which operation should be executed. If not 241 | provided, a 400 error will be returned if the `query` contains multiple 242 | named operations. 243 | 244 | - **`raw`**: If the `graphiql` option is enabled and the `raw` parameter is 245 | provided, raw JSON will always be returned instead of GraphiQL even when 246 | loaded from a browser. 247 | 248 | GraphQL will first look for each parameter in the query string of a URL: 249 | 250 | ``` 251 | /graphql?query=query+getUser($id:ID){user(id:$id){name}}&variables={"id":"4"} 252 | ``` 253 | 254 | If not found in the query string, it will look in the POST request body. 255 | 256 | If a previous middleware has already parsed the POST body, the `request.body` 257 | value will be used. Use [`multer`][] or a similar middleware to add support 258 | for `multipart/form-data` content, which may be useful for GraphQL mutations 259 | involving uploading files. See an [example using multer](https://github.com/graphql-community/koa-graphql/blob/e1a98f3548203a3c41fedf3d4267846785480d28/src/__tests__/http-test.js#L664-L732). 260 | 261 | If the POST body has not yet been parsed, `koa-graphql` will interpret it 262 | depending on the provided _Content-Type_ header. 263 | 264 | - **`application/json`**: the POST body will be parsed as a JSON 265 | object of parameters. 266 | 267 | - **`application/x-www-form-urlencoded`**: the POST body will be 268 | parsed as a url-encoded string of key-value pairs. 269 | 270 | - **`application/graphql`**: the POST body will be parsed as GraphQL 271 | query string, which provides the `query` parameter. 272 | 273 | ## Combining with Other koa Middleware 274 | 275 | By default, the koa request is passed as the GraphQL `context`. 276 | Since most koa middleware operates by adding extra data to the 277 | request object, this means you can use most koa middleware just by inserting it before `graphqlHTTP` is mounted. This covers scenarios such as authenticating the user, handling file uploads, or mounting GraphQL on a dynamic endpoint. 278 | 279 | This example uses [`koa-session`][] to provide GraphQL with the currently logged-in session. 280 | 281 | ```js 282 | const Koa = require('koa'); 283 | const mount = require('koa-mount'); 284 | const session = require('koa-session'); 285 | const { graphqlHTTP } = require('koa-graphql'); 286 | 287 | const app = new Koa(); 288 | app.keys = ['some secret']; 289 | app.use(session(app)); 290 | app.use(function* (next) { 291 | this.session.id = 'me'; 292 | yield next; 293 | }); 294 | 295 | app.use( 296 | mount( 297 | '/graphql', 298 | graphqlHTTP({ 299 | schema: MySessionAwareGraphQLSchema, 300 | graphiql: true, 301 | }), 302 | ), 303 | ); 304 | ``` 305 | 306 | Then in your type definitions, you can access the ctx via the third "context" argument in your `resolve` function: 307 | 308 | ```js 309 | new GraphQLObjectType({ 310 | name: 'MyType', 311 | fields: { 312 | myField: { 313 | type: GraphQLString, 314 | resolve(parentValue, args, ctx) { 315 | // use `ctx.session` here 316 | }, 317 | }, 318 | }, 319 | }); 320 | ``` 321 | 322 | ## Providing Extensions 323 | 324 | The GraphQL response allows for adding additional information in a response to 325 | a GraphQL query via a field in the response called `"extensions"`. This is added 326 | by providing an `extensions` function when using `graphqlHTTP`. The function 327 | must return a JSON-serializable Object. 328 | 329 | When called, this is provided an argument which you can use to get information 330 | about the GraphQL request: 331 | 332 | `{ document, variables, operationName, result, context }` 333 | 334 | This example illustrates adding the amount of time consumed by running the 335 | provided query, which could perhaps be used by your development tools. 336 | 337 | ```js 338 | const { graphqlHTTP } = require('koa-graphql'); 339 | 340 | const app = new Koa(); 341 | 342 | const extensions = ({ 343 | document, 344 | variables, 345 | operationName, 346 | result, 347 | context, 348 | }) => { 349 | return { 350 | runTime: Date.now() - context.startTime, 351 | }; 352 | }; 353 | 354 | app.use( 355 | mount( 356 | '/graphql', 357 | graphqlHTTP((request) => { 358 | return { 359 | schema: MyGraphQLSchema, 360 | context: { startTime: Date.now() }, 361 | graphiql: true, 362 | extensions, 363 | }; 364 | }), 365 | ), 366 | ); 367 | ``` 368 | 369 | When querying this endpoint, it would include this information in the result, 370 | for example: 371 | 372 | ```js 373 | { 374 | "data": { ... }, 375 | "extensions": { 376 | "runTime": 135 377 | } 378 | } 379 | ``` 380 | 381 | ## Additional Validation Rules 382 | 383 | GraphQL's [validation phase](https://graphql.github.io/graphql-spec/#sec-Validation) checks the query to ensure that it can be successfully executed against the schema. The `validationRules` option allows for additional rules to be run during this phase. Rules are applied to each node in an AST representing the query using the Visitor pattern. 384 | 385 | A validation rule is a function which returns a visitor for one or more node Types. Below is an example of a validation preventing the specific field name `metadata` from being queried. For more examples, see the [`specifiedRules`](https://github.com/graphql/graphql-js/tree/main/src/validation/rules) in the [graphql-js](https://github.com/graphql/graphql-js) package. 386 | 387 | ```js 388 | import { GraphQLError } from 'graphql'; 389 | 390 | export function DisallowMetadataQueries(context) { 391 | return { 392 | Field(node) { 393 | const fieldName = node.name.value; 394 | 395 | if (fieldName === 'metadata') { 396 | context.reportError( 397 | new GraphQLError( 398 | `Validation: Requesting the field ${fieldName} is not allowed`, 399 | ), 400 | ); 401 | } 402 | }, 403 | }; 404 | } 405 | ``` 406 | 407 | ### Disabling Introspection 408 | 409 | Disabling introspection does not reflect best practices and does not necessarily make your 410 | application any more secure. Nevertheless, disabling introspection is possible by utilizing the 411 | `NoSchemaIntrospectionCustomRule` provided by the [graphql-js](https://github.com/graphql/graphql-js) 412 | package. 413 | 414 | ```js 415 | import { NoSchemaIntrospectionCustomRule } from 'graphql'; 416 | 417 | app.use( 418 | mount( 419 | '/graphql', 420 | graphqlHTTP((request) => { 421 | return { 422 | schema: MyGraphQLSchema, 423 | validationRules: [NoSchemaIntrospectionCustomRule], 424 | }; 425 | }), 426 | ), 427 | ); 428 | ``` 429 | 430 | ## Custom GraphiQL Themes 431 | 432 | To use custom GraphiQL theme you should pass to `graphiql` option an object with 433 | the property `editorTheme`. It could be a string with the name of a theme from `CodeMirror` 434 | 435 | ```js 436 | router.all( 437 | '/graphql', 438 | graphqlHTTP({ 439 | schema: MyGraphQLSchema, 440 | graphiql: { 441 | editorTheme: 'blackboard', 442 | }, 443 | }), 444 | ); 445 | ``` 446 | 447 | [List of available CodeMirror themes](https://codemirror.net/demo/theme.html) 448 | 449 | or an object with `url` and `name` properties where `url` should lead to 450 | your custom theme and `name` would be passed to the `GraphiQL` 451 | react element on creation as the `editorTheme` property 452 | 453 | ```js 454 | router.all( 455 | '/graphql', 456 | graphqlHTTP({ 457 | schema: MyGraphQLSchema, 458 | graphiql: { 459 | editorTheme: { 460 | name: 'blackboard', 461 | url: 'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.53.2/theme/erlang-dark.css', 462 | }, 463 | }, 464 | }), 465 | ); 466 | ``` 467 | 468 | For details see the [GraphiQL spec](https://github.com/graphql/graphiql/tree/master/packages/graphiql#applying-an-editor-theme) 469 | 470 | ## Additional Validation Rules 471 | 472 | GraphQL's [validation phase](https://graphql.github.io/graphql-spec/#sec-Validation) checks the query to ensure that it can be successfully executed against the schema. The `validationRules` option allows for additional rules to be run during this phase. Rules are applied to each node in an AST representing the query using the Visitor pattern. 473 | 474 | A validation rule is a function which returns a visitor for one or more node Types. Below is an example of a validation preventing the specific field name `metadata` from being queried. For more examples see the [`specifiedRules`](https://github.com/graphql/graphql-js/tree/main/src/validation/rules) in the [graphql-js](https://github.com/graphql/graphql-js) package. 475 | 476 | ```js 477 | import { GraphQLError } from 'graphql'; 478 | 479 | export function DisallowMetadataQueries(context) { 480 | return { 481 | Field(node) { 482 | const fieldName = node.name.value; 483 | 484 | if (fieldName === 'metadata') { 485 | context.reportError( 486 | new GraphQLError( 487 | `Validation: Requesting the field ${fieldName} is not allowed`, 488 | ), 489 | ); 490 | } 491 | }, 492 | }; 493 | } 494 | ``` 495 | 496 | ## Debugging Tips 497 | 498 | During development, it's useful to get more information from errors, such as 499 | stack traces. Providing a function to `customFormatErrorFn` enables this: 500 | 501 | ```js 502 | customFormatErrorFn: (error, ctx) => ({ 503 | message: error.message, 504 | locations: error.locations, 505 | stack: error.stack ? error.stack.split('\n') : [], 506 | path: error.path, 507 | }); 508 | ``` 509 | 510 | ### Examples 511 | 512 | - [tests](https://github.com/graphql-community/koa-graphql/blob/main/src/__tests__/http-test.js) 513 | 514 | ### Other Relevant Projects 515 | 516 | Please checkout [awesome-graphql](https://github.com/chentsulin/awesome-graphql). 517 | 518 | ### Contributing 519 | 520 | Welcome pull requests! 521 | 522 | ### License 523 | 524 | MIT 525 | 526 | [`graphql-js`]: https://github.com/graphql/graphql-js 527 | [`formaterror`]: https://github.com/graphql/graphql-js/blob/main/src/error/formatError.js 528 | [graphiql]: https://github.com/graphql/graphiql 529 | [`multer`]: https://github.com/expressjs/multer 530 | [`koa-session`]: https://github.com/koajs/session 531 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | sourceType: script 3 | ecmaVersion: 2020 4 | env: 5 | es6: true 6 | node: true 7 | reportUnusedDisableDirectives: true 8 | plugins: 9 | - internal-rules 10 | - node 11 | - istanbul 12 | - import 13 | settings: 14 | node: 15 | tryExtensions: ['.js', '.json', '.node', '.ts', '.d.ts'] 16 | 17 | rules: 18 | ############################################################################## 19 | # Internal rules located in 'resources/eslint-internal-rules'. 20 | # See './resources/eslint-internal-rules/README.md' 21 | ############################################################################## 22 | 23 | internal-rules/no-dir-import: error 24 | 25 | ############################################################################## 26 | # `eslint-plugin-istanbul` rule list based on `v0.1.2` 27 | # https://github.com/istanbuljs/eslint-plugin-istanbul#rules 28 | ############################################################################## 29 | 30 | istanbul/no-ignore-file: error 31 | istanbul/prefer-ignore-reason: error 32 | 33 | ############################################################################## 34 | # `eslint-plugin-node` rule list based on `v11.1.x` 35 | ############################################################################## 36 | 37 | # Possible Errors 38 | # https://github.com/mysticatea/eslint-plugin-node#possible-errors 39 | 40 | node/handle-callback-err: [error, error] 41 | node/no-callback-literal: error 42 | node/no-exports-assign: error 43 | node/no-extraneous-import: error 44 | node/no-extraneous-require: error 45 | node/no-missing-import: error 46 | node/no-missing-require: error 47 | node/no-new-require: error 48 | node/no-path-concat: error 49 | node/no-process-exit: off 50 | node/no-unpublished-bin: error 51 | ## TODO: https://github.com/mysticatea/eslint-plugin-node/issues/236#issuecomment-934875131 52 | node/no-unpublished-import: [error, { allowModules: ['koa'] }] 53 | node/no-unpublished-require: error 54 | node/no-unsupported-features/es-builtins: error 55 | node/no-unsupported-features/es-syntax: error 56 | node/no-unsupported-features/node-builtins: error 57 | node/process-exit-as-throw: error 58 | node/shebang: error 59 | 60 | # Best Practices 61 | # https://github.com/mysticatea/eslint-plugin-node#best-practices 62 | node/no-deprecated-api: error 63 | 64 | # Stylistic Issues 65 | # https://github.com/mysticatea/eslint-plugin-node#stylistic-issues 66 | 67 | node/callback-return: error 68 | node/exports-style: off # TODO consider 69 | node/file-extension-in-import: off # TODO consider 70 | node/global-require: error 71 | node/no-mixed-requires: error 72 | node/no-process-env: off 73 | node/no-restricted-import: off 74 | node/no-restricted-require: off 75 | node/no-sync: error 76 | node/prefer-global/buffer: error 77 | node/prefer-global/console: error 78 | node/prefer-global/process: error 79 | node/prefer-global/text-decoder: error 80 | node/prefer-global/text-encoder: error 81 | node/prefer-global/url-search-params: error 82 | node/prefer-global/url: error 83 | node/prefer-promises/dns: error 84 | node/prefer-promises/fs: error 85 | 86 | ############################################################################## 87 | # `eslint-plugin-import` rule list based on `v2.22.x` 88 | ############################################################################## 89 | 90 | # Static analysis 91 | # https://github.com/benmosher/eslint-plugin-import#static-analysis 92 | import/no-unresolved: error 93 | import/named: error 94 | import/default: error 95 | import/namespace: error 96 | import/no-restricted-paths: off 97 | import/no-absolute-path: error 98 | import/no-dynamic-require: error 99 | import/no-internal-modules: off 100 | import/no-webpack-loader-syntax: error 101 | import/no-self-import: error 102 | import/no-cycle: error 103 | import/no-useless-path-segments: error 104 | import/no-relative-parent-imports: off 105 | 106 | # Helpful warnings 107 | # https://github.com/benmosher/eslint-plugin-import#helpful-warnings 108 | import/export: error 109 | import/no-named-as-default: error 110 | import/no-named-as-default-member: error 111 | import/no-deprecated: error 112 | import/no-extraneous-dependencies: [error, { devDependencies: false }] 113 | import/no-mutable-exports: error 114 | import/no-unused-modules: error 115 | 116 | # Module systems 117 | # https://github.com/benmosher/eslint-plugin-import#module-systems 118 | import/unambiguous: error 119 | import/no-commonjs: error 120 | import/no-amd: error 121 | import/no-nodejs-modules: off 122 | 123 | # Style guide 124 | # https://github.com/benmosher/eslint-plugin-import#style-guide 125 | import/first: error 126 | import/exports-last: off 127 | import/no-duplicates: error 128 | import/no-namespace: error 129 | import/extensions: [error, never] # TODO: switch to ignorePackages 130 | import/order: [error, { newlines-between: always-and-inside-groups }] 131 | import/newline-after-import: error 132 | import/prefer-default-export: off 133 | import/max-dependencies: off 134 | import/no-unassigned-import: error 135 | import/no-named-default: error 136 | import/no-default-export: off 137 | import/no-named-export: off 138 | import/no-anonymous-default-export: error 139 | import/group-exports: off 140 | import/dynamic-import-chunkname: off 141 | 142 | ############################################################################## 143 | # ESLint builtin rules list based on `v7.13.x` 144 | ############################################################################## 145 | 146 | # Possible Errors 147 | # https://eslint.org/docs/rules/#possible-errors 148 | 149 | for-direction: error 150 | getter-return: error 151 | no-async-promise-executor: error 152 | no-await-in-loop: error 153 | no-compare-neg-zero: error 154 | no-cond-assign: error 155 | no-console: warn 156 | no-constant-condition: error 157 | no-control-regex: error 158 | no-debugger: warn 159 | no-dupe-args: error 160 | no-dupe-else-if: error 161 | no-dupe-keys: error 162 | no-duplicate-case: error 163 | no-empty: error 164 | no-empty-character-class: error 165 | no-ex-assign: error 166 | no-extra-boolean-cast: error 167 | no-func-assign: error 168 | no-import-assign: error 169 | no-inner-declarations: [error, both] 170 | no-invalid-regexp: error 171 | no-irregular-whitespace: error 172 | no-loss-of-precision: error 173 | no-misleading-character-class: error 174 | no-obj-calls: error 175 | no-promise-executor-return: error 176 | no-prototype-builtins: error 177 | no-regex-spaces: error 178 | no-setter-return: error 179 | no-sparse-arrays: error 180 | no-template-curly-in-string: error 181 | no-unreachable: error 182 | no-unreachable-loop: error 183 | no-unsafe-finally: error 184 | no-unsafe-negation: error 185 | no-useless-backreference: error 186 | require-atomic-updates: error 187 | use-isnan: error 188 | valid-typeof: error 189 | 190 | # Best Practices 191 | # https://eslint.org/docs/rules/#best-practices 192 | 193 | accessor-pairs: error 194 | array-callback-return: error 195 | block-scoped-var: error 196 | class-methods-use-this: off 197 | complexity: off 198 | consistent-return: off 199 | curly: error 200 | default-case: off 201 | default-case-last: error 202 | default-param-last: error 203 | dot-notation: error 204 | eqeqeq: [error, smart] 205 | grouped-accessor-pairs: error 206 | guard-for-in: error 207 | max-classes-per-file: off 208 | no-alert: error 209 | no-caller: error 210 | no-case-declarations: error 211 | no-constructor-return: error 212 | no-div-regex: error 213 | no-else-return: error 214 | no-empty-function: error 215 | no-empty-pattern: error 216 | no-eq-null: off 217 | no-eval: error 218 | no-extend-native: error 219 | no-extra-bind: error 220 | no-extra-label: error 221 | no-fallthrough: error 222 | no-global-assign: error 223 | no-implicit-coercion: error 224 | no-implicit-globals: off 225 | no-implied-eval: error 226 | no-invalid-this: error 227 | no-iterator: error 228 | no-labels: error 229 | no-lone-blocks: error 230 | no-loop-func: error 231 | no-magic-numbers: off 232 | no-multi-str: error 233 | no-new: error 234 | no-new-func: error 235 | no-new-wrappers: error 236 | no-octal: error 237 | no-octal-escape: error 238 | no-param-reassign: error 239 | no-proto: error 240 | no-redeclare: error 241 | no-restricted-properties: off 242 | no-return-assign: error 243 | no-return-await: error 244 | no-script-url: error 245 | no-self-assign: error 246 | no-self-compare: error 247 | no-sequences: error 248 | no-throw-literal: error 249 | no-unmodified-loop-condition: error 250 | no-unused-expressions: error 251 | no-unused-labels: error 252 | no-useless-call: error 253 | no-useless-catch: error 254 | no-useless-concat: error 255 | no-useless-escape: error 256 | no-useless-return: error 257 | no-void: error 258 | no-warning-comments: off 259 | no-with: error 260 | prefer-named-capture-group: error 261 | prefer-promise-reject-errors: error 262 | prefer-regex-literals: error 263 | radix: error 264 | require-await: error 265 | require-unicode-regexp: off 266 | vars-on-top: error 267 | yoda: [error, never, { exceptRange: true }] 268 | 269 | # Strict Mode 270 | # https://eslint.org/docs/rules/#strict-mode 271 | 272 | strict: error 273 | 274 | # Variables 275 | # https://eslint.org/docs/rules/#variables 276 | 277 | init-declarations: off 278 | no-delete-var: error 279 | no-label-var: error 280 | no-restricted-globals: off 281 | no-shadow: error 282 | no-shadow-restricted-names: error 283 | no-undef: error 284 | no-undef-init: error 285 | no-undefined: off 286 | no-unused-vars: [error, { vars: all, args: all, argsIgnorePattern: '^_' }] 287 | no-use-before-define: off 288 | 289 | # Stylistic Issues 290 | # https://eslint.org/docs/rules/#stylistic-issues 291 | 292 | camelcase: error 293 | capitalized-comments: off # maybe 294 | consistent-this: off 295 | func-name-matching: off 296 | func-names: off 297 | func-style: off 298 | id-denylist: off 299 | id-length: off 300 | id-match: [error, '^(?:_?[a-zA-Z0-9]*)|[_A-Z0-9]+$'] 301 | line-comment-position: off 302 | lines-around-comment: off 303 | lines-between-class-members: [error, always, { exceptAfterSingleLine: true }] 304 | max-depth: off 305 | max-lines: off 306 | max-lines-per-function: off 307 | max-nested-callbacks: off 308 | max-params: off 309 | max-statements: off 310 | max-statements-per-line: off 311 | multiline-comment-style: off 312 | new-cap: error 313 | no-array-constructor: error 314 | no-bitwise: off 315 | no-continue: off 316 | no-inline-comments: off 317 | no-lonely-if: error 318 | no-multi-assign: off 319 | no-negated-condition: off 320 | no-nested-ternary: off 321 | no-new-object: error 322 | no-plusplus: off 323 | no-restricted-syntax: off 324 | no-tabs: error 325 | no-ternary: off 326 | no-underscore-dangle: error 327 | no-unneeded-ternary: error 328 | one-var: [error, never] 329 | operator-assignment: error 330 | padding-line-between-statements: off 331 | prefer-exponentiation-operator: error 332 | prefer-object-spread: error 333 | quotes: [error, single, { avoidEscape: true }] 334 | sort-keys: off 335 | sort-vars: off 336 | spaced-comment: error 337 | 338 | # ECMAScript 6 339 | # https://eslint.org/docs/rules/#ecmascript-6 340 | 341 | arrow-body-style: error 342 | constructor-super: error 343 | no-class-assign: error 344 | no-const-assign: error 345 | no-dupe-class-members: error 346 | no-duplicate-imports: off # Superseded by `import/no-duplicates` 347 | no-new-symbol: error 348 | no-restricted-exports: off 349 | no-restricted-imports: off 350 | no-this-before-super: error 351 | no-useless-computed-key: error 352 | no-useless-constructor: error 353 | no-useless-rename: error 354 | no-var: error 355 | object-shorthand: error 356 | prefer-arrow-callback: error 357 | prefer-const: error 358 | prefer-destructuring: off 359 | prefer-numeric-literals: error 360 | prefer-rest-params: error 361 | prefer-spread: error 362 | prefer-template: off 363 | require-yield: error 364 | sort-imports: off 365 | symbol-description: off 366 | 367 | # Bellow rules are disabled because coflicts with Prettier, see: 368 | # https://github.com/prettier/eslint-config-prettier/blob/master/index.js 369 | array-bracket-newline: off 370 | array-bracket-spacing: off 371 | array-element-newline: off 372 | arrow-parens: off 373 | arrow-spacing: off 374 | block-spacing: off 375 | brace-style: off 376 | comma-dangle: off 377 | comma-spacing: off 378 | comma-style: off 379 | computed-property-spacing: off 380 | dot-location: off 381 | eol-last: off 382 | func-call-spacing: off 383 | function-call-argument-newline: off 384 | function-paren-newline: off 385 | generator-star-spacing: off 386 | implicit-arrow-linebreak: off 387 | indent: off 388 | jsx-quotes: off 389 | key-spacing: off 390 | keyword-spacing: off 391 | linebreak-style: off 392 | max-len: off 393 | multiline-ternary: off 394 | newline-per-chained-call: off 395 | new-parens: off 396 | no-confusing-arrow: off 397 | no-extra-parens: off 398 | no-extra-semi: off 399 | no-floating-decimal: off 400 | no-mixed-operators: off 401 | no-mixed-spaces-and-tabs: off 402 | no-multi-spaces: off 403 | no-multiple-empty-lines: off 404 | no-trailing-spaces: off 405 | no-unexpected-multiline: off 406 | no-whitespace-before-property: off 407 | nonblock-statement-body-position: off 408 | object-curly-newline: off 409 | object-curly-spacing: off 410 | object-property-newline: off 411 | one-var-declaration-per-line: off 412 | operator-linebreak: off 413 | padded-blocks: off 414 | quote-props: off 415 | rest-spread-spacing: off 416 | semi: off 417 | semi-spacing: off 418 | semi-style: off 419 | space-before-blocks: off 420 | space-before-function-paren: off 421 | space-in-parens: off 422 | space-infix-ops: off 423 | space-unary-ops: off 424 | switch-colon-spacing: off 425 | template-curly-spacing: off 426 | template-tag-spacing: off 427 | unicode-bom: off 428 | wrap-iife: off 429 | wrap-regex: off 430 | yield-star-spacing: off 431 | 432 | overrides: 433 | - files: '**/*.ts' 434 | parser: '@typescript-eslint/parser' 435 | parserOptions: 436 | sourceType: module 437 | project: ['tsconfig.json'] 438 | plugins: 439 | - '@typescript-eslint' 440 | extends: 441 | - plugin:import/typescript 442 | rules: 443 | node/no-unsupported-features/es-syntax: off 444 | 445 | ########################################################################## 446 | # `@typescript-eslint/eslint-plugin` rule list based on `v4.8.x` 447 | ########################################################################## 448 | 449 | # Supported Rules 450 | # https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules 451 | '@typescript-eslint/adjacent-overload-signatures': error 452 | '@typescript-eslint/array-type': [error, { default: generic }] 453 | '@typescript-eslint/await-thenable': error 454 | '@typescript-eslint/ban-ts-comment': [error, { 'ts-expect-error': false }] 455 | '@typescript-eslint/ban-tslint-comment': error 456 | '@typescript-eslint/ban-types': error 457 | '@typescript-eslint/class-literal-property-style': error 458 | '@typescript-eslint/consistent-indexed-object-style': off # TODO enable 459 | '@typescript-eslint/consistent-type-assertions': 460 | [error, { assertionStyle: as, objectLiteralTypeAssertions: never }] 461 | '@typescript-eslint/consistent-type-definitions': off # TODO consider 462 | '@typescript-eslint/consistent-type-imports': error 463 | '@typescript-eslint/explicit-function-return-type': off # TODO consider 464 | '@typescript-eslint/explicit-member-accessibility': off # TODO consider 465 | '@typescript-eslint/explicit-module-boundary-types': off # TODO consider 466 | '@typescript-eslint/member-ordering': off # TODO consider 467 | '@typescript-eslint/method-signature-style': error 468 | '@typescript-eslint/naming-convention': off # TODO consider 469 | '@typescript-eslint/no-base-to-string': error 470 | '@typescript-eslint/no-confusing-non-null-assertion': error 471 | '@typescript-eslint/no-confusing-void-expression': off # FIXME 472 | '@typescript-eslint/no-dynamic-delete': off 473 | '@typescript-eslint/no-empty-interface': error 474 | '@typescript-eslint/no-explicit-any': off # TODO error 475 | '@typescript-eslint/no-extra-non-null-assertion': error 476 | '@typescript-eslint/no-extraneous-class': off # TODO consider 477 | '@typescript-eslint/no-floating-promises': error 478 | '@typescript-eslint/no-for-in-array': error 479 | '@typescript-eslint/no-implicit-any-catch': error 480 | '@typescript-eslint/no-implied-eval': error 481 | '@typescript-eslint/no-inferrable-types': 482 | [error, { ignoreParameters: true, ignoreProperties: true }] 483 | '@typescript-eslint/no-misused-new': error 484 | '@typescript-eslint/no-misused-promises': error 485 | '@typescript-eslint/no-namespace': error 486 | '@typescript-eslint/no-non-null-asserted-optional-chain': error 487 | '@typescript-eslint/no-non-null-assertion': error 488 | '@typescript-eslint/no-parameter-properties': error 489 | '@typescript-eslint/no-invalid-void-type': error 490 | '@typescript-eslint/no-require-imports': error 491 | '@typescript-eslint/no-this-alias': error 492 | '@typescript-eslint/no-throw-literal': error 493 | '@typescript-eslint/no-type-alias': off # TODO consider 494 | '@typescript-eslint/no-unnecessary-boolean-literal-compare': error 495 | '@typescript-eslint/no-unnecessary-condition': error 496 | '@typescript-eslint/no-unnecessary-qualifier': error 497 | '@typescript-eslint/no-unnecessary-type-arguments': error 498 | '@typescript-eslint/no-unnecessary-type-assertion': error 499 | '@typescript-eslint/no-unnecessary-type-constraint': off # TODO consider 500 | '@typescript-eslint/no-unsafe-assignment': off # TODO consider 501 | '@typescript-eslint/no-unsafe-call': off # TODO consider 502 | '@typescript-eslint/no-unsafe-member-access': off # TODO consider 503 | '@typescript-eslint/no-unsafe-return': off # TODO consider 504 | '@typescript-eslint/no-var-requires': error 505 | '@typescript-eslint/prefer-as-const': off # TODO consider 506 | '@typescript-eslint/prefer-enum-initializers': off # TODO consider 507 | '@typescript-eslint/prefer-for-of': error 508 | '@typescript-eslint/prefer-function-type': error 509 | '@typescript-eslint/prefer-includes': error 510 | '@typescript-eslint/prefer-literal-enum-member': error 511 | '@typescript-eslint/prefer-namespace-keyword': error 512 | '@typescript-eslint/prefer-nullish-coalescing': error 513 | '@typescript-eslint/prefer-optional-chain': error 514 | '@typescript-eslint/prefer-readonly': error 515 | '@typescript-eslint/prefer-readonly-parameter-types': off # TODO consider 516 | '@typescript-eslint/prefer-reduce-type-parameter': error 517 | '@typescript-eslint/prefer-regexp-exec': error 518 | '@typescript-eslint/prefer-ts-expect-error': error 519 | '@typescript-eslint/prefer-string-starts-ends-with': error 520 | '@typescript-eslint/promise-function-async': off 521 | '@typescript-eslint/require-array-sort-compare': error 522 | '@typescript-eslint/restrict-plus-operands': 523 | [error, { checkCompoundAssignments: true }] 524 | '@typescript-eslint/restrict-template-expressions': error 525 | '@typescript-eslint/strict-boolean-expressions': error 526 | '@typescript-eslint/switch-exhaustiveness-check': error 527 | '@typescript-eslint/triple-slash-reference': error 528 | '@typescript-eslint/typedef': off 529 | '@typescript-eslint/unbound-method': off # TODO consider 530 | '@typescript-eslint/unified-signatures': error 531 | 532 | # Extension Rules 533 | # https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#extension-rules 534 | 535 | # Disable conflicting ESLint rules and enable TS-compatible ones 536 | default-param-last: off 537 | dot-notation: off 538 | lines-between-class-members: off 539 | no-array-constructor: off 540 | no-dupe-class-members: off 541 | no-empty-function: off 542 | no-invalid-this: off 543 | no-loop-func: off 544 | no-loss-of-precision: off 545 | no-redeclare: off 546 | no-shadow: off 547 | no-unused-expressions: off 548 | no-unused-vars: off 549 | no-useless-constructor: off 550 | require-await: off 551 | no-return-await: off 552 | '@typescript-eslint/default-param-last': error 553 | '@typescript-eslint/dot-notation': error 554 | '@typescript-eslint/lines-between-class-members': 555 | [error, always, { exceptAfterSingleLine: true }] 556 | '@typescript-eslint/no-array-constructor': error 557 | '@typescript-eslint/no-dupe-class-members': error 558 | '@typescript-eslint/no-empty-function': error 559 | '@typescript-eslint/no-invalid-this': error 560 | '@typescript-eslint/no-loop-func': error 561 | '@typescript-eslint/no-loss-of-precision': error 562 | '@typescript-eslint/no-redeclare': error 563 | '@typescript-eslint/no-shadow': error 564 | '@typescript-eslint/no-unused-expressions': error 565 | '@typescript-eslint/no-unused-vars': 566 | [ 567 | error, 568 | { 569 | vars: all, 570 | args: all, 571 | argsIgnorePattern: '^_', 572 | varsIgnorePattern: '^_T', 573 | }, 574 | ] 575 | '@typescript-eslint/no-useless-constructor': error 576 | '@typescript-eslint/require-await': error 577 | '@typescript-eslint/return-await': error 578 | 579 | # Disable for JS, Flow and TS 580 | '@typescript-eslint/init-declarations': off 581 | '@typescript-eslint/no-magic-numbers': off 582 | '@typescript-eslint/no-use-before-define': off 583 | '@typescript-eslint/no-duplicate-imports': off # Superseded by `import/no-duplicates` 584 | 585 | # Bellow rules are disabled because coflicts with Prettier, see: 586 | # https://github.com/prettier/eslint-config-prettier/blob/master/%40typescript-eslint.js 587 | '@typescript-eslint/quotes': off 588 | '@typescript-eslint/brace-style': off 589 | '@typescript-eslint/comma-dangle': off 590 | '@typescript-eslint/comma-spacing': off 591 | '@typescript-eslint/func-call-spacing': off 592 | '@typescript-eslint/indent': off 593 | '@typescript-eslint/keyword-spacing': off 594 | '@typescript-eslint/member-delimiter-style': off 595 | '@typescript-eslint/no-extra-parens': off 596 | '@typescript-eslint/no-extra-semi': off 597 | '@typescript-eslint/semi': off 598 | '@typescript-eslint/space-before-function-paren': off 599 | '@typescript-eslint/space-infix-ops': off 600 | '@typescript-eslint/type-annotation-spacing': off 601 | - files: ['src/**/__*__/**', 'integrationTests/**'] 602 | rules: 603 | node/no-unpublished-import: off 604 | node/no-unpublished-require: off 605 | node/no-sync: off 606 | import/no-restricted-paths: off 607 | import/no-extraneous-dependencies: [error, { devDependencies: true }] 608 | import/no-nodejs-modules: off 609 | - files: 'integrationTests/*/**' 610 | rules: 611 | node/no-missing-require: off 612 | no-console: off 613 | - files: 'resources/**' 614 | rules: 615 | node/no-unpublished-import: off 616 | node/no-unpublished-require: off 617 | node/no-missing-require: off 618 | node/no-sync: off 619 | node/global-require: off 620 | import/no-dynamic-require: off 621 | import/no-extraneous-dependencies: [error, { devDependencies: true }] 622 | import/no-nodejs-modules: off 623 | import/no-commonjs: off 624 | no-await-in-loop: off 625 | no-console: off 626 | - files: 'examples/**' 627 | rules: 628 | internal-rules/no-dir-import: off 629 | node/no-unpublished-import: off 630 | import/no-extraneous-dependencies: [error, { devDependencies: true }] 631 | no-console: off 632 | -------------------------------------------------------------------------------- /src/__tests__/http-test.ts: -------------------------------------------------------------------------------- 1 | import zlib from 'zlib'; 2 | import type { Readable } from 'stream'; 3 | 4 | import Koa from 'koa'; 5 | import mount from 'koa-mount'; 6 | import session from 'koa-session'; 7 | import parseBody from 'co-body'; 8 | import getRawBody from 'raw-body'; 9 | import request from 'supertest'; 10 | 11 | import type { ASTVisitor, ValidationContext } from 'graphql'; 12 | import sinon from 'sinon'; 13 | import multer from 'multer'; 14 | import { expect } from 'chai'; 15 | import { describe, it } from 'mocha'; 16 | import { 17 | Source, 18 | GraphQLError, 19 | GraphQLString, 20 | GraphQLNonNull, 21 | GraphQLObjectType, 22 | GraphQLSchema, 23 | parse, 24 | execute, 25 | validate, 26 | buildSchema, 27 | } from 'graphql'; 28 | 29 | import { graphqlHTTP } from '../index'; 30 | 31 | import multerWrapper from './helpers/koa-multer'; 32 | 33 | declare module 'koa' { 34 | interface Request { 35 | body?: any; 36 | rawBody: string; 37 | } 38 | } 39 | 40 | type MulterFile = { 41 | /** Name of the form field associated with this file. */ 42 | fieldname: string; 43 | /** Name of the file on the uploader's computer. */ 44 | originalname: string; 45 | /** 46 | * Value of the `Content-Transfer-Encoding` header for this file. 47 | * @deprecated since July 2015 48 | * @see RFC 7578, Section 4.7 49 | */ 50 | encoding: string; 51 | /** Value of the `Content-Type` header for this file. */ 52 | mimetype: string; 53 | /** Size of the file in bytes. */ 54 | size: number; 55 | /** 56 | * A readable stream of this file. Only available to the `_handleFile` 57 | * callback for custom `StorageEngine`s. 58 | */ 59 | stream: Readable; 60 | /** `DiskStorage` only: Directory to which this file has been uploaded. */ 61 | destination: string; 62 | /** `DiskStorage` only: Name of this file within `destination`. */ 63 | filename: string; 64 | /** `DiskStorage` only: Full path to the uploaded file. */ 65 | path: string; 66 | /** `MemoryStorage` only: A Buffer containing the entire file. */ 67 | buffer: Buffer; 68 | }; 69 | 70 | declare module 'http' { 71 | interface IncomingMessage { 72 | file?: MulterFile | undefined; 73 | /** 74 | * Array or dictionary of `Multer.File` object populated by `array()`, 75 | * `fields()`, and `any()` middleware. 76 | */ 77 | files?: 78 | | { 79 | [fieldname: string]: Array; 80 | } 81 | | Array 82 | | undefined; 83 | } 84 | } 85 | 86 | const QueryRootType = new GraphQLObjectType({ 87 | name: 'QueryRoot', 88 | fields: { 89 | test: { 90 | type: GraphQLString, 91 | args: { 92 | who: { type: GraphQLString }, 93 | }, 94 | resolve: (_root, args: { who?: string }) => 95 | 'Hello ' + (args.who ?? 'World'), 96 | }, 97 | thrower: { 98 | type: GraphQLString, 99 | resolve: () => { 100 | throw new Error('Throws!'); 101 | }, 102 | }, 103 | }, 104 | }); 105 | 106 | const TestSchema = new GraphQLSchema({ 107 | query: QueryRootType, 108 | mutation: new GraphQLObjectType({ 109 | name: 'MutationRoot', 110 | fields: { 111 | writeTest: { 112 | type: QueryRootType, 113 | resolve: () => ({}), 114 | }, 115 | }, 116 | }), 117 | }); 118 | 119 | function stringifyURLParams(urlParams?: { [param: string]: string }): string { 120 | return new URLSearchParams(urlParams).toString(); 121 | } 122 | 123 | function urlString(urlParams?: { [param: string]: string }): string { 124 | let string = '/graphql'; 125 | if (urlParams) { 126 | string += '?' + stringifyURLParams(urlParams); 127 | } 128 | return string; 129 | } 130 | 131 | function server() { 132 | const app = new Koa(); 133 | 134 | /* istanbul ignore next Error handler added only for debugging failed tests */ 135 | app.on('error', (error) => { 136 | // eslint-disable-next-line no-console 137 | console.warn('App encountered an error:', error); 138 | }); 139 | 140 | return app; 141 | } 142 | 143 | describe('GraphQL-HTTP tests', () => { 144 | describe('GET functionality', () => { 145 | it('allows GET with query param', async () => { 146 | const app = server(); 147 | 148 | app.use( 149 | mount( 150 | urlString(), 151 | graphqlHTTP({ 152 | schema: TestSchema, 153 | }), 154 | ), 155 | ); 156 | 157 | const response = await request(app.listen()).get( 158 | urlString({ 159 | query: '{test}', 160 | }), 161 | ); 162 | 163 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 164 | }); 165 | 166 | it('allows GET with variable values', async () => { 167 | const app = server(); 168 | 169 | app.use( 170 | mount( 171 | urlString(), 172 | graphqlHTTP({ 173 | schema: TestSchema, 174 | }), 175 | ), 176 | ); 177 | 178 | const response = await request(app.listen()).get( 179 | urlString({ 180 | query: 'query helloWho($who: String){ test(who: $who) }', 181 | variables: JSON.stringify({ who: 'Dolly' }), 182 | }), 183 | ); 184 | 185 | expect(response.text).to.equal('{"data":{"test":"Hello Dolly"}}'); 186 | }); 187 | 188 | it('allows GET with operation name', async () => { 189 | const app = server(); 190 | 191 | app.use( 192 | mount( 193 | urlString(), 194 | graphqlHTTP({ 195 | schema: TestSchema, 196 | }), 197 | ), 198 | ); 199 | 200 | const response = await request(app.listen()).get( 201 | urlString({ 202 | query: ` 203 | query helloYou { test(who: "You"), ...shared } 204 | query helloWorld { test(who: "World"), ...shared } 205 | query helloDolly { test(who: "Dolly"), ...shared } 206 | fragment shared on QueryRoot { 207 | shared: test(who: "Everyone") 208 | } 209 | `, 210 | operationName: 'helloWorld', 211 | }), 212 | ); 213 | 214 | expect(JSON.parse(response.text)).to.deep.equal({ 215 | data: { 216 | test: 'Hello World', 217 | shared: 'Hello Everyone', 218 | }, 219 | }); 220 | }); 221 | 222 | it('Reports validation errors', async () => { 223 | const app = server(); 224 | 225 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 226 | 227 | const response = await request(app.listen()).get( 228 | urlString({ 229 | query: '{ test, unknownOne, unknownTwo }', 230 | }), 231 | ); 232 | 233 | expect(response.status).to.equal(400); 234 | expect(JSON.parse(response.text)).to.deep.equal({ 235 | errors: [ 236 | { 237 | message: 'Cannot query field "unknownOne" on type "QueryRoot".', 238 | locations: [{ line: 1, column: 9 }], 239 | }, 240 | { 241 | message: 'Cannot query field "unknownTwo" on type "QueryRoot".', 242 | locations: [{ line: 1, column: 21 }], 243 | }, 244 | ], 245 | }); 246 | }); 247 | 248 | it('Errors when missing operation name', async () => { 249 | const app = server(); 250 | 251 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 252 | 253 | const response = await request(app.listen()).get( 254 | urlString({ 255 | query: ` 256 | query TestQuery { test } 257 | mutation TestMutation { writeTest { test } } 258 | `, 259 | }), 260 | ); 261 | 262 | expect(response.status).to.equal(500); 263 | expect(JSON.parse(response.text)).to.deep.equal({ 264 | errors: [ 265 | { 266 | message: 267 | 'Must provide operation name if query contains multiple operations.', 268 | }, 269 | ], 270 | }); 271 | }); 272 | 273 | it('Errors when sending a mutation via GET', async () => { 274 | const app = server(); 275 | 276 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 277 | 278 | const response = await request(app.listen()).get( 279 | urlString({ 280 | query: 'mutation TestMutation { writeTest { test } }', 281 | }), 282 | ); 283 | 284 | expect(response.status).to.equal(405); 285 | expect(JSON.parse(response.text)).to.deep.equal({ 286 | errors: [ 287 | { 288 | message: 289 | 'Can only perform a mutation operation from a POST request.', 290 | }, 291 | ], 292 | }); 293 | }); 294 | 295 | it('Errors when selecting a mutation within a GET', async () => { 296 | const app = server(); 297 | 298 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 299 | 300 | const response = await request(app.listen()).get( 301 | urlString({ 302 | operationName: 'TestMutation', 303 | query: ` 304 | query TestQuery { test } 305 | mutation TestMutation { writeTest { test } } 306 | `, 307 | }), 308 | ); 309 | 310 | expect(response.status).to.equal(405); 311 | expect(JSON.parse(response.text)).to.deep.equal({ 312 | errors: [ 313 | { 314 | message: 315 | 'Can only perform a mutation operation from a POST request.', 316 | }, 317 | ], 318 | }); 319 | }); 320 | 321 | it('Allows a mutation to exist within a GET', async () => { 322 | const app = server(); 323 | 324 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 325 | 326 | const response = await request(app.listen()).get( 327 | urlString({ 328 | operationName: 'TestQuery', 329 | query: ` 330 | mutation TestMutation { writeTest { test } } 331 | query TestQuery { test } 332 | `, 333 | }), 334 | ); 335 | 336 | expect(response.status).to.equal(200); 337 | expect(JSON.parse(response.text)).to.deep.equal({ 338 | data: { 339 | test: 'Hello World', 340 | }, 341 | }); 342 | }); 343 | 344 | it('Allows async resolvers', async () => { 345 | const schema = new GraphQLSchema({ 346 | query: new GraphQLObjectType({ 347 | name: 'Query', 348 | fields: { 349 | foo: { 350 | type: GraphQLString, 351 | resolve: () => Promise.resolve('bar'), 352 | }, 353 | }, 354 | }), 355 | }); 356 | const app = server(); 357 | 358 | app.use(mount(urlString(), graphqlHTTP({ schema }))); 359 | 360 | const response = await request(app.listen()).get( 361 | urlString({ 362 | query: '{ foo }', 363 | }), 364 | ); 365 | 366 | expect(response.status).to.equal(200); 367 | expect(JSON.parse(response.text)).to.deep.equal({ 368 | data: { foo: 'bar' }, 369 | }); 370 | }); 371 | 372 | it('Allows passing in a context', async () => { 373 | const schema = new GraphQLSchema({ 374 | query: new GraphQLObjectType({ 375 | name: 'Query', 376 | fields: { 377 | test: { 378 | type: GraphQLString, 379 | resolve: (_obj, _args, context) => context, 380 | }, 381 | }, 382 | }), 383 | }); 384 | const app = server(); 385 | 386 | app.use( 387 | mount( 388 | urlString(), 389 | graphqlHTTP({ 390 | schema, 391 | context: 'testValue', 392 | }), 393 | ), 394 | ); 395 | 396 | const response = await request(app.listen()).get( 397 | urlString({ 398 | query: '{ test }', 399 | }), 400 | ); 401 | 402 | expect(response.status).to.equal(200); 403 | expect(JSON.parse(response.text)).to.deep.equal({ 404 | data: { 405 | test: 'testValue', 406 | }, 407 | }); 408 | }); 409 | 410 | it('Allows passing in a fieldResolver', async () => { 411 | const schema = buildSchema(` 412 | type Query { 413 | test: String 414 | } 415 | `); 416 | const app = server(); 417 | 418 | app.use( 419 | mount( 420 | urlString(), 421 | graphqlHTTP({ 422 | schema, 423 | fieldResolver: () => 'fieldResolver data', 424 | }), 425 | ), 426 | ); 427 | 428 | const response = await request(app.listen()).get( 429 | urlString({ 430 | query: '{ test }', 431 | }), 432 | ); 433 | 434 | expect(response.status).to.equal(200); 435 | expect(JSON.parse(response.text)).to.deep.equal({ 436 | data: { 437 | test: 'fieldResolver data', 438 | }, 439 | }); 440 | }); 441 | 442 | it('Allows passing in a typeResolver', async () => { 443 | const schema = buildSchema(` 444 | type Foo { 445 | foo: String 446 | } 447 | type Bar { 448 | bar: String 449 | } 450 | union UnionType = Foo | Bar 451 | type Query { 452 | test: UnionType 453 | } 454 | `); 455 | const app = server(); 456 | 457 | app.use( 458 | mount( 459 | urlString(), 460 | graphqlHTTP({ 461 | schema, 462 | rootValue: { test: {} }, 463 | typeResolver: () => 'Bar', 464 | }), 465 | ), 466 | ); 467 | 468 | const response = await request(app.listen()).get( 469 | urlString({ 470 | query: '{ test { __typename } }', 471 | }), 472 | ); 473 | 474 | expect(response.status).to.equal(200); 475 | expect(JSON.parse(response.text)).to.deep.equal({ 476 | data: { 477 | test: { __typename: 'Bar' }, 478 | }, 479 | }); 480 | }); 481 | 482 | it('Uses ctx as context by default', async () => { 483 | const schema = new GraphQLSchema({ 484 | query: new GraphQLObjectType({ 485 | name: 'Query', 486 | fields: { 487 | test: { 488 | type: GraphQLString, 489 | resolve: (_obj, _args, context) => context.foo, 490 | }, 491 | }, 492 | }), 493 | }); 494 | const app = server(); 495 | 496 | // Middleware that adds ctx.foo to every request 497 | app.use((ctx, next) => { 498 | ctx.foo = 'bar'; 499 | return next(); 500 | }); 501 | 502 | app.use( 503 | mount( 504 | urlString(), 505 | graphqlHTTP({ 506 | schema, 507 | }), 508 | ), 509 | ); 510 | 511 | const response = await request(app.listen()).get( 512 | urlString({ 513 | query: '{ test }', 514 | }), 515 | ); 516 | 517 | expect(response.status).to.equal(200); 518 | expect(JSON.parse(response.text)).to.deep.equal({ 519 | data: { 520 | test: 'bar', 521 | }, 522 | }); 523 | }); 524 | 525 | it('Allows returning an options Promise', async () => { 526 | const app = server(); 527 | 528 | app.use( 529 | mount( 530 | urlString(), 531 | graphqlHTTP(() => 532 | Promise.resolve({ 533 | schema: TestSchema, 534 | }), 535 | ), 536 | ), 537 | ); 538 | 539 | const response = await request(app.listen()).get( 540 | urlString({ 541 | query: '{test}', 542 | }), 543 | ); 544 | 545 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 546 | }); 547 | 548 | it('Provides an options function with arguments', async () => { 549 | const app = server(); 550 | 551 | let seenRequest; 552 | let seenResponse; 553 | let seenContext; 554 | let seenParams; 555 | 556 | app.use( 557 | mount( 558 | urlString(), 559 | graphqlHTTP((req, res, ctx, params) => { 560 | seenRequest = req; 561 | seenResponse = res; 562 | seenContext = ctx; 563 | seenParams = params; 564 | return { schema: TestSchema }; 565 | }), 566 | ), 567 | ); 568 | 569 | const response = await request(app.listen()).get( 570 | urlString({ 571 | query: '{test}', 572 | }), 573 | ); 574 | 575 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 576 | 577 | expect(seenRequest).to.not.equal(null); 578 | expect(seenResponse).to.not.equal(null); 579 | expect(seenContext).to.not.equal(null); 580 | expect(seenParams).to.deep.equal({ 581 | query: '{test}', 582 | operationName: null, 583 | variables: null, 584 | raw: false, 585 | }); 586 | }); 587 | 588 | it('Catches errors thrown from options function', async () => { 589 | const app = server(); 590 | 591 | app.use( 592 | mount( 593 | urlString(), 594 | graphqlHTTP(() => { 595 | throw new Error('I did something wrong'); 596 | }), 597 | ), 598 | ); 599 | 600 | const response = await request(app.listen()).get( 601 | urlString({ 602 | query: '{test}', 603 | }), 604 | ); 605 | 606 | expect(response.status).to.equal(500); 607 | expect(response.text).to.equal( 608 | '{"errors":[{"message":"I did something wrong"}]}', 609 | ); 610 | }); 611 | }); 612 | 613 | describe('POST functionality', () => { 614 | it('allows POST with JSON encoding', async () => { 615 | const app = server(); 616 | 617 | app.use( 618 | mount( 619 | urlString(), 620 | graphqlHTTP({ 621 | schema: TestSchema, 622 | }), 623 | ), 624 | ); 625 | 626 | const response = await request(app.listen()) 627 | .post(urlString()) 628 | .send({ query: '{test}' }); 629 | 630 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 631 | }); 632 | 633 | it('Allows sending a mutation via POST', async () => { 634 | const app = server(); 635 | 636 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 637 | 638 | const response = await request(app.listen()) 639 | .post(urlString()) 640 | .send({ query: 'mutation TestMutation { writeTest { test } }' }); 641 | 642 | expect(response.status).to.equal(200); 643 | expect(response.text).to.equal( 644 | '{"data":{"writeTest":{"test":"Hello World"}}}', 645 | ); 646 | }); 647 | 648 | it('allows POST with url encoding', async () => { 649 | const app = server(); 650 | 651 | app.use( 652 | mount( 653 | urlString(), 654 | graphqlHTTP({ 655 | schema: TestSchema, 656 | }), 657 | ), 658 | ); 659 | 660 | const response = await request(app.listen()) 661 | .post(urlString()) 662 | .send(stringifyURLParams({ query: '{test}' })); 663 | 664 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 665 | }); 666 | 667 | it('supports POST JSON query with string variables', async () => { 668 | const app = server(); 669 | 670 | app.use( 671 | mount( 672 | urlString(), 673 | graphqlHTTP({ 674 | schema: TestSchema, 675 | }), 676 | ), 677 | ); 678 | 679 | const response = await request(app.listen()) 680 | .post(urlString()) 681 | .send({ 682 | query: 'query helloWho($who: String){ test(who: $who) }', 683 | variables: JSON.stringify({ who: 'Dolly' }), 684 | }); 685 | 686 | expect(response.text).to.equal('{"data":{"test":"Hello Dolly"}}'); 687 | }); 688 | 689 | it('supports POST JSON query with JSON variables', async () => { 690 | const app = server(); 691 | 692 | app.use( 693 | mount( 694 | urlString(), 695 | graphqlHTTP({ 696 | schema: TestSchema, 697 | }), 698 | ), 699 | ); 700 | 701 | const response = await request(app.listen()) 702 | .post(urlString()) 703 | .send({ 704 | query: 'query helloWho($who: String){ test(who: $who) }', 705 | variables: { who: 'Dolly' }, 706 | }); 707 | 708 | expect(response.text).to.equal('{"data":{"test":"Hello Dolly"}}'); 709 | }); 710 | 711 | it('supports POST url encoded query with string variables', async () => { 712 | const app = server(); 713 | 714 | app.use( 715 | mount( 716 | urlString(), 717 | graphqlHTTP({ 718 | schema: TestSchema, 719 | }), 720 | ), 721 | ); 722 | 723 | const response = await request(app.listen()) 724 | .post(urlString()) 725 | .send( 726 | stringifyURLParams({ 727 | query: 'query helloWho($who: String){ test(who: $who) }', 728 | variables: JSON.stringify({ who: 'Dolly' }), 729 | }), 730 | ); 731 | 732 | expect(response.text).to.equal('{"data":{"test":"Hello Dolly"}}'); 733 | }); 734 | 735 | it('supports POST JSON query with GET variable values', async () => { 736 | const app = server(); 737 | 738 | app.use( 739 | mount( 740 | urlString(), 741 | graphqlHTTP({ 742 | schema: TestSchema, 743 | }), 744 | ), 745 | ); 746 | 747 | const response = await request(app.listen()) 748 | .post( 749 | urlString({ 750 | variables: JSON.stringify({ who: 'Dolly' }), 751 | }), 752 | ) 753 | .send({ query: 'query helloWho($who: String){ test(who: $who) }' }); 754 | 755 | expect(response.text).to.equal('{"data":{"test":"Hello Dolly"}}'); 756 | }); 757 | 758 | it('supports POST url encoded query with GET variable values', async () => { 759 | const app = server(); 760 | 761 | app.use( 762 | mount( 763 | urlString(), 764 | graphqlHTTP({ 765 | schema: TestSchema, 766 | }), 767 | ), 768 | ); 769 | 770 | const response = await request(app.listen()) 771 | .post( 772 | urlString({ 773 | variables: JSON.stringify({ who: 'Dolly' }), 774 | }), 775 | ) 776 | .send( 777 | stringifyURLParams({ 778 | query: 'query helloWho($who: String){ test(who: $who) }', 779 | }), 780 | ); 781 | 782 | expect(response.text).to.equal('{"data":{"test":"Hello Dolly"}}'); 783 | }); 784 | 785 | it('supports POST raw text query with GET variable values', async () => { 786 | const app = server(); 787 | 788 | app.use( 789 | mount( 790 | urlString(), 791 | graphqlHTTP({ 792 | schema: TestSchema, 793 | }), 794 | ), 795 | ); 796 | 797 | const response = await request(app.listen()) 798 | .post( 799 | urlString({ 800 | variables: JSON.stringify({ who: 'Dolly' }), 801 | }), 802 | ) 803 | .set('Content-Type', 'application/graphql') 804 | .send('query helloWho($who: String){ test(who: $who) }'); 805 | 806 | expect(response.text).to.equal('{"data":{"test":"Hello Dolly"}}'); 807 | }); 808 | 809 | it('allows POST with operation name', async () => { 810 | const app = server(); 811 | 812 | app.use( 813 | mount( 814 | urlString(), 815 | graphqlHTTP({ 816 | schema: TestSchema, 817 | }), 818 | ), 819 | ); 820 | 821 | const response = await request(app.listen()) 822 | .post(urlString()) 823 | .send({ 824 | query: ` 825 | query helloYou { test(who: "You"), ...shared } 826 | query helloWorld { test(who: "World"), ...shared } 827 | query helloDolly { test(who: "Dolly"), ...shared } 828 | fragment shared on QueryRoot { 829 | shared: test(who: "Everyone") 830 | } 831 | `, 832 | operationName: 'helloWorld', 833 | }); 834 | 835 | expect(JSON.parse(response.text)).to.deep.equal({ 836 | data: { 837 | test: 'Hello World', 838 | shared: 'Hello Everyone', 839 | }, 840 | }); 841 | }); 842 | 843 | it('allows POST with GET operation name', async () => { 844 | const app = server(); 845 | 846 | app.use( 847 | mount( 848 | urlString(), 849 | graphqlHTTP({ 850 | schema: TestSchema, 851 | }), 852 | ), 853 | ); 854 | 855 | const response = await request(app.listen()) 856 | .post( 857 | urlString({ 858 | operationName: 'helloWorld', 859 | }), 860 | ) 861 | .set('Content-Type', 'application/graphql').send(` 862 | query helloYou { test(who: "You"), ...shared } 863 | query helloWorld { test(who: "World"), ...shared } 864 | query helloDolly { test(who: "Dolly"), ...shared } 865 | fragment shared on QueryRoot { 866 | shared: test(who: "Everyone") 867 | } 868 | `); 869 | 870 | expect(JSON.parse(response.text)).to.deep.equal({ 871 | data: { 872 | test: 'Hello World', 873 | shared: 'Hello Everyone', 874 | }, 875 | }); 876 | }); 877 | 878 | it('allows other UTF charsets', async () => { 879 | const app = server(); 880 | 881 | app.use( 882 | mount( 883 | urlString(), 884 | graphqlHTTP({ 885 | schema: TestSchema, 886 | }), 887 | ), 888 | ); 889 | 890 | const req = request(app.listen()) 891 | .post(urlString()) 892 | .set('Content-Type', 'application/graphql; charset=utf-16'); 893 | req.write(Buffer.from('{ test(who: "World") }', 'utf16le')); 894 | const response = await req; 895 | 896 | expect(JSON.parse(response.text)).to.deep.equal({ 897 | data: { 898 | test: 'Hello World', 899 | }, 900 | }); 901 | }); 902 | 903 | it('allows gzipped POST bodies', async () => { 904 | const app = server(); 905 | 906 | app.use( 907 | mount( 908 | urlString(), 909 | graphqlHTTP({ 910 | schema: TestSchema, 911 | }), 912 | ), 913 | ); 914 | 915 | const req = request(app.listen()) 916 | .post(urlString()) 917 | .set('Content-Type', 'application/json') 918 | .set('Content-Encoding', 'gzip'); 919 | 920 | req.write(zlib.gzipSync('{ "query": "{ test }" }')); 921 | 922 | const response = await req; 923 | 924 | expect(JSON.parse(response.text)).to.deep.equal({ 925 | data: { 926 | test: 'Hello World', 927 | }, 928 | }); 929 | }); 930 | 931 | it('allows deflated POST bodies', async () => { 932 | const app = server(); 933 | 934 | app.use( 935 | mount( 936 | urlString(), 937 | graphqlHTTP({ 938 | schema: TestSchema, 939 | }), 940 | ), 941 | ); 942 | 943 | const req = request(app.listen()) 944 | .post(urlString()) 945 | .set('Content-Type', 'application/json') 946 | .set('Content-Encoding', 'deflate'); 947 | 948 | req.write(zlib.deflateSync('{ "query": "{ test }" }')); 949 | 950 | const response = await req; 951 | 952 | expect(JSON.parse(response.text)).to.deep.equal({ 953 | data: { 954 | test: 'Hello World', 955 | }, 956 | }); 957 | }); 958 | 959 | // should replace multer with koa middleware 960 | it('allows for pre-parsed POST bodies', async () => { 961 | // Note: this is not the only way to handle file uploads with GraphQL, 962 | // but it is terse and illustrative of using koa-graphql and multer 963 | // together. 964 | 965 | // A simple schema which includes a mutation. 966 | const UploadedFileType = new GraphQLObjectType({ 967 | name: 'UploadedFile', 968 | fields: { 969 | originalname: { type: GraphQLString }, 970 | mimetype: { type: GraphQLString }, 971 | }, 972 | }); 973 | 974 | const TestMutationSchema = new GraphQLSchema({ 975 | query: new GraphQLObjectType({ 976 | name: 'QueryRoot', 977 | fields: { 978 | test: { type: GraphQLString }, 979 | }, 980 | }), 981 | mutation: new GraphQLObjectType({ 982 | name: 'MutationRoot', 983 | fields: { 984 | uploadFile: { 985 | type: UploadedFileType, 986 | resolve(rootValue) { 987 | // For this test demo, we're just returning the uploaded 988 | // file directly, but presumably you might return a Promise 989 | // to go store the file somewhere first. 990 | return rootValue.request.file; 991 | }, 992 | }, 993 | }, 994 | }), 995 | }); 996 | 997 | const app = server(); 998 | 999 | // Multer provides multipart form data parsing. 1000 | const storage = multer.memoryStorage(); 1001 | app.use(mount(urlString(), multerWrapper({ storage }).single('file'))); 1002 | 1003 | // Providing the request as part of `rootValue` allows it to 1004 | // be accessible from within Schema resolve functions. 1005 | app.use( 1006 | mount( 1007 | urlString(), 1008 | graphqlHTTP((_req, _res, ctx) => { 1009 | expect(ctx.req.file?.originalname).to.equal('test.txt'); 1010 | return { 1011 | schema: TestMutationSchema, 1012 | rootValue: { request: ctx.req }, 1013 | }; 1014 | }), 1015 | ), 1016 | ); 1017 | 1018 | const response = await request(app.listen()) 1019 | .post(urlString()) 1020 | .field( 1021 | 'query', 1022 | `mutation TestMutation { 1023 | uploadFile { originalname, mimetype } 1024 | }`, 1025 | ) 1026 | .attach('file', Buffer.from('test'), 'test.txt'); 1027 | 1028 | expect(JSON.parse(response.text)).to.deep.equal({ 1029 | data: { 1030 | uploadFile: { 1031 | originalname: 'test.txt', 1032 | mimetype: 'text/plain', 1033 | }, 1034 | }, 1035 | }); 1036 | }); 1037 | 1038 | it('allows for pre-parsed POST using application/graphql', async () => { 1039 | const app = server(); 1040 | app.use(async (ctx, next) => { 1041 | if (typeof ctx.is('application/graphql') === 'string') { 1042 | // eslint-disable-next-line require-atomic-updates 1043 | ctx.request.body = await parseBody.text(ctx); 1044 | } 1045 | return next(); 1046 | }); 1047 | 1048 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 1049 | 1050 | const req = request(app.listen()) 1051 | .post(urlString()) 1052 | .set('Content-Type', 'application/graphql'); 1053 | req.write(Buffer.from('{ test(who: "World") }')); 1054 | const response = await req; 1055 | 1056 | expect(JSON.parse(response.text)).to.deep.equal({ 1057 | data: { 1058 | test: 'Hello World', 1059 | }, 1060 | }); 1061 | }); 1062 | 1063 | it('does not accept unknown pre-parsed POST string', async () => { 1064 | const app = server(); 1065 | app.use(async (ctx, next) => { 1066 | if (typeof ctx.is('*/*') === 'string') { 1067 | // eslint-disable-next-line require-atomic-updates 1068 | ctx.request.body = await parseBody.text(ctx); 1069 | } 1070 | return next(); 1071 | }); 1072 | 1073 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 1074 | 1075 | const req = request(app.listen()).post(urlString()); 1076 | req.write(Buffer.from('{ test(who: "World") }')); 1077 | const response = await req; 1078 | 1079 | expect(response.status).to.equal(400); 1080 | expect(JSON.parse(response.text)).to.deep.equal({ 1081 | errors: [{ message: 'Must provide query string.' }], 1082 | }); 1083 | }); 1084 | 1085 | it('does not accept unknown pre-parsed POST raw Buffer', async () => { 1086 | const app = server(); 1087 | app.use(async (ctx, next) => { 1088 | if (typeof ctx.is('*/*') === 'string') { 1089 | const req = ctx.req; 1090 | // eslint-disable-next-line require-atomic-updates 1091 | ctx.request.body = await getRawBody(req, { 1092 | length: req.headers['content-length'], 1093 | limit: '1mb', 1094 | encoding: null, 1095 | }); 1096 | } 1097 | return next(); 1098 | }); 1099 | 1100 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 1101 | 1102 | const req = request(app.listen()) 1103 | .post(urlString()) 1104 | .set('Content-Type', 'application/graphql'); 1105 | req.write(Buffer.from('{ test(who: "World") }')); 1106 | const response = await req; 1107 | 1108 | expect(response.status).to.equal(400); 1109 | expect(JSON.parse(response.text)).to.deep.equal({ 1110 | errors: [{ message: 'Must provide query string.' }], 1111 | }); 1112 | }); 1113 | }); 1114 | 1115 | describe('Pretty printing', () => { 1116 | it('supports pretty printing', async () => { 1117 | const app = server(); 1118 | 1119 | app.use( 1120 | mount( 1121 | urlString(), 1122 | graphqlHTTP({ 1123 | schema: TestSchema, 1124 | pretty: true, 1125 | }), 1126 | ), 1127 | ); 1128 | 1129 | const response = await request(app.listen()).get( 1130 | urlString({ 1131 | query: '{test}', 1132 | }), 1133 | ); 1134 | 1135 | expect(response.text).to.equal( 1136 | [ 1137 | // Pretty printed JSON 1138 | '{', 1139 | ' "data": {', 1140 | ' "test": "Hello World"', 1141 | ' }', 1142 | '}', 1143 | ].join('\n'), 1144 | ); 1145 | }); 1146 | 1147 | it('supports pretty printing configured by request', async () => { 1148 | const app = server(); 1149 | let pretty: boolean | undefined; 1150 | 1151 | app.use( 1152 | mount( 1153 | urlString(), 1154 | graphqlHTTP(() => ({ 1155 | schema: TestSchema, 1156 | pretty, 1157 | })), 1158 | ), 1159 | ); 1160 | 1161 | pretty = undefined; 1162 | const defaultResponse = await request(app.listen()).get( 1163 | urlString({ 1164 | query: '{test}', 1165 | }), 1166 | ); 1167 | 1168 | expect(defaultResponse.text).to.equal('{"data":{"test":"Hello World"}}'); 1169 | 1170 | pretty = true; 1171 | const prettyResponse = await request(app.listen()).get( 1172 | urlString({ 1173 | query: '{test}', 1174 | pretty: '1', 1175 | }), 1176 | ); 1177 | 1178 | expect(prettyResponse.text).to.equal( 1179 | [ 1180 | // Pretty printed JSON 1181 | '{', 1182 | ' "data": {', 1183 | ' "test": "Hello World"', 1184 | ' }', 1185 | '}', 1186 | ].join('\n'), 1187 | ); 1188 | 1189 | pretty = false; 1190 | const unprettyResponse = await request(app.listen()).get( 1191 | urlString({ 1192 | query: '{test}', 1193 | pretty: '0', 1194 | }), 1195 | ); 1196 | 1197 | expect(unprettyResponse.text).to.equal('{"data":{"test":"Hello World"}}'); 1198 | }); 1199 | }); 1200 | 1201 | it('will send request, response and context when using thunk', async () => { 1202 | const app = server(); 1203 | 1204 | let seenRequest; 1205 | let seenResponse; 1206 | let seenContext; 1207 | 1208 | app.use( 1209 | mount( 1210 | urlString(), 1211 | graphqlHTTP((req, res, ctx) => { 1212 | seenRequest = req; 1213 | seenResponse = res; 1214 | seenContext = ctx; 1215 | return { schema: TestSchema }; 1216 | }), 1217 | ), 1218 | ); 1219 | 1220 | await request(app.listen()).get(urlString({ query: '{test}' })); 1221 | 1222 | expect(seenRequest).to.not.equal(undefined); 1223 | expect(seenResponse).to.not.equal(undefined); 1224 | expect(seenContext).to.not.equal(undefined); 1225 | }); 1226 | 1227 | describe('Error handling functionality', () => { 1228 | it('handles field errors caught by GraphQL', async () => { 1229 | const app = server(); 1230 | 1231 | app.use( 1232 | mount( 1233 | urlString(), 1234 | graphqlHTTP({ 1235 | schema: TestSchema, 1236 | }), 1237 | ), 1238 | ); 1239 | 1240 | const response = await request(app.listen()).get( 1241 | urlString({ 1242 | query: '{thrower}', 1243 | }), 1244 | ); 1245 | 1246 | expect(response.status).to.equal(200); 1247 | expect(JSON.parse(response.text)).to.deep.equal({ 1248 | data: { thrower: null }, 1249 | errors: [ 1250 | { 1251 | message: 'Throws!', 1252 | locations: [{ line: 1, column: 2 }], 1253 | path: ['thrower'], 1254 | }, 1255 | ], 1256 | }); 1257 | }); 1258 | 1259 | it('handles query errors from non-null top field errors', async () => { 1260 | const schema = new GraphQLSchema({ 1261 | query: new GraphQLObjectType({ 1262 | name: 'Query', 1263 | fields: { 1264 | test: { 1265 | type: new GraphQLNonNull(GraphQLString), 1266 | resolve() { 1267 | throw new Error('Throws!'); 1268 | }, 1269 | }, 1270 | }, 1271 | }), 1272 | }); 1273 | const app = server(); 1274 | 1275 | app.use( 1276 | mount( 1277 | urlString(), 1278 | graphqlHTTP({ 1279 | schema, 1280 | }), 1281 | ), 1282 | ); 1283 | 1284 | const response = await request(app.listen()).get( 1285 | urlString({ 1286 | query: '{ test }', 1287 | }), 1288 | ); 1289 | 1290 | expect(response.status).to.equal(500); 1291 | expect(JSON.parse(response.text)).to.deep.equal({ 1292 | data: null, 1293 | errors: [ 1294 | { 1295 | message: 'Throws!', 1296 | locations: [{ line: 1, column: 3 }], 1297 | path: ['test'], 1298 | }, 1299 | ], 1300 | }); 1301 | }); 1302 | 1303 | it('allows for custom error formatting to sanitize', async () => { 1304 | const app = server(); 1305 | 1306 | app.use( 1307 | mount( 1308 | urlString(), 1309 | graphqlHTTP({ 1310 | schema: TestSchema, 1311 | customFormatErrorFn(error) { 1312 | return { message: 'Custom error format: ' + error.message }; 1313 | }, 1314 | }), 1315 | ), 1316 | ); 1317 | 1318 | const response = await request(app.listen()).get( 1319 | urlString({ 1320 | query: '{thrower}', 1321 | }), 1322 | ); 1323 | 1324 | expect(response.status).to.equal(200); 1325 | expect(JSON.parse(response.text)).to.deep.equal({ 1326 | data: { thrower: null }, 1327 | errors: [ 1328 | { 1329 | message: 'Custom error format: Throws!', 1330 | }, 1331 | ], 1332 | }); 1333 | }); 1334 | 1335 | it('allows for custom error formatting to elaborate', async () => { 1336 | const app = server(); 1337 | 1338 | app.use( 1339 | mount( 1340 | urlString(), 1341 | graphqlHTTP({ 1342 | schema: TestSchema, 1343 | customFormatErrorFn(error) { 1344 | return { 1345 | message: error.message, 1346 | locations: error.locations, 1347 | stack: 'Stack trace', 1348 | }; 1349 | }, 1350 | }), 1351 | ), 1352 | ); 1353 | 1354 | const response = await request(app.listen()).get( 1355 | urlString({ 1356 | query: '{thrower}', 1357 | }), 1358 | ); 1359 | 1360 | expect(response.status).to.equal(200); 1361 | expect(JSON.parse(response.text)).to.deep.equal({ 1362 | data: { thrower: null }, 1363 | errors: [ 1364 | { 1365 | message: 'Throws!', 1366 | locations: [{ line: 1, column: 2 }], 1367 | stack: 'Stack trace', 1368 | }, 1369 | ], 1370 | }); 1371 | }); 1372 | 1373 | it('handles syntax errors caught by GraphQL', async () => { 1374 | const app = server(); 1375 | 1376 | app.use( 1377 | mount( 1378 | urlString(), 1379 | graphqlHTTP({ 1380 | schema: TestSchema, 1381 | }), 1382 | ), 1383 | ); 1384 | 1385 | const response = await request(app.listen()).get( 1386 | urlString({ 1387 | query: 'syntax_error', 1388 | }), 1389 | ); 1390 | 1391 | expect(response.status).to.equal(400); 1392 | expect(JSON.parse(response.text)).to.deep.equal({ 1393 | errors: [ 1394 | { 1395 | message: 'Syntax Error: Unexpected Name "syntax_error".', 1396 | locations: [{ line: 1, column: 1 }], 1397 | }, 1398 | ], 1399 | }); 1400 | }); 1401 | 1402 | it('handles errors caused by a lack of query', async () => { 1403 | const app = server(); 1404 | 1405 | app.use( 1406 | mount( 1407 | urlString(), 1408 | graphqlHTTP({ 1409 | schema: TestSchema, 1410 | }), 1411 | ), 1412 | ); 1413 | 1414 | const response = await request(app.listen()).get(urlString()); 1415 | 1416 | expect(response.status).to.equal(400); 1417 | expect(JSON.parse(response.text)).to.deep.equal({ 1418 | errors: [{ message: 'Must provide query string.' }], 1419 | }); 1420 | }); 1421 | 1422 | it('handles invalid JSON bodies', async () => { 1423 | const app = server(); 1424 | 1425 | app.use( 1426 | mount( 1427 | urlString(), 1428 | graphqlHTTP({ 1429 | schema: TestSchema, 1430 | }), 1431 | ), 1432 | ); 1433 | 1434 | const response = await request(app.listen()) 1435 | .post(urlString()) 1436 | .set('Content-Type', 'application/json') 1437 | .send('[]'); 1438 | 1439 | expect(response.status).to.equal(400); 1440 | expect(JSON.parse(response.text)).to.deep.equal({ 1441 | errors: [{ message: 'POST body sent invalid JSON.' }], 1442 | }); 1443 | }); 1444 | 1445 | it('handles incomplete JSON bodies', async () => { 1446 | const app = server(); 1447 | 1448 | app.use( 1449 | mount( 1450 | urlString(), 1451 | graphqlHTTP({ 1452 | schema: TestSchema, 1453 | }), 1454 | ), 1455 | ); 1456 | 1457 | const response = await request(app.listen()) 1458 | .post(urlString()) 1459 | .set('Content-Type', 'application/json') 1460 | .send('{"query":'); 1461 | 1462 | expect(response.status).to.equal(400); 1463 | expect(JSON.parse(response.text)).to.deep.equal({ 1464 | errors: [{ message: 'POST body sent invalid JSON.' }], 1465 | }); 1466 | }); 1467 | 1468 | it('handles plain POST text', async () => { 1469 | const app = server(); 1470 | 1471 | app.use( 1472 | mount( 1473 | urlString(), 1474 | graphqlHTTP({ 1475 | schema: TestSchema, 1476 | }), 1477 | ), 1478 | ); 1479 | 1480 | const response = await request(app.listen()) 1481 | .post( 1482 | urlString({ 1483 | variables: JSON.stringify({ who: 'Dolly' }), 1484 | }), 1485 | ) 1486 | .set('Content-Type', 'text/plain') 1487 | .send('query helloWho($who: String){ test(who: $who) }'); 1488 | 1489 | expect(response.status).to.equal(400); 1490 | expect(JSON.parse(response.text)).to.deep.equal({ 1491 | errors: [{ message: 'Must provide query string.' }], 1492 | }); 1493 | }); 1494 | 1495 | it('handles unsupported charset', async () => { 1496 | const app = server(); 1497 | 1498 | app.use( 1499 | mount( 1500 | urlString(), 1501 | graphqlHTTP({ 1502 | schema: TestSchema, 1503 | }), 1504 | ), 1505 | ); 1506 | 1507 | const response = await request(app.listen()) 1508 | .post(urlString()) 1509 | .set('Content-Type', 'application/graphql; charset=ascii') 1510 | .send('{ test(who: "World") }'); 1511 | 1512 | expect(response.status).to.equal(415); 1513 | expect(JSON.parse(response.text)).to.deep.equal({ 1514 | errors: [{ message: 'Unsupported charset "ASCII".' }], 1515 | }); 1516 | }); 1517 | 1518 | it('handles unsupported utf charset', async () => { 1519 | const app = server(); 1520 | 1521 | app.use( 1522 | mount( 1523 | urlString(), 1524 | graphqlHTTP({ 1525 | schema: TestSchema, 1526 | }), 1527 | ), 1528 | ); 1529 | 1530 | const response = await request(app.listen()) 1531 | .post(urlString()) 1532 | .set('Content-Type', 'application/graphql; charset=utf-53') 1533 | .send('{ test(who: "World") }'); 1534 | 1535 | expect(response.status).to.equal(415); 1536 | expect(JSON.parse(response.text)).to.deep.equal({ 1537 | errors: [{ message: 'Unsupported charset "UTF-53".' }], 1538 | }); 1539 | }); 1540 | 1541 | it('handles unknown encoding', async () => { 1542 | const app = server(); 1543 | 1544 | app.use( 1545 | mount( 1546 | urlString(), 1547 | graphqlHTTP({ 1548 | schema: TestSchema, 1549 | }), 1550 | ), 1551 | ); 1552 | 1553 | const response = await request(app.listen()) 1554 | .post(urlString()) 1555 | .set('Content-Encoding', 'garbage') 1556 | .send('!@#$%^*(&^$%#@'); 1557 | 1558 | expect(response.status).to.equal(415); 1559 | expect(JSON.parse(response.text)).to.deep.equal({ 1560 | errors: [{ message: 'Unsupported content-encoding "garbage".' }], 1561 | }); 1562 | }); 1563 | 1564 | it('handles invalid body', async () => { 1565 | const app = server(); 1566 | 1567 | app.use( 1568 | mount( 1569 | urlString(), 1570 | graphqlHTTP(() => ({ 1571 | schema: TestSchema, 1572 | })), 1573 | ), 1574 | ); 1575 | 1576 | const response = await request(app.listen()) 1577 | .post(urlString()) 1578 | .set('Content-Type', 'application/json') 1579 | .send(`{ "query": "{ ${new Array(102400).fill('test').join('')} }" }`); 1580 | 1581 | expect(response.status).to.equal(413); 1582 | expect(JSON.parse(response.text)).to.deep.equal({ 1583 | errors: [{ message: 'Invalid body: request entity too large.' }], 1584 | }); 1585 | }); 1586 | 1587 | it('handles poorly formed variables', async () => { 1588 | const app = server(); 1589 | 1590 | app.use( 1591 | mount( 1592 | urlString(), 1593 | graphqlHTTP({ 1594 | schema: TestSchema, 1595 | }), 1596 | ), 1597 | ); 1598 | 1599 | const response = await request(app.listen()).get( 1600 | urlString({ 1601 | variables: 'who:You', 1602 | query: 'query helloWho($who: String){ test(who: $who) }', 1603 | }), 1604 | ); 1605 | 1606 | expect(response.status).to.equal(400); 1607 | expect(JSON.parse(response.text)).to.deep.equal({ 1608 | errors: [{ message: 'Variables are invalid JSON.' }], 1609 | }); 1610 | }); 1611 | 1612 | it('`formatError` is deprecated', async () => { 1613 | const app = server(); 1614 | 1615 | app.use( 1616 | mount( 1617 | urlString(), 1618 | graphqlHTTP({ 1619 | schema: TestSchema, 1620 | formatError(error) { 1621 | return { message: 'Custom error format: ' + error.message }; 1622 | }, 1623 | }), 1624 | ), 1625 | ); 1626 | 1627 | const spy = sinon.spy(console, 'warn'); 1628 | 1629 | const response = await request(app.listen()).get( 1630 | urlString({ 1631 | variables: 'who:You', 1632 | query: 'query helloWho($who: String){ test(who: $who) }', 1633 | }), 1634 | ); 1635 | 1636 | expect( 1637 | spy.calledWith( 1638 | '`formatError` is deprecated and replaced by `customFormatErrorFn`. It will be removed in version 1.0.0.', 1639 | ), 1640 | ); 1641 | expect(response.status).to.equal(400); 1642 | expect(JSON.parse(response.text)).to.deep.equal({ 1643 | errors: [ 1644 | { 1645 | message: 'Custom error format: Variables are invalid JSON.', 1646 | }, 1647 | ], 1648 | }); 1649 | 1650 | spy.restore(); 1651 | }); 1652 | 1653 | it('allows for custom error formatting of poorly formed requests', async () => { 1654 | const app = server(); 1655 | 1656 | app.use( 1657 | mount( 1658 | urlString(), 1659 | graphqlHTTP({ 1660 | schema: TestSchema, 1661 | customFormatErrorFn(error) { 1662 | return { message: 'Custom error format: ' + error.message }; 1663 | }, 1664 | }), 1665 | ), 1666 | ); 1667 | 1668 | const response = await request(app.listen()).get( 1669 | urlString({ 1670 | variables: 'who:You', 1671 | query: 'query helloWho($who: String){ test(who: $who) }', 1672 | }), 1673 | ); 1674 | 1675 | expect(response.status).to.equal(400); 1676 | expect(JSON.parse(response.text)).to.deep.equal({ 1677 | errors: [ 1678 | { 1679 | message: 'Custom error format: Variables are invalid JSON.', 1680 | }, 1681 | ], 1682 | }); 1683 | }); 1684 | 1685 | it('allows disabling prettifying poorly formed requests', async () => { 1686 | const app = server(); 1687 | 1688 | app.use( 1689 | mount( 1690 | urlString(), 1691 | graphqlHTTP({ 1692 | schema: TestSchema, 1693 | pretty: false, 1694 | }), 1695 | ), 1696 | ); 1697 | 1698 | const response = await request(app.listen()).get( 1699 | urlString({ 1700 | variables: 'who:You', 1701 | query: 'query helloWho($who: String){ test(who: $who) }', 1702 | }), 1703 | ); 1704 | 1705 | expect(response.status).to.equal(400); 1706 | expect(response.text).to.equal( 1707 | '{"errors":[{"message":"Variables are invalid JSON."}]}', 1708 | ); 1709 | }); 1710 | 1711 | it('handles invalid variables', async () => { 1712 | const app = server(); 1713 | 1714 | app.use( 1715 | mount( 1716 | urlString(), 1717 | graphqlHTTP({ 1718 | schema: TestSchema, 1719 | }), 1720 | ), 1721 | ); 1722 | 1723 | const response = await request(app.listen()) 1724 | .post(urlString()) 1725 | .send({ 1726 | query: 'query helloWho($who: String){ test(who: $who) }', 1727 | variables: { who: ['John', 'Jane'] }, 1728 | }); 1729 | 1730 | expect(response.status).to.equal(500); 1731 | expect(JSON.parse(response.text)).to.deep.equal({ 1732 | errors: [ 1733 | { 1734 | locations: [{ column: 16, line: 1 }], 1735 | message: 1736 | 'Variable "$who" got invalid value ["John", "Jane"]; String cannot represent a non string value: ["John", "Jane"]', 1737 | }, 1738 | ], 1739 | }); 1740 | }); 1741 | 1742 | it('handles unsupported HTTP methods', async () => { 1743 | const app = server(); 1744 | 1745 | app.use( 1746 | mount( 1747 | urlString(), 1748 | graphqlHTTP({ 1749 | schema: TestSchema, 1750 | }), 1751 | ), 1752 | ); 1753 | 1754 | const response = await request(app.listen()).put( 1755 | urlString({ query: '{test}' }), 1756 | ); 1757 | 1758 | expect(response.status).to.equal(405); 1759 | expect(response.get('allow')).to.equal('GET, POST'); 1760 | expect(JSON.parse(response.text)).to.deep.equal({ 1761 | errors: [{ message: 'GraphQL only supports GET and POST requests.' }], 1762 | }); 1763 | }); 1764 | }); 1765 | 1766 | describe('Built-in GraphiQL support', () => { 1767 | it('does not renders GraphiQL if no opt-in', async () => { 1768 | const app = server(); 1769 | 1770 | app.use(mount(urlString(), graphqlHTTP({ schema: TestSchema }))); 1771 | 1772 | const response = await request(app.listen()) 1773 | .get(urlString({ query: '{test}' })) 1774 | .set('Accept', 'text/html'); 1775 | 1776 | expect(response.status).to.equal(200); 1777 | expect(response.type).to.equal('application/json'); 1778 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 1779 | }); 1780 | 1781 | it('presents GraphiQL when accepting HTML', async () => { 1782 | const app = server(); 1783 | 1784 | app.use( 1785 | mount( 1786 | urlString(), 1787 | graphqlHTTP({ 1788 | schema: TestSchema, 1789 | graphiql: true, 1790 | }), 1791 | ), 1792 | ); 1793 | 1794 | const response = await request(app.listen()) 1795 | .get(urlString({ query: '{test}' })) 1796 | .set('Accept', 'text/html'); 1797 | 1798 | expect(response.status).to.equal(200); 1799 | expect(response.type).to.equal('text/html'); 1800 | expect(response.text).to.include('graphiql.min.js'); 1801 | }); 1802 | 1803 | it('contains a default query within GraphiQL', async () => { 1804 | const app = server(); 1805 | 1806 | app.use( 1807 | mount( 1808 | urlString(), 1809 | graphqlHTTP({ 1810 | schema: TestSchema, 1811 | graphiql: { defaultQuery: 'query testDefaultQuery { hello }' }, 1812 | }), 1813 | ), 1814 | ); 1815 | 1816 | const response = await request(app.listen()) 1817 | .get(urlString()) 1818 | .set('Accept', 'text/html'); 1819 | 1820 | expect(response.status).to.equal(200); 1821 | expect(response.type).to.equal('text/html'); 1822 | expect(response.text).to.include( 1823 | 'defaultQuery: "query testDefaultQuery { hello }"', 1824 | ); 1825 | }); 1826 | 1827 | it('contains a pre-run response within GraphiQL', async () => { 1828 | const app = server(); 1829 | 1830 | app.use( 1831 | mount( 1832 | urlString(), 1833 | graphqlHTTP({ 1834 | schema: TestSchema, 1835 | graphiql: true, 1836 | }), 1837 | ), 1838 | ); 1839 | 1840 | const response = await request(app.listen()) 1841 | .get(urlString({ query: '{test}' })) 1842 | .set('Accept', 'text/html'); 1843 | 1844 | expect(response.status).to.equal(200); 1845 | expect(response.type).to.equal('text/html'); 1846 | expect(response.text).to.include( 1847 | 'response: ' + 1848 | JSON.stringify( 1849 | JSON.stringify({ data: { test: 'Hello World' } }, null, 2), 1850 | ), 1851 | ); 1852 | }); 1853 | 1854 | it('contains a pre-run operation name within GraphiQL', async () => { 1855 | const app = server(); 1856 | 1857 | app.use( 1858 | mount( 1859 | urlString(), 1860 | graphqlHTTP({ 1861 | schema: TestSchema, 1862 | graphiql: true, 1863 | }), 1864 | ), 1865 | ); 1866 | 1867 | const response = await request(app.listen()) 1868 | .get( 1869 | urlString({ 1870 | query: 'query A{a:test} query B{b:test}', 1871 | operationName: 'B', 1872 | }), 1873 | ) 1874 | .set('Accept', 'text/html'); 1875 | 1876 | expect(response.status).to.equal(200); 1877 | expect(response.type).to.equal('text/html'); 1878 | expect(response.text).to.include( 1879 | 'response: ' + 1880 | JSON.stringify( 1881 | JSON.stringify({ data: { b: 'Hello World' } }, null, 2), 1882 | ), 1883 | ); 1884 | expect(response.text).to.include('operationName: "B"'); 1885 | }); 1886 | 1887 | it('escapes HTML in queries within GraphiQL', async () => { 1888 | const app = server(); 1889 | 1890 | app.use( 1891 | mount( 1892 | urlString(), 1893 | graphqlHTTP({ 1894 | schema: TestSchema, 1895 | graphiql: true, 1896 | }), 1897 | ), 1898 | ); 1899 | 1900 | const response = await request(app.listen()) 1901 | .get(urlString({ query: '' })) 1902 | .set('Accept', 'text/html'); 1903 | 1904 | expect(response.status).to.equal(400); 1905 | expect(response.type).to.equal('text/html'); 1906 | expect(response.text).to.not.include( 1907 | '', 1908 | ); 1909 | }); 1910 | 1911 | it('escapes HTML in variables within GraphiQL', async () => { 1912 | const app = server(); 1913 | 1914 | app.use( 1915 | mount( 1916 | urlString(), 1917 | graphqlHTTP({ 1918 | schema: TestSchema, 1919 | graphiql: true, 1920 | }), 1921 | ), 1922 | ); 1923 | 1924 | const response = await request(app.listen()) 1925 | .get( 1926 | urlString({ 1927 | query: 'query helloWho($who: String) { test(who: $who) }', 1928 | variables: JSON.stringify({ 1929 | who: '', 1930 | }), 1931 | }), 1932 | ) 1933 | .set('Accept', 'text/html'); 1934 | 1935 | expect(response.status).to.equal(200); 1936 | expect(response.type).to.equal('text/html'); 1937 | expect(response.text).to.not.include( 1938 | '', 1939 | ); 1940 | }); 1941 | 1942 | it('GraphiQL renders provided variables', async () => { 1943 | const app = server(); 1944 | 1945 | app.use( 1946 | mount( 1947 | urlString(), 1948 | graphqlHTTP({ 1949 | schema: TestSchema, 1950 | graphiql: true, 1951 | }), 1952 | ), 1953 | ); 1954 | 1955 | const response = await request(app.listen()) 1956 | .get( 1957 | urlString({ 1958 | query: 'query helloWho($who: String) { test(who: $who) }', 1959 | variables: JSON.stringify({ who: 'Dolly' }), 1960 | }), 1961 | ) 1962 | .set('Accept', 'text/html'); 1963 | 1964 | expect(response.status).to.equal(200); 1965 | expect(response.type).to.equal('text/html'); 1966 | expect(response.text).to.include( 1967 | 'variables: ' + 1968 | JSON.stringify(JSON.stringify({ who: 'Dolly' }, null, 2)), 1969 | ); 1970 | }); 1971 | 1972 | it('GraphiQL accepts an empty query', async () => { 1973 | const app = server(); 1974 | 1975 | app.use( 1976 | mount( 1977 | urlString(), 1978 | graphqlHTTP({ 1979 | schema: TestSchema, 1980 | graphiql: true, 1981 | }), 1982 | ), 1983 | ); 1984 | 1985 | const response = await request(app.listen()) 1986 | .get(urlString()) 1987 | .set('Accept', 'text/html'); 1988 | 1989 | expect(response.status).to.equal(200); 1990 | expect(response.type).to.equal('text/html'); 1991 | expect(response.text).to.include('response: undefined'); 1992 | }); 1993 | 1994 | it('GraphiQL accepts a mutation query - does not execute it', async () => { 1995 | const app = server(); 1996 | 1997 | app.use( 1998 | mount( 1999 | urlString(), 2000 | graphqlHTTP({ 2001 | schema: TestSchema, 2002 | graphiql: true, 2003 | }), 2004 | ), 2005 | ); 2006 | 2007 | const response = await request(app.listen()) 2008 | .get( 2009 | urlString({ 2010 | query: 'mutation TestMutation { writeTest { test } }', 2011 | }), 2012 | ) 2013 | .set('Accept', 'text/html'); 2014 | 2015 | expect(response.status).to.equal(200); 2016 | expect(response.type).to.equal('text/html'); 2017 | expect(response.text).to.include( 2018 | 'query: "mutation TestMutation { writeTest { test } }"', 2019 | ); 2020 | expect(response.text).to.include('response: undefined'); 2021 | }); 2022 | 2023 | it('returns HTML if preferred', async () => { 2024 | const app = server(); 2025 | 2026 | app.use( 2027 | mount( 2028 | urlString(), 2029 | graphqlHTTP({ 2030 | schema: TestSchema, 2031 | graphiql: true, 2032 | }), 2033 | ), 2034 | ); 2035 | 2036 | const response = await request(app.listen()) 2037 | .get(urlString({ query: '{test}' })) 2038 | .set('Accept', 'text/html,application/json'); 2039 | 2040 | expect(response.status).to.equal(200); 2041 | expect(response.type).to.equal('text/html'); 2042 | expect(response.text).to.include('{test}'); 2043 | expect(response.text).to.include('graphiql.min.js'); 2044 | }); 2045 | 2046 | it('returns JSON if preferred', async () => { 2047 | const app = server(); 2048 | 2049 | app.use( 2050 | mount( 2051 | urlString(), 2052 | graphqlHTTP({ 2053 | schema: TestSchema, 2054 | graphiql: true, 2055 | }), 2056 | ), 2057 | ); 2058 | 2059 | const response = await request(app.listen()) 2060 | .get(urlString({ query: '{test}' })) 2061 | .set('Accept', 'application/json,text/html'); 2062 | 2063 | expect(response.status).to.equal(200); 2064 | expect(response.type).to.equal('application/json'); 2065 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 2066 | }); 2067 | 2068 | it('prefers JSON if unknown accept', async () => { 2069 | const app = server(); 2070 | 2071 | app.use( 2072 | mount( 2073 | urlString(), 2074 | graphqlHTTP({ 2075 | schema: TestSchema, 2076 | graphiql: true, 2077 | }), 2078 | ), 2079 | ); 2080 | 2081 | const response = await request(app.listen()) 2082 | .get(urlString({ query: '{test}' })) 2083 | .set('Accept', 'unknown'); 2084 | 2085 | expect(response.status).to.equal(200); 2086 | expect(response.type).to.equal('application/json'); 2087 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 2088 | }); 2089 | 2090 | it('prefers JSON if explicitly requested raw response', async () => { 2091 | const app = server(); 2092 | 2093 | app.use( 2094 | mount( 2095 | urlString(), 2096 | graphqlHTTP({ 2097 | schema: TestSchema, 2098 | graphiql: true, 2099 | }), 2100 | ), 2101 | ); 2102 | 2103 | const response = await request(app.listen()) 2104 | .get(urlString({ query: '{test}', raw: '' })) 2105 | .set('Accept', 'text/html'); 2106 | 2107 | expect(response.status).to.equal(200); 2108 | expect(response.type).to.equal('application/json'); 2109 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 2110 | }); 2111 | 2112 | it('contains subscriptionEndpoint within GraphiQL', async () => { 2113 | const app = server(); 2114 | 2115 | app.use( 2116 | mount( 2117 | urlString(), 2118 | graphqlHTTP({ 2119 | schema: TestSchema, 2120 | graphiql: { subscriptionEndpoint: 'ws://localhost' }, 2121 | }), 2122 | ), 2123 | ); 2124 | 2125 | const response = await request(app.listen()) 2126 | .get(urlString()) 2127 | .set('Accept', 'text/html'); 2128 | 2129 | expect(response.status).to.equal(200); 2130 | expect(response.type).to.equal('text/html'); 2131 | // should contain the function to make fetcher for subscription or non-subscription 2132 | expect(response.text).to.include('makeFetcher'); 2133 | // should contain subscriptions-transport-ws browser client 2134 | expect(response.text).to.include('SubscriptionsTransportWs'); 2135 | 2136 | // should contain the subscriptionEndpoint url 2137 | expect(response.text).to.include('ws:\\/\\/localhost'); 2138 | }); 2139 | 2140 | it('contains subscriptionEndpoint within GraphiQL with websocketClient option', async () => { 2141 | const app = server(); 2142 | 2143 | app.use( 2144 | mount( 2145 | urlString(), 2146 | graphqlHTTP({ 2147 | schema: TestSchema, 2148 | graphiql: { 2149 | subscriptionEndpoint: 'ws://localhost', 2150 | websocketClient: 'v1', 2151 | }, 2152 | }), 2153 | ), 2154 | ); 2155 | 2156 | const response = await request(app.listen()) 2157 | .get(urlString()) 2158 | .set('Accept', 'text/html'); 2159 | 2160 | expect(response.status).to.equal(200); 2161 | expect(response.type).to.equal('text/html'); 2162 | // should contain graphql-ws browser client 2163 | expect(response.text).to.include('graphql-transport-ws'); 2164 | 2165 | // should contain the subscriptionEndpoint url 2166 | expect(response.text).to.include('ws:\\/\\/localhost'); 2167 | }); 2168 | }); 2169 | 2170 | describe('Custom validate function', () => { 2171 | it('returns data', async () => { 2172 | const app = server(); 2173 | 2174 | app.use( 2175 | mount( 2176 | urlString(), 2177 | graphqlHTTP({ 2178 | schema: TestSchema, 2179 | customValidateFn(schema, documentAST, validationRules) { 2180 | return validate(schema, documentAST, validationRules); 2181 | }, 2182 | }), 2183 | ), 2184 | ); 2185 | 2186 | const response = await request(app.listen()) 2187 | .get(urlString({ query: '{test}', raw: '' })) 2188 | .set('Accept', 'text/html'); 2189 | 2190 | expect(response.status).to.equal(200); 2191 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 2192 | }); 2193 | 2194 | it('returns validation errors', async () => { 2195 | const app = server(); 2196 | 2197 | app.use( 2198 | mount( 2199 | urlString(), 2200 | graphqlHTTP({ 2201 | schema: TestSchema, 2202 | customValidateFn(schema, documentAST, validationRules) { 2203 | const errors = validate(schema, documentAST, validationRules); 2204 | 2205 | return [new GraphQLError(`custom error ${errors.length}`)]; 2206 | }, 2207 | }), 2208 | ), 2209 | ); 2210 | 2211 | const response = await request(app.listen()).get( 2212 | urlString({ 2213 | query: '{thrower}', 2214 | }), 2215 | ); 2216 | 2217 | expect(response.status).to.equal(400); 2218 | expect(JSON.parse(response.text)).to.deep.equal({ 2219 | errors: [ 2220 | { 2221 | message: 'custom error 0', 2222 | }, 2223 | ], 2224 | }); 2225 | }); 2226 | }); 2227 | 2228 | describe('Custom validation rules', () => { 2229 | const AlwaysInvalidRule = function ( 2230 | context: ValidationContext, 2231 | ): ASTVisitor { 2232 | return { 2233 | Document() { 2234 | context.reportError( 2235 | new GraphQLError('AlwaysInvalidRule was really invalid!'), 2236 | ); 2237 | }, 2238 | }; 2239 | }; 2240 | 2241 | it('Do not execute a query if it do not pass the custom validation.', async () => { 2242 | const app = server(); 2243 | 2244 | app.use( 2245 | mount( 2246 | urlString(), 2247 | graphqlHTTP({ 2248 | schema: TestSchema, 2249 | validationRules: [AlwaysInvalidRule], 2250 | pretty: true, 2251 | }), 2252 | ), 2253 | ); 2254 | 2255 | const response = await request(app.listen()).get( 2256 | urlString({ 2257 | query: '{thrower}', 2258 | }), 2259 | ); 2260 | 2261 | expect(response.status).to.equal(400); 2262 | expect(JSON.parse(response.text)).to.deep.equal({ 2263 | errors: [ 2264 | { 2265 | message: 'AlwaysInvalidRule was really invalid!', 2266 | }, 2267 | ], 2268 | }); 2269 | }); 2270 | }); 2271 | 2272 | describe('Session support', () => { 2273 | it('supports koa-session', async () => { 2274 | const SessionAwareGraphQLSchema = new GraphQLSchema({ 2275 | query: new GraphQLObjectType({ 2276 | name: 'MyType', 2277 | fields: { 2278 | myField: { 2279 | type: GraphQLString, 2280 | resolve(_parentValue, _, sess) { 2281 | return sess.id; 2282 | }, 2283 | }, 2284 | }, 2285 | }), 2286 | }); 2287 | const app = server(); 2288 | app.keys = ['some secret hurr']; 2289 | app.use(session(app)); 2290 | app.use((ctx, next) => { 2291 | if (ctx.session !== null) { 2292 | ctx.session.id = 'me'; 2293 | } 2294 | return next(); 2295 | }); 2296 | 2297 | app.use( 2298 | mount( 2299 | '/graphql', 2300 | graphqlHTTP((_req, _res, ctx) => ({ 2301 | schema: SessionAwareGraphQLSchema, 2302 | context: ctx.session, 2303 | })), 2304 | ), 2305 | ); 2306 | 2307 | const response = await request(app.listen()).get( 2308 | urlString({ 2309 | query: '{myField}', 2310 | }), 2311 | ); 2312 | 2313 | expect(response.text).to.equal('{"data":{"myField":"me"}}'); 2314 | }); 2315 | }); 2316 | 2317 | describe('Custom execute', () => { 2318 | it('allow to replace default execute', async () => { 2319 | const app = server(); 2320 | 2321 | let seenExecuteArgs; 2322 | 2323 | app.use( 2324 | mount( 2325 | urlString(), 2326 | graphqlHTTP(() => ({ 2327 | schema: TestSchema, 2328 | async customExecuteFn(args) { 2329 | seenExecuteArgs = args; 2330 | const result = await Promise.resolve(execute(args)); 2331 | return { 2332 | ...result, 2333 | data: { 2334 | ...result.data, 2335 | test2: 'Modification', 2336 | }, 2337 | }; 2338 | }, 2339 | })), 2340 | ), 2341 | ); 2342 | 2343 | const response = await request(app.listen()) 2344 | .get(urlString({ query: '{test}', raw: '' })) 2345 | .set('Accept', 'text/html'); 2346 | 2347 | expect(response.text).to.equal( 2348 | '{"data":{"test":"Hello World","test2":"Modification"}}', 2349 | ); 2350 | expect(seenExecuteArgs).to.not.equal(null); 2351 | }); 2352 | 2353 | it('catches errors thrown from custom execute function', async () => { 2354 | const app = server(); 2355 | 2356 | app.use( 2357 | mount( 2358 | urlString(), 2359 | graphqlHTTP(() => ({ 2360 | schema: TestSchema, 2361 | customExecuteFn() { 2362 | throw new Error('I did something wrong'); 2363 | }, 2364 | })), 2365 | ), 2366 | ); 2367 | 2368 | const response = await request(app.listen()) 2369 | .get(urlString({ query: '{test}', raw: '' })) 2370 | .set('Accept', 'text/html'); 2371 | 2372 | expect(response.status).to.equal(400); 2373 | expect(response.text).to.equal( 2374 | '{"errors":[{"message":"I did something wrong"}]}', 2375 | ); 2376 | }); 2377 | }); 2378 | 2379 | describe('Custom parse function', () => { 2380 | it('can replace default parse functionality', async () => { 2381 | const app = server(); 2382 | 2383 | let seenParseArgs; 2384 | 2385 | app.use( 2386 | mount( 2387 | urlString(), 2388 | graphqlHTTP(() => ({ 2389 | schema: TestSchema, 2390 | customParseFn(args) { 2391 | seenParseArgs = args; 2392 | return parse(new Source('{test}', 'Custom parse function')); 2393 | }, 2394 | })), 2395 | ), 2396 | ); 2397 | 2398 | const response = await request(app.listen()).get( 2399 | urlString({ query: '----' }), 2400 | ); 2401 | 2402 | expect(response.status).to.equal(200); 2403 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 2404 | expect(seenParseArgs).property('body', '----'); 2405 | }); 2406 | 2407 | it('can throw errors', async () => { 2408 | const app = server(); 2409 | 2410 | app.use( 2411 | mount( 2412 | urlString(), 2413 | graphqlHTTP(() => ({ 2414 | schema: TestSchema, 2415 | customParseFn() { 2416 | throw new GraphQLError('my custom parse error'); 2417 | }, 2418 | })), 2419 | ), 2420 | ); 2421 | 2422 | const response = await request(app.listen()).get( 2423 | urlString({ query: '----' }), 2424 | ); 2425 | 2426 | expect(response.status).to.equal(400); 2427 | expect(response.text).to.equal( 2428 | '{"errors":[{"message":"my custom parse error"}]}', 2429 | ); 2430 | }); 2431 | }); 2432 | 2433 | describe('Custom result extensions', () => { 2434 | it('allows for adding extensions', async () => { 2435 | const app = server(); 2436 | 2437 | app.use( 2438 | mount( 2439 | urlString(), 2440 | graphqlHTTP(() => ({ 2441 | schema: TestSchema, 2442 | context: { foo: 'bar' }, 2443 | extensions({ context }) { 2444 | return { contextValue: JSON.stringify(context) }; 2445 | }, 2446 | })), 2447 | ), 2448 | ); 2449 | 2450 | const response = await request(app.listen()) 2451 | .get(urlString({ query: '{test}', raw: '' })) 2452 | .set('Accept', 'text/html'); 2453 | 2454 | expect(response.status).to.equal(200); 2455 | expect(response.type).to.equal('application/json'); 2456 | expect(response.text).to.equal( 2457 | '{"data":{"test":"Hello World"},"extensions":{"contextValue":"{\\"foo\\":\\"bar\\"}"}}', 2458 | ); 2459 | }); 2460 | 2461 | it('extensions have access to initial GraphQL result', async () => { 2462 | const app = server(); 2463 | 2464 | app.use( 2465 | mount( 2466 | urlString(), 2467 | graphqlHTTP({ 2468 | schema: TestSchema, 2469 | customFormatErrorFn: () => ({ 2470 | message: 'Some generic error message.', 2471 | }), 2472 | extensions({ result }) { 2473 | return { preservedResult: { ...result } }; 2474 | }, 2475 | }), 2476 | ), 2477 | ); 2478 | 2479 | const response = await request(app.listen()).get( 2480 | urlString({ 2481 | query: '{thrower}', 2482 | }), 2483 | ); 2484 | 2485 | expect(response.status).to.equal(200); 2486 | expect(JSON.parse(response.text)).to.deep.equal({ 2487 | data: { thrower: null }, 2488 | errors: [{ message: 'Some generic error message.' }], 2489 | extensions: { 2490 | preservedResult: { 2491 | data: { thrower: null }, 2492 | errors: [ 2493 | { 2494 | message: 'Throws!', 2495 | locations: [{ line: 1, column: 2 }], 2496 | path: ['thrower'], 2497 | }, 2498 | ], 2499 | }, 2500 | }, 2501 | }); 2502 | }); 2503 | 2504 | it('extension function may be async', async () => { 2505 | const app = server(); 2506 | 2507 | app.use( 2508 | mount( 2509 | urlString(), 2510 | graphqlHTTP({ 2511 | schema: TestSchema, 2512 | extensions() { 2513 | // Note: you can await arbitrary things here! 2514 | return Promise.resolve({ eventually: 42 }); 2515 | }, 2516 | }), 2517 | ), 2518 | ); 2519 | 2520 | const response = await request(app.listen()) 2521 | .get(urlString({ query: '{test}', raw: '' })) 2522 | .set('Accept', 'text/html'); 2523 | 2524 | expect(response.status).to.equal(200); 2525 | expect(response.type).to.equal('application/json'); 2526 | expect(response.text).to.equal( 2527 | '{"data":{"test":"Hello World"},"extensions":{"eventually":42}}', 2528 | ); 2529 | }); 2530 | 2531 | it('does nothing if extensions function does not return an object', async () => { 2532 | const app = server(); 2533 | 2534 | app.use( 2535 | mount( 2536 | urlString(), 2537 | // @ts-expect-error 2538 | graphqlHTTP(() => ({ 2539 | schema: TestSchema, 2540 | context: { foo: 'bar' }, 2541 | extensions({ context }) { 2542 | return () => ({ contextValue: JSON.stringify(context) }); 2543 | }, 2544 | })), 2545 | ), 2546 | ); 2547 | 2548 | const response = await request(app.listen()) 2549 | .get(urlString({ query: '{test}', raw: '' })) 2550 | .set('Accept', 'text/html'); 2551 | 2552 | expect(response.status).to.equal(200); 2553 | expect(response.type).to.equal('application/json'); 2554 | expect(response.text).to.equal('{"data":{"test":"Hello World"}}'); 2555 | }); 2556 | }); 2557 | }); 2558 | --------------------------------------------------------------------------------