├── .editorconfig ├── .eslintrc.json ├── .github ├── funding.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── GraphQLAggregateError.mjs ├── GraphQLAggregateError.test.mjs ├── assertKoaContextRequestGraphQL.mjs ├── assertKoaContextRequestGraphQL.test.mjs ├── changelog.md ├── checkGraphQLSchema.mjs ├── checkGraphQLSchema.test.mjs ├── checkGraphQLValidationRules.mjs ├── checkGraphQLValidationRules.test.mjs ├── checkOptions.mjs ├── checkOptions.test.mjs ├── errorHandler.mjs ├── errorHandler.test.mjs ├── execute.mjs ├── execute.test.mjs ├── graphql-api-koa-logo.sketch ├── graphql-api-koa-logo.svg ├── jsconfig.json ├── license.md ├── package.json ├── readme.md ├── test.mjs └── test ├── fetchGraphQL.mjs └── listen.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | max_line_length = 80 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended"], 3 | "env": { 4 | "es2022": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": "latest" 9 | }, 10 | "plugins": ["simple-import-sort"], 11 | "rules": { 12 | "simple-import-sort/imports": "error", 13 | "simple-import-sort/exports": "error" 14 | }, 15 | "overrides": [ 16 | { 17 | "files": ["*.mjs"], 18 | "parserOptions": { 19 | "sourceType": "module" 20 | }, 21 | "globals": { 22 | "__dirname": "off", 23 | "__filename": "off", 24 | "exports": "off", 25 | "module": "off", 26 | "require": "off" 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: jaydenseric 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Test with Node.js v${{ matrix.node }} and ${{ matrix.os }} 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, macos-latest] 10 | node: ["14", "16", "18"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Setup Node.js v${{ matrix.node }} 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: ${{ matrix.node }} 17 | - name: npm install and test 18 | run: npm install-test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "never" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.disableAutomaticTypeAcquisition": true, 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "typescript.tsdk": "node_modules/typescript/lib" 5 | } 6 | -------------------------------------------------------------------------------- /GraphQLAggregateError.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { GraphQLError } from "graphql"; 4 | 5 | /** 6 | * An aggregate error for GraphQL schema validation, query validation, or 7 | * execution errors. 8 | */ 9 | export default class GraphQLAggregateError 10 | // Todo: Use `AggregateError` instead of `Error`, once it’s available in all 11 | // supported Node.js versions. 12 | extends Error 13 | { 14 | /** 15 | * @param {ReadonlyArray} errors GraphQL 16 | * errors. 17 | * @param {string} message Aggregate error message. 18 | * @param {number} status Determines the response HTTP status code. 19 | * @param {boolean} expose Should the original error {@linkcode message} be 20 | * exposed to the client. Note that individual {@linkcode errors} that 21 | * represent GraphQL execution errors thrown in resolvers have an 22 | * {@linkcode GraphQLError.originalError originalError} property that may 23 | * have an `expose` property. 24 | */ 25 | constructor(errors, message, status, expose) { 26 | if (!Array.isArray(errors)) 27 | throw new TypeError("Argument 1 `errors` must be an array."); 28 | 29 | if (!errors.every((error) => error instanceof GraphQLError)) 30 | throw new TypeError( 31 | "Argument 1 `errors` must be an array containing only `GraphQLError` instances." 32 | ); 33 | 34 | if (typeof message !== "string") 35 | throw new TypeError("Argument 2 `message` must be a string."); 36 | 37 | if (typeof status !== "number") 38 | throw new TypeError("Argument 3 `status` must be a number."); 39 | 40 | if (typeof expose !== "boolean") 41 | throw new TypeError("Argument 4 `expose` must be a boolean."); 42 | 43 | super(message); 44 | 45 | /** 46 | * Error name. 47 | * @type {string} 48 | */ 49 | this.name = "GraphQLAggregateError"; 50 | 51 | /** 52 | * GraphQL errors. 53 | * @type {Array} 54 | */ 55 | this.errors = [...errors]; 56 | 57 | /** 58 | * Determines the response HTTP status code. 59 | * @type {number} 60 | */ 61 | this.status = status; 62 | 63 | /** 64 | * Should the {@linkcode GraphQLAggregateError.message message} be exposed 65 | * to the client. 66 | * @type {boolean} 67 | */ 68 | this.expose = expose; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /GraphQLAggregateError.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; 4 | 5 | import { GraphQLError } from "graphql"; 6 | 7 | import GraphQLAggregateError from "./GraphQLAggregateError.mjs"; 8 | 9 | /** 10 | * Adds `GraphQLAggregateError` tests. 11 | * @param {import("test-director").default} tests Test director. 12 | */ 13 | export default (tests) => { 14 | tests.add( 15 | "`GraphQLAggregateError` constructor, argument 1 `errors` not an array.", 16 | () => { 17 | throws(() => { 18 | new GraphQLAggregateError( 19 | // @ts-expect-error Testing invalid. 20 | true, 21 | "", 22 | 200, 23 | true 24 | ); 25 | }, new TypeError("Argument 1 `errors` must be an array.")); 26 | } 27 | ); 28 | 29 | tests.add( 30 | "`GraphQLAggregateError` constructor, argument 1 `errors` array containing a non `GraphQLError` instance.", 31 | () => { 32 | throws(() => { 33 | new GraphQLAggregateError( 34 | [ 35 | new GraphQLError("A"), 36 | // @ts-expect-error Testing invalid. 37 | true, 38 | ], 39 | "", 40 | 200, 41 | true 42 | ); 43 | }, new TypeError("Argument 1 `errors` must be an array containing only `GraphQLError` instances.")); 44 | } 45 | ); 46 | 47 | tests.add( 48 | "`GraphQLAggregateError` constructor, argument 2 `message` not a string.", 49 | () => { 50 | throws(() => { 51 | new GraphQLAggregateError( 52 | [], 53 | // @ts-expect-error Testing invalid. 54 | true, 55 | 200, 56 | true 57 | ); 58 | }, new TypeError("Argument 2 `message` must be a string.")); 59 | } 60 | ); 61 | 62 | tests.add( 63 | "`GraphQLAggregateError` constructor, argument 3 `status` not a number.", 64 | () => { 65 | throws(() => { 66 | new GraphQLAggregateError( 67 | [], 68 | "", 69 | // @ts-expect-error Testing invalid. 70 | true, 71 | true 72 | ); 73 | }, new TypeError("Argument 3 `status` must be a number.")); 74 | } 75 | ); 76 | 77 | tests.add( 78 | "`GraphQLAggregateError` constructor, argument 4 `expose` not a boolean.", 79 | () => { 80 | throws(() => { 81 | new GraphQLAggregateError( 82 | [], 83 | "", 84 | 200, 85 | // @ts-expect-error Testing invalid. 86 | 1 87 | ); 88 | }, new TypeError("Argument 4 `expose` must be a boolean.")); 89 | } 90 | ); 91 | 92 | tests.add("`GraphQLAggregateError` constructor, valid.", () => { 93 | const errors = Object.freeze([ 94 | new GraphQLError("A"), 95 | new GraphQLError("B"), 96 | ]); 97 | const message = "abc"; 98 | const status = 200; 99 | const expose = true; 100 | const graphqlAggregateError = new GraphQLAggregateError( 101 | errors, 102 | message, 103 | status, 104 | expose 105 | ); 106 | 107 | ok(graphqlAggregateError instanceof Error); 108 | strictEqual(graphqlAggregateError.name, "GraphQLAggregateError"); 109 | deepStrictEqual(graphqlAggregateError.message, message); 110 | deepStrictEqual(graphqlAggregateError.errors, errors); 111 | strictEqual(graphqlAggregateError.status, status); 112 | strictEqual(graphqlAggregateError.expose, expose); 113 | }); 114 | }; 115 | -------------------------------------------------------------------------------- /assertKoaContextRequestGraphQL.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import createHttpError from "http-errors"; 4 | 5 | /** 6 | * Asserts that a Koa context has a correctly structured GraphQL request body. 7 | * @template [KoaContextState=import("koa").DefaultState] 8 | * @template [KoaContext=import("koa").DefaultContext] 9 | * @param {import("koa").ParameterizedContext< 10 | * KoaContextState, 11 | * KoaContext 12 | * >} koaContext Koa context. 13 | * @returns {asserts koaContext is KoaContextRequestGraphQL< 14 | * KoaContextState, 15 | * KoaContext 16 | * >} 17 | */ 18 | export default function assertKoaContextRequestGraphQL(koaContext) { 19 | if (typeof koaContext.request.body === "undefined") 20 | throw createHttpError(500, "Request body missing."); 21 | 22 | if ( 23 | typeof koaContext.request.body !== "object" || 24 | koaContext.request.body == null || 25 | Array.isArray(koaContext.request.body) 26 | ) 27 | throw createHttpError(400, "Request body must be a JSON object."); 28 | 29 | if (!("query" in koaContext.request.body)) 30 | throw createHttpError(400, "GraphQL operation field `query` missing."); 31 | 32 | const body = /** @type {{ [key: string]: unknown }} */ ( 33 | koaContext.request.body 34 | ); 35 | 36 | if (typeof body.query !== "string") 37 | throw createHttpError( 38 | 400, 39 | "GraphQL operation field `query` must be a string." 40 | ); 41 | 42 | if ( 43 | body.operationName != undefined && 44 | body.operationName != null && 45 | typeof body.operationName !== "string" 46 | ) 47 | throw createHttpError( 48 | 400, 49 | "Request body JSON `operationName` field must be a string." 50 | ); 51 | 52 | if ( 53 | body.variables != undefined && 54 | body.variables != null && 55 | (typeof body.variables !== "object" || Array.isArray(body.variables)) 56 | ) 57 | throw createHttpError( 58 | 400, 59 | "Request body JSON `variables` field must be an object." 60 | ); 61 | } 62 | 63 | /** 64 | * @template [KoaContextState=import("koa").DefaultState] 65 | * @template [KoaContext=import("koa").DefaultContext] 66 | * @typedef {import("koa").ParameterizedContext & { 67 | * request: { 68 | * body: { 69 | * query: string, 70 | * operationName?: string | null, 71 | * variables?: { [variableName: string]: unknown } | null, 72 | * [key: string]: unknown, 73 | * } 74 | * } 75 | * }} KoaContextRequestGraphQL 76 | */ 77 | -------------------------------------------------------------------------------- /assertKoaContextRequestGraphQL.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { doesNotThrow, throws } from "node:assert"; 4 | 5 | import assertKoaContextRequestGraphQL from "./assertKoaContextRequestGraphQL.mjs"; 6 | 7 | /** 8 | * Adds `assertKoaContextRequestGraphQL` tests. 9 | * @param {import("test-director").default} tests Test director. 10 | */ 11 | export default (tests) => { 12 | tests.add( 13 | "`assertKoaContextRequestGraphQL` with request body invalid, boolean.", 14 | () => { 15 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 16 | request: { 17 | body: true, 18 | }, 19 | }); 20 | 21 | throws(() => assertKoaContextRequestGraphQL(koaContext), { 22 | name: "BadRequestError", 23 | message: "Request body must be a JSON object.", 24 | status: 400, 25 | expose: true, 26 | }); 27 | } 28 | ); 29 | 30 | tests.add( 31 | "`assertKoaContextRequestGraphQL` with request body invalid, array.", 32 | () => { 33 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 34 | request: { 35 | body: [], 36 | }, 37 | }); 38 | 39 | throws(() => assertKoaContextRequestGraphQL(koaContext), { 40 | name: "BadRequestError", 41 | message: "Request body must be a JSON object.", 42 | status: 400, 43 | expose: true, 44 | }); 45 | } 46 | ); 47 | 48 | tests.add( 49 | "`assertKoaContextRequestGraphQL` with request body invalid, `query` missing.", 50 | () => { 51 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 52 | request: { 53 | body: {}, 54 | }, 55 | }); 56 | 57 | throws(() => assertKoaContextRequestGraphQL(koaContext), { 58 | name: "BadRequestError", 59 | message: "GraphQL operation field `query` missing.", 60 | status: 400, 61 | expose: true, 62 | }); 63 | } 64 | ); 65 | 66 | tests.add( 67 | "`assertKoaContextRequestGraphQL` with request body invalid, `query` not a string.", 68 | () => { 69 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 70 | request: { 71 | body: { 72 | query: true, 73 | }, 74 | }, 75 | }); 76 | 77 | throws(() => assertKoaContextRequestGraphQL(koaContext), { 78 | name: "BadRequestError", 79 | message: "GraphQL operation field `query` must be a string.", 80 | status: 400, 81 | expose: true, 82 | }); 83 | } 84 | ); 85 | 86 | tests.add( 87 | "`assertKoaContextRequestGraphQL` with request body invalid, `operationName` not a string.", 88 | () => { 89 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 90 | request: { 91 | body: { 92 | query: "", 93 | operationName: true, 94 | }, 95 | }, 96 | }); 97 | 98 | throws(() => assertKoaContextRequestGraphQL(koaContext), { 99 | name: "BadRequestError", 100 | message: "Request body JSON `operationName` field must be a string.", 101 | status: 400, 102 | expose: true, 103 | }); 104 | } 105 | ); 106 | 107 | tests.add( 108 | "`assertKoaContextRequestGraphQL` with request body invalid, `variables` invalid, boolean.", 109 | () => { 110 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 111 | request: { 112 | body: { 113 | query: "", 114 | variables: true, 115 | }, 116 | }, 117 | }); 118 | 119 | throws(() => assertKoaContextRequestGraphQL(koaContext), { 120 | name: "BadRequestError", 121 | message: "Request body JSON `variables` field must be an object.", 122 | status: 400, 123 | expose: true, 124 | }); 125 | } 126 | ); 127 | 128 | tests.add( 129 | "`assertKoaContextRequestGraphQL` with request body invalid, `variables` invalid, array.", 130 | () => { 131 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 132 | request: { 133 | body: { 134 | query: "", 135 | variables: [], 136 | }, 137 | }, 138 | }); 139 | 140 | throws(() => assertKoaContextRequestGraphQL(koaContext), { 141 | name: "BadRequestError", 142 | message: "Request body JSON `variables` field must be an object.", 143 | status: 400, 144 | expose: true, 145 | }); 146 | } 147 | ); 148 | 149 | tests.add( 150 | "`assertKoaContextRequestGraphQL` with request body valid, `query` only.", 151 | () => { 152 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 153 | request: { 154 | body: { 155 | query: "", 156 | }, 157 | }, 158 | }); 159 | 160 | doesNotThrow(() => assertKoaContextRequestGraphQL(koaContext)); 161 | } 162 | ); 163 | 164 | tests.add( 165 | "`assertKoaContextRequestGraphQL` with request body valid, `operationName` null.", 166 | () => { 167 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 168 | request: { 169 | body: { 170 | query: "", 171 | operationName: null, 172 | }, 173 | }, 174 | }); 175 | 176 | doesNotThrow(() => assertKoaContextRequestGraphQL(koaContext)); 177 | } 178 | ); 179 | 180 | tests.add( 181 | "`assertKoaContextRequestGraphQL` with request body valid, `variables` null.", 182 | () => { 183 | const koaContext = /** @type {import("koa").ParameterizedContext} */ ({ 184 | request: { 185 | body: { 186 | query: "", 187 | variables: null, 188 | }, 189 | }, 190 | }); 191 | 192 | doesNotThrow(() => assertKoaContextRequestGraphQL(koaContext)); 193 | } 194 | ); 195 | 196 | tests.add("`assertKoaContextRequestGraphQL` with generic arguments.", () => { 197 | const koaContext = 198 | /** 199 | * @type {import("koa").ParameterizedContext<{ 200 | * a?: 1 201 | * }, { 202 | * b?: 1 203 | * }>} 204 | */ 205 | ({ 206 | state: {}, 207 | request: { 208 | body: { 209 | query: "", 210 | }, 211 | }, 212 | }); 213 | 214 | assertKoaContextRequestGraphQL(koaContext); 215 | 216 | // @ts-expect-error Testing invalid. 217 | koaContext.b = true; 218 | koaContext.b = 1; 219 | 220 | // @ts-expect-error Testing invalid. 221 | koaContext.state.a = true; 222 | koaContext.state.a = 1; 223 | }); 224 | }; 225 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # graphql-api-koa changelog 2 | 3 | ## 9.1.3 4 | 5 | ### Patch 6 | 7 | - Updated dev dependencies. 8 | - Simplified the `execute` middleware using a new private function `assertKoaContextRequestGraphQL`. 9 | - Improved `execute` middleware related types: 10 | - The middleware no longer requires the Koa context to have a `request.body` type of `[key: string]: unknown`. 11 | - Option `override` now has a more accurate type for function argument 1 `context`. 12 | - Tweaked formatting in tests. 13 | 14 | ## 9.1.2 15 | 16 | ### Patch 17 | 18 | - Updated dependencies. 19 | - Fixed TypeScript types for the class `GraphQLAggregateError` properties that were unintentionally `any`. 20 | 21 | ## 9.1.1 22 | 23 | ### Patch 24 | 25 | - Updated dependencies. 26 | - Use the `node:` URL scheme for Node.js builtin module imports in tests. 27 | - Revamped the readme: 28 | - Removed the badges. 29 | - Added information about TypeScript config and [optimal JavaScript module design](https://jaydenseric.com/blog/optimal-javascript-module-design). 30 | 31 | ## 9.1.0 32 | 33 | ### Minor 34 | 35 | - Added the `GraphQLAggregateError.mjs` module to the package `exports` field. 36 | 37 | ## 9.0.0 38 | 39 | ### Major 40 | 41 | - Updated Node.js support to `^14.17.0 || ^16.0.0 || >= 18.0.0`. 42 | - Updated dev dependencies, some of which require newer Node.js versions than previously supported. 43 | - Public modules are now individually listed in the package `files` and `exports` fields. 44 | - Removed `./package` from the package `exports` field; the full `package.json` filename must be used in a `require` path. 45 | - Removed the package main index module; deep imports must be used. 46 | - Shortened public module deep import paths, removing the `/public/`. 47 | - The `errorHandler` Koa middleware no longer exposes error `locations` and `path` properties within the GraphQL response body `errors` array for errors that aren’t GraphQL validation or execution errors. 48 | - Implemented TypeScript types via JSDoc comments. 49 | 50 | ### Patch 51 | 52 | - Updated dependencies. 53 | - Removed the [`isobject`](https://npm.im/isobject) dependency. 54 | - Simplified dev dependencies and config for ESLint. 55 | - Removed the [`jsdoc-md`](https://npm.im/jsdoc-md) dev dependency and the package `docs-update` and `docs-check` scripts, replacing the readme “API” section with a manually written “Exports” section. 56 | - Check TypeScript types via a new package `types` script. 57 | - Simplified package scripts. 58 | - Updated GitHub Actions CI config: 59 | - Run tests with Node.js v14, v16, v18. 60 | - Updated `actions/checkout` to v3. 61 | - Updated `actions/setup-node` to v3. 62 | - Reorganized the test file structure. 63 | - Improved tests and test helpers. 64 | - Stopped using deprecated `GraphQLError` constructor parameters in tests. 65 | - Updated the `execute` Koa middleware to throw an appropriate HTTP error when the GraphQL operation `operationName` is invalid. 66 | - Implemented a more reliable system based on a new `GraphQLAggregateError` class for throwing a GraphQL validation or execution aggregate error in the `execute` Koa middleware for special handling in the `errorHandler` Koa middleware. 67 | - Updated the `errorHandler` Koa middleware to overwrite an existing Koa context `response.body` if it’s not a suitable object when handling an error. 68 | - Configured Prettier option `singleQuote` to the default, `false`. 69 | - Improved documentation. 70 | - Added a `license.md` MIT License file. 71 | 72 | ## 8.0.0 73 | 74 | ### Major 75 | 76 | - Updated Node.js support to `^12.20.0 || ^14.13.1 || >= 16.0.0`. 77 | - Updated dev dependencies, some of which require newer Node.js versions than previously supported. 78 | - Updated the [`graphql`](https://npm.im/graphql) peer dependency to `^16.0.0`. 79 | - Updated the `errorHandler` Koa middleware to avoid the `formatError` function deprecated in [`graphql`](https://npm.im/graphql) v16, using the new `GraphQLError.toJSON` method. 80 | 81 | ### Patch 82 | 83 | - Also run GitHub Actions CI with Node.js v17. 84 | 85 | ## 7.0.0 86 | 87 | ### Major 88 | 89 | - Updated Node.js support to `^12.20 || >= 14.13`. 90 | - Updated dependencies, some of which require newer Node.js versions than previously supported. 91 | - The API is now ESM in `.mjs` files instead of CJS in `.js` files, [accessible via `import` but not `require`](https://nodejs.org/dist/latest/docs/api/esm.html#esm_require). 92 | - Replaced the the `package.json` `exports` field public [subpath folder mapping](https://nodejs.org/api/packages.html#packages_subpath_folder_mappings) (deprecated by Node.js) with a [subpath pattern](https://nodejs.org/api/packages.html#packages_subpath_patterns). 93 | 94 | ### Minor 95 | 96 | - Added a package `sideEffects` field. 97 | 98 | ### Patch 99 | 100 | - Stop using [`hard-rejection`](https://npm.im/hard-rejection) to detect unhandled `Promise` rejections in tests, as Node.js v15+ does this natively. 101 | - Updated GitHub Actions CI config: 102 | - Updated the tested Node.js versions to v12, v14, v16. 103 | - Updated `actions/checkout` to v2. 104 | - Updated `actions/setup-node` to v2. 105 | - Simplify config with the [`npm install-test`](https://docs.npmjs.com/cli/v7/commands/npm-install-test) command. 106 | - Don’t specify the `CI` environment variable as it’s set by default. 107 | - Removed `npm-debug.log` from the `.gitignore` file as npm [v4.2.0](https://github.com/npm/npm/releases/tag/v4.2.0)+ doesn’t create it in the current working directory. 108 | - Simplified JSDoc related package scripts now that [`jsdoc-md`](https://npm.im/jsdoc-md) v10 automatically generates a Prettier formatted readme. 109 | - Added a package `test:jsdoc` script that checks the readme API docs are up to date with the source JSDoc. 110 | - Readme tweaks. 111 | 112 | ## 6.0.0 113 | 114 | ### Major 115 | 116 | - Added a [package `exports` field](https://nodejs.org/api/esm.html#esm_package_entry_points) with [conditional exports](https://nodejs.org/api/esm.html#esm_conditional_exports) to support native ESM in Node.js and keep internal code private, [whilst avoiding the dual package hazard](https://nodejs.org/api/esm.html#esm_approach_1_use_an_es_module_wrapper). Published files have been reorganized, so previously undocumented deep imports will need to be rewritten according to the newly documented paths. 117 | - Use the `application/graphql+json` content-type instead of `application/json` for GraphQL requests and responses. This content-type [is being standardized](https://github.com/APIs-guru/graphql-over-http/issues/31) in the [GraphQL over HTTP specification](https://github.com/APIs-guru/graphql-over-http). 118 | 119 | ### Patch 120 | 121 | - Updated dev dependencies. 122 | - Updated the EditorConfig URL. 123 | - Stopped testing with Node.js v13. 124 | - Prettier format JSDoc example code. 125 | - Added ESM related keywords to the package `keywords` field. 126 | 127 | ## 5.1.0 128 | 129 | ### Minor 130 | 131 | - Improved `execute` middleware errors: 132 | - More specific error when the operation field `query` isn’t a string. 133 | - Use GraphQL errors when the query can’t be parsed due to syntax errors and expose the location of the syntax error to the client. 134 | 135 | ### Patch 136 | 137 | - Updated dev dependencies. 138 | 139 | ## 5.0.0 140 | 141 | ### Major 142 | 143 | - Updated Node.js support to `^10.13.0 || ^12.0.0 || >= 13.7.0`. 144 | - Updated dev dependencies, some of which require newer Node.js versions than previously supported. 145 | 146 | ### Patch 147 | 148 | - Updated the [`graphql`](https://npm.im/graphql) peer dependency to `0.13.1 - 15`. 149 | - Also run GitHub Actions with Node.js v14. 150 | 151 | ## 4.1.2 152 | 153 | ### Patch 154 | 155 | - Updated dev dependencies. 156 | - Updated Prettier related package scripts. 157 | - Configured Prettier option `semi` to the default, `true`. 158 | - Ensure GitHub Actions run on pull request. 159 | - Minor v0.1.0 changelog entry tweak. 160 | - For clarity, manually specify a `500` HTTP status code even though it’s the default when throwing errors via [`http-errors`](https://npm.im/http-errors). 161 | - Changed the error that the `execute` Koa middleware throws when there are GraphQL execution errors: 162 | 163 | - The error is no longer created using [`http-errors`](https://npm.im/http-errors), which [doesn’t easily accept a `200` `status`](https://github.com/jshttp/http-errors/issues/50#issuecomment-395107925). This allowed the removal of the `createHttpError` function workaround. 164 | - Changed the error message (an internal change as this message is not exposed to the client by the `errorHandler` Koa middleware): 165 | 166 | ```diff 167 | - GraphQL errors. 168 | + GraphQL execution errors. 169 | ``` 170 | 171 | - Updated the `errorHandler` Koa middleware, fixing [#8](https://github.com/jaydenseric/graphql-api-koa/issues/8): 172 | - It can now handle a non enumerable object error, e.g. `null`. 173 | - The `extensions` property of an error is now always exposed to the client in the payload `errors` array, even if the error message is not exposed via an `expose` property. 174 | - Added new `ErrorKoaMiddleware` and `ErrorGraphQLResolver` JSDoc typedefs to better document the special properties errors may have for the `errorHandler` Koa middleware to use to determine how the error appears in the response payload `errors` array and the response HTTP status code. 175 | - Documented that additional custom Koa middleware can be used to customize the response. 176 | - Renamed the `startServer` test helper to `listen`. 177 | 178 | ## 4.1.1 179 | 180 | ### Patch 181 | 182 | - Updated dev dependencies. 183 | - Use [`isobject`](https://npm.im/isobject) for checking if values are enumerable, non-array objects. 184 | - Destructure GraphQL execute results. 185 | 186 | ## 4.1.0 187 | 188 | ### Minor 189 | 190 | - Added a new overridable `execute` middleware option `execute`, to optionally replace [GraphQL.js `execute`](https://graphql.org/graphql-js/execution/#execute). This [adds support](https://twitter.com/jaydenseric/status/1214343687284518912) for [`graphql-jit`](https://npm.im/graphql-jit). 191 | 192 | ### Patch 193 | 194 | - Updated dev dependencies. 195 | - Added a new [`hard-rejection`](https://npm.im/hard-rejection) dev dependency to ensure unhandled rejections in tests exit the process with an error. 196 | - Reorganized the test files. 197 | - Simplified `test/index.js`. 198 | - Tweaked some test names. 199 | - Reduced the `execute` middleware per request validation work. 200 | - Better handling of invalid GraphQL operation `variables` and GraphQL execution errors. 201 | 202 | ## 4.0.0 203 | 204 | ### Major 205 | 206 | - ESM is no longer published, due to CJS/ESM compatibility issues across recent Node.js versions. 207 | - The file structure and non-index file exports have changed. This should only affect projects using undocumented deep imports. 208 | 209 | ### Patch 210 | 211 | - Stop testing the `statusCode` property of HTTP errors; they are inconsequential as Koa uses the `status` property. 212 | 213 | ## 3.0.0 214 | 215 | ### Major 216 | 217 | - Updated Node.js support from v8.5+ to v10+. 218 | - Updated dev dependencies, some of which require newer Node.js versions that v8.5. 219 | 220 | ### Minor 221 | 222 | - Added a package `module` field. 223 | - Setup [GitHub Sponsors funding](https://github.com/sponsors/jaydenseric): 224 | - Added `.github/funding.yml` to display a sponsor button in GitHub. 225 | - Added a `package.json` `funding` field to enable npm CLI funding features. 226 | 227 | ### Patch 228 | 229 | - Removed the now redundant [`eslint-plugin-import-order-alphabetical`](https://npm.im/eslint-plugin-import-order-alphabetical) dev dependency. 230 | - Stop using [`husky`](https://npm.im/husky) and [`lint-staged`](https://npm.im/lint-staged). 231 | - Replaced the [`tap`](https://npm.im/tap) dev dependency with [`test-director`](https://npm.im/test-director) and [`coverage-node`](https://npm.im/coverage-node), and refactored tests accordingly. 232 | - Added a new [`babel-plugin-transform-require-extensions`](https://npm.im/babel-plugin-transform-require-extensions) dev dependency and ensured ESM import specifiers in both source and published `.mjs` files contain file names with extensions, which [are mandatory in the final Node.js ESM implementation](https://nodejs.org/api/esm.html#esm_mandatory_file_extensions). Published CJS `.js` files now also have file extensions in `require` paths. 233 | - Updated the package description and keywords. 234 | - Updated code examples to use CJS instead of ESM. 235 | - Removed `package-lock.json` from `.gitignore` and `.prettierignore` as it’s disabled in `.npmrc` anyway. 236 | - Use strict mode for scripts. 237 | - Use GitHub Actions instead of Travis for CI. 238 | - Use [jsDelivr](https://jsdelivr.com) for the readme logo instead of [RawGit](https://rawgit.com) as they are shutting down. 239 | 240 | ## 2.2.0 241 | 242 | ### Minor 243 | 244 | - Added a new `execute` middleware `validationRules` option. 245 | 246 | ### Patch 247 | 248 | - Updated dev dependencies. 249 | - Ensure only desired `execute` middleware options apply to the GraphQL `execute` function. 250 | - Move tests to files adjacent to source files. 251 | - Renamed the `isPlainObject` helper to `isEnumerableObject` and added tests. 252 | - Moved `isEnumerableObject` checks into the `checkOptions` helper and added tests. 253 | - Renamed the `checkSchema` helper to `checkGraphQLSchema` and added tests. 254 | - Tweaked option related error messages. 255 | - Significantly simplified test assertions using `t.throws` and `t.match`. 256 | - Moved JSDoc type defs into `src/index.js`. 257 | - Renamed the `MiddlewareOptionsOverride` JSDoc type to `ExecuteOptionsOverride`. 258 | - Tweaked JSDoc descriptions. 259 | - Consistent JSDoc syntax style for array types. 260 | - Prettier `errorHandler` JSDoc example source code formatting. 261 | - Moved an `execute` middleware constant from function to module scope. 262 | - Added “Minor” and “Patch” subheadings to old changelog entries. 263 | 264 | ## 2.1.0 265 | 266 | ### Minor 267 | 268 | - `execute` middleware now throws an appropriate error when the `schema` option is undefined, without an override. 269 | 270 | ### Patch 271 | 272 | - Updated dependencies. 273 | - Cleaner readme “API” section table of contents with “See” and “Examples” headings excluded, thanks to [`jsdoc-md` v3.1.0](https://github.com/jaydenseric/jsdoc-md/releases/tag/v3.1.0). 274 | - Removed the `watch` script and [`watch`](https://npm.im/watch) dev dependency. 275 | - Redid the test scripts and added a `.nycrc.json` file for improved reporting and code coverage. 276 | - Simplified the `prepublishOnly` script. 277 | - Reduced the size of the published `package.json` by moving dev tool config to files. 278 | - Removed the package `module` field. By default webpack resolves extensionless paths the same way Node.js (prior to v12) in `--experimental-modules` mode does; `.mjs` files are preferred. Tools misconfigured or unable to resolve `.mjs` can get confused when `module` points to an `.mjs` ESM file and they attempt to resolve named imports from `.js` CJS files. 279 | - Enforced 100% code coverage for tests. 280 | - Test `errorHandler` middleware handles an error correctly after `ctx.response.body` was set. 281 | - Added the Open Graph image design to the logo Sketch file. 282 | 283 | ## 2.0.0 284 | 285 | ### Major 286 | 287 | - Errors thrown in resolvers without an `expose: true` property have their message masked by `Internal Server Error` in the response body to prevent client exposure. Koa app listeners and middleware still have access to the original errors. 288 | 289 | ## 1.1.2 290 | 291 | ### Patch 292 | 293 | - Fix event listeners added in v1.1.1 to be compatible with Node.js < v10. 294 | - Downgrade `node-fetch` to fix `--experimental-modules` tests for Node.js < v10.2.0 (see [bitinn/node-fetch#502](https://github.com/bitinn/node-fetch/issues/502)). 295 | 296 | ## 1.1.1 297 | 298 | ### Patch 299 | 300 | - Updated dependencies. 301 | - Updated package scripts and config for the new [`husky`](https://npm.im/husky) version. 302 | - Silence the `http-errors deprecated non-error status code; use only 4xx or 5xx status codes` warnings that appear (due to [jshttp/http-errors#50](https://github.com/jshttp/http-errors/issues/50)) when there are GraphQL errors. 303 | - Expanded the source into separate files for easier code navigation. 304 | - Add a project logo. 305 | 306 | ## 1.1.0 307 | 308 | ### Minor 309 | 310 | - Support [`graphql`](https://npm.im/graphql) v14. 311 | 312 | ### Patch 313 | 314 | - Updated dependencies. 315 | - Stopped using [`npm-run-all`](https://npm.im/npm-run-all) for package scripts. 316 | - Configured Prettier to lint `.yml` files. 317 | - Ensure the readme Travis build status badge only tracks `master` branch. 318 | - Use [Badgen](https://badgen.net) for the readme npm version badge. 319 | 320 | ## 1.0.0 321 | 322 | ### Patch 323 | 324 | - Updated dependencies. 325 | - Lint fixes following dependency updates. 326 | - Use [`jsdoc-md`](https://npm.im/jsdoc-md) instead of [`documentation`](https://npm.im/documentation) to generate readme API docs. 327 | - Removed a temporary workaround for [a fixed Babel CLI bug](https://github.com/babel/babel/issues/8077). 328 | - Updated package description and tags. 329 | 330 | ## 0.3.1 331 | 332 | ### Patch 333 | 334 | - Updated dependencies. 335 | - Simplified ESLint config with [`eslint-config-env`](https://npm.im/eslint-config-env). 336 | 337 | ## 0.3.0 338 | 339 | ### Minor 340 | 341 | - Refactored package scripts to use `prepare` to support installation via Git (e.g. `npm install jaydenseric/graphql-api-koa`). 342 | 343 | ### Patch 344 | 345 | - Corrected an `errorHandler` middleware example in the readme. 346 | - Compact package `repository` field. 347 | 348 | ## 0.2.0 349 | 350 | ### Minor 351 | 352 | - Set Node.js support as v8.5+. 353 | 354 | ### Patch 355 | 356 | - Avoided using a Koa context response shortcut. 357 | - Fixed test snapshot consistency between Node.js versions (see [tapjs/node-tap#450](https://github.com/tapjs/node-tap/issues/450)). 358 | - Manually test error properties instead of using snapshots. 359 | - Added `errorHandler` middleware tests. 360 | - Readme badge changes to deal with [shields.io](https://shields.io) unreliability: 361 | - Used the more reliable build status badge provided by Travis and placed it first as it loads the quickest. 362 | - Removed the licence badge. The licence can be found in `package.json` and rarely changes. 363 | - Removed the Github issues and stars badges. The readme is most viewed on Github anyway. 364 | - Improved documentation. 365 | 366 | ## 0.1.0 367 | 368 | Initial release. 369 | -------------------------------------------------------------------------------- /checkGraphQLSchema.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { GraphQLSchema, validateSchema } from "graphql"; 4 | import createHttpError from "http-errors"; 5 | 6 | import GraphQLAggregateError from "./GraphQLAggregateError.mjs"; 7 | 8 | /** 9 | * Validates a GraphQL schema. 10 | * @param {GraphQLSchema} schema GraphQL schema. 11 | * @param {string} errorMessagePrefix Error message prefix. 12 | */ 13 | export default function checkGraphQLSchema(schema, errorMessagePrefix) { 14 | if (!(schema instanceof GraphQLSchema)) 15 | throw createHttpError( 16 | 500, 17 | `${errorMessagePrefix} GraphQL schema must be a \`GraphQLSchema\` instance.` 18 | ); 19 | 20 | const schemaValidationErrors = validateSchema(schema); 21 | 22 | if (schemaValidationErrors.length) 23 | throw new GraphQLAggregateError( 24 | schemaValidationErrors, 25 | `${errorMessagePrefix} has GraphQL schema validation errors.`, 26 | 500, 27 | false 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /checkGraphQLSchema.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { doesNotThrow, throws } from "node:assert"; 4 | 5 | import { 6 | GraphQLError, 7 | GraphQLObjectType, 8 | GraphQLSchema, 9 | GraphQLString, 10 | } from "graphql"; 11 | 12 | import checkGraphQLSchema from "./checkGraphQLSchema.mjs"; 13 | import GraphQLAggregateError from "./GraphQLAggregateError.mjs"; 14 | 15 | /** 16 | * Adds `checkGraphQLSchema` tests. 17 | * @param {import("test-director").default} tests Test director. 18 | */ 19 | export default (tests) => { 20 | tests.add("`checkGraphQLSchema` with a valid GraphQL schema.", () => { 21 | doesNotThrow(() => 22 | checkGraphQLSchema( 23 | new GraphQLSchema({ 24 | query: new GraphQLObjectType({ 25 | name: "Query", 26 | fields: { 27 | test: { 28 | type: GraphQLString, 29 | }, 30 | }, 31 | }), 32 | }), 33 | "Test" 34 | ) 35 | ); 36 | }); 37 | 38 | tests.add("`checkGraphQLSchema` with a non GraphQL schema.", () => { 39 | throws( 40 | () => 41 | checkGraphQLSchema( 42 | // @ts-expect-error Testing invalid. 43 | false, 44 | "Test" 45 | ), 46 | { 47 | name: "InternalServerError", 48 | message: "Test GraphQL schema must be a `GraphQLSchema` instance.", 49 | status: 500, 50 | expose: false, 51 | } 52 | ); 53 | }); 54 | 55 | tests.add( 56 | "`checkGraphQLSchema` with GraphQL schema validation errors.", 57 | () => { 58 | throws( 59 | () => checkGraphQLSchema(new GraphQLSchema({}), "Test"), 60 | new GraphQLAggregateError( 61 | [new GraphQLError("Query root type must be provided.")], 62 | "Test has GraphQL schema validation errors.", 63 | 500, 64 | false 65 | ) 66 | ); 67 | } 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /checkGraphQLValidationRules.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import createHttpError from "http-errors"; 4 | 5 | /** 6 | * Validates GraphQL validation rules. 7 | * @param {ReadonlyArray} rules GraphQL 8 | * validation rules. 9 | * @param {string} errorMessagePrefix Error message prefix. 10 | */ 11 | export default function checkGraphQLValidationRules(rules, errorMessagePrefix) { 12 | if (!Array.isArray(rules)) 13 | throw createHttpError( 14 | 500, 15 | `${errorMessagePrefix} GraphQL validation rules must be an array.` 16 | ); 17 | 18 | if (rules.some((rule) => typeof rule !== "function")) 19 | throw createHttpError( 20 | 500, 21 | `${errorMessagePrefix} GraphQL validation rules must be functions.` 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /checkGraphQLValidationRules.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { doesNotThrow, throws } from "node:assert"; 4 | 5 | import { specifiedRules } from "graphql"; 6 | 7 | import checkGraphQLValidationRules from "./checkGraphQLValidationRules.mjs"; 8 | 9 | /** 10 | * Adds `checkGraphQLValidationRules` tests. 11 | * @param {import("test-director").default} tests Test director. 12 | */ 13 | export default (tests) => { 14 | tests.add( 15 | "`checkGraphQLValidationRules` with valid GraphQL validation rules.", 16 | () => { 17 | doesNotThrow(() => checkGraphQLValidationRules(specifiedRules, "Test")); 18 | } 19 | ); 20 | 21 | tests.add("`checkGraphQLValidationRules` with a non array.", () => { 22 | throws( 23 | () => 24 | checkGraphQLValidationRules( 25 | // @ts-expect-error Testing invalid. 26 | false, 27 | "Test" 28 | ), 29 | { 30 | name: "InternalServerError", 31 | message: "Test GraphQL validation rules must be an array.", 32 | status: 500, 33 | expose: false, 34 | } 35 | ); 36 | }); 37 | 38 | tests.add("`checkGraphQLValidationRules` with non function rules.", () => { 39 | throws( 40 | () => 41 | checkGraphQLValidationRules( 42 | [ 43 | // @ts-expect-error Testing invalid. 44 | false, 45 | ], 46 | "Test" 47 | ), 48 | { 49 | name: "InternalServerError", 50 | message: "Test GraphQL validation rules must be functions.", 51 | status: 500, 52 | expose: false, 53 | } 54 | ); 55 | }); 56 | }; 57 | -------------------------------------------------------------------------------- /checkOptions.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import createHttpError from "http-errors"; 4 | 5 | /** 6 | * Validates options are an enumerable object that conforms to a whitelist of 7 | * allowed keys. 8 | * @param {{ [key: string]: unknown }} options Options to validate. 9 | * @param {ReadonlyArray} allowedKeys Allowed option keys. 10 | * @param {string} errorMessagePrefix Error message prefix. 11 | */ 12 | export default function checkOptions(options, allowedKeys, errorMessagePrefix) { 13 | if (typeof options !== "object" || options == null || Array.isArray(options)) 14 | throw createHttpError( 15 | 500, 16 | `${errorMessagePrefix} options must be an enumerable object.` 17 | ); 18 | 19 | const invalid = Object.keys(options).filter( 20 | (option) => !allowedKeys.includes(option) 21 | ); 22 | 23 | if (invalid.length) 24 | throw createHttpError( 25 | 500, 26 | `${errorMessagePrefix} options invalid: \`${invalid.join("`, `")}\`.` 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /checkOptions.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { doesNotThrow, throws } from "node:assert"; 4 | 5 | import checkOptions from "./checkOptions.mjs"; 6 | 7 | /** 8 | * Adds `checkOptions` tests. 9 | * @param {import("test-director").default} tests Test director. 10 | */ 11 | export default (tests) => { 12 | tests.add("`checkOptions` with valid options.", () => { 13 | doesNotThrow(() => checkOptions({ a: true }, ["a"], "Test")); 14 | }); 15 | 16 | tests.add("`checkOptions` with unenumerable options.", () => { 17 | throws( 18 | () => 19 | checkOptions( 20 | // @ts-expect-error Testing invalid. 21 | null, 22 | ["a"], 23 | "Test" 24 | ), 25 | { 26 | message: "Test options must be an enumerable object.", 27 | status: 500, 28 | expose: false, 29 | } 30 | ); 31 | }); 32 | 33 | tests.add("`checkOptions` with invalid option keys.", () => { 34 | throws(() => checkOptions({ a: true, b: true, c: true }, ["b"], "Test"), { 35 | message: "Test options invalid: `a`, `c`.", 36 | status: 500, 37 | expose: false, 38 | }); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /errorHandler.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import createHttpError from "http-errors"; 4 | 5 | import GraphQLAggregateError from "./GraphQLAggregateError.mjs"; 6 | 7 | /** 8 | * Creates Koa middleware to handle errors. Use this before other middleware to 9 | * catch all errors for a correctly formatted 10 | * [GraphQL response](https://spec.graphql.org/October2021/#sec-Errors). 11 | * 12 | * Special {@link KoaMiddlewareError Koa middleware error} properties can be 13 | * used to determine how the error appears in the GraphQL response body 14 | * {@linkcode GraphQLResponseBody.errors errors} array and the response HTTP 15 | * status code. 16 | * 17 | * Additional custom Koa middleware can be used to customize the response. 18 | * @see [GraphQL spec for errors](https://spec.graphql.org/October2021/#sec-Errors). 19 | * @see [GraphQL over HTTP spec](https://github.com/graphql/graphql-over-http). 20 | * @see [Koa error handling docs](https://koajs.com/#error-handling). 21 | * @see [`http-errors`](https://npm.im/http-errors), also a Koa dependency. 22 | * @returns Koa middleware. 23 | * @example 24 | * A client error thrown in Koa middleware… 25 | * 26 | * Error constructed manually: 27 | * 28 | * ```js 29 | * const error = new Error("Rate limit exceeded."); 30 | * error.extensions = { 31 | * code: "RATE_LIMIT_EXCEEDED", 32 | * }; 33 | * error.status = 429; 34 | * ``` 35 | * 36 | * Error constructed using [`http-errors`](https://npm.im/http-errors): 37 | * 38 | * ```js 39 | * import createHttpError from "http-errors"; 40 | * 41 | * const error = createHttpError(429, "Rate limit exceeded.", { 42 | * extensions: { 43 | * code: "RATE_LIMIT_EXCEEDED", 44 | * }, 45 | * }); 46 | * ``` 47 | * 48 | * Response has a 429 HTTP status code, with this body: 49 | * 50 | * ```json 51 | * { 52 | * "errors": [ 53 | * { 54 | * "message": "Rate limit exceeded.", 55 | * "extensions": { 56 | * "code": "RATE_LIMIT_EXCEEDED" 57 | * } 58 | * } 59 | * ] 60 | * } 61 | * ``` 62 | * @example 63 | * A server error thrown in Koa middleware, not exposed to the client… 64 | * 65 | * Error: 66 | * 67 | * ```js 68 | * const error = new Error("Database connection failed."); 69 | * ``` 70 | * 71 | * Response has a 500 HTTP status code, with this body: 72 | * 73 | * ```json 74 | * { 75 | * "errors": [ 76 | * { 77 | * "message": "Internal Server Error" 78 | * } 79 | * ] 80 | * } 81 | * ``` 82 | * @example 83 | * A server error thrown in Koa middleware, exposed to the client… 84 | * 85 | * Error: 86 | * 87 | * ```js 88 | * const error = new Error("Service unavailable due to maintenance."); 89 | * error.status = 503; 90 | * error.expose = true; 91 | * ``` 92 | * 93 | * Response has a 503 HTTP status code, with this body: 94 | * 95 | * ```json 96 | * { 97 | * "errors": [ 98 | * { 99 | * "message": "Service unavailable due to maintenance." 100 | * } 101 | * ] 102 | * } 103 | * ``` 104 | * @example 105 | * An error thrown in a GraphQL resolver, exposed to the client… 106 | * 107 | * Query: 108 | * 109 | * ```graphql 110 | * { 111 | * user(handle: "jaydenseric") { 112 | * name 113 | * email 114 | * } 115 | * } 116 | * ``` 117 | * 118 | * Error thrown in the `User.email` resolver: 119 | * 120 | * ```js 121 | * const error = new Error("Unauthorized access to user data."); 122 | * error.expose = true; 123 | * ``` 124 | * 125 | * Response has a 200 HTTP status code, with this body: 126 | * 127 | * ```json 128 | * { 129 | * "errors": [ 130 | * { 131 | * "message": "Unauthorized access to user data.", 132 | * "locations": [{ "line": 4, "column": 5 }], 133 | * "path": ["user", "email"] 134 | * } 135 | * ], 136 | * "data": { 137 | * "user": { 138 | * "name": "Jayden Seric", 139 | * "email": null 140 | * } 141 | * } 142 | * } 143 | * ``` 144 | */ 145 | export default function errorHandler() { 146 | /** 147 | * Koa middleware to handle errors. 148 | * @param {import("koa").ParameterizedContext} ctx Koa context. 149 | * @param {() => Promise} next 150 | */ 151 | async function errorHandlerMiddleware(ctx, next) { 152 | try { 153 | // Await all following middleware. 154 | await next(); 155 | } catch (error) { 156 | // Create response body if necessary. It may have been created after 157 | // GraphQL execution and contain data. 158 | if (typeof ctx.response.body !== "object" || ctx.response.body == null) 159 | ctx.response.body = {}; 160 | 161 | const body = /** @type {GraphQLResponseBody} */ (ctx.response.body); 162 | 163 | if ( 164 | error instanceof GraphQLAggregateError && 165 | // GraphQL schema validation errors are not exposed. 166 | error.expose 167 | ) { 168 | // Error contains GraphQL query validation or execution errors. 169 | 170 | body.errors = error.errors.map((graphqlError) => { 171 | const formattedError = graphqlError.toJSON(); 172 | 173 | return ( 174 | // Originally thrown in resolvers (not a GraphQL validation error). 175 | graphqlError.originalError && 176 | // Not specifically marked to be exposed to the client. 177 | !( 178 | /** @type {KoaMiddlewareError} */ (graphqlError.originalError) 179 | .expose 180 | ) 181 | ? { 182 | ...formattedError, 183 | 184 | // Overwrite the message to prevent client exposure. Wording 185 | // is consistent with the http-errors 500 server error 186 | // message. 187 | message: "Internal Server Error", 188 | } 189 | : formattedError 190 | ); 191 | }); 192 | 193 | // For GraphQL query validation errors the status will be 400. For 194 | // GraphQL execution errors the status will be 200; by convention they 195 | // shouldn’t result in a response error HTTP status code. 196 | ctx.response.status = error.status; 197 | } else { 198 | // Error is some other Koa middleware error, possibly GraphQL schema 199 | // validation errors. 200 | 201 | // Coerce the error to a HTTP error, in case it’s not one already. 202 | let httpError = createHttpError( 203 | // @ts-ignore Let the library handle an invalid error type. 204 | error 205 | ); 206 | 207 | // If the error is not to be exposed to the client, use a generic 500 208 | // server error. 209 | if (!httpError.expose) { 210 | httpError = createHttpError(500); 211 | 212 | // Assume that an `extensions` property is intended to be exposed to 213 | // the client in the GraphQL response body `errors` array and isn’t 214 | // from something unrelated with a conflicting name. 215 | if ( 216 | // Guard against a non enumerable object error, e.g. null. 217 | error instanceof Error && 218 | typeof (/** @type {KoaMiddlewareError} */ (error).extensions) === 219 | "object" && 220 | /** @type {KoaMiddlewareError} */ (error).extensions != null 221 | ) 222 | httpError.extensions = /** @type {KoaMiddlewareError} */ ( 223 | error 224 | ).extensions; 225 | } 226 | 227 | body.errors = [ 228 | "extensions" in httpError 229 | ? { 230 | message: httpError.message, 231 | extensions: httpError.extensions, 232 | } 233 | : { 234 | message: httpError.message, 235 | }, 236 | ]; 237 | 238 | ctx.response.status = httpError.status; 239 | } 240 | 241 | // Set the content-type. 242 | ctx.response.type = "application/graphql+json"; 243 | 244 | // Support Koa app error listeners. 245 | ctx.app.emit("error", error, ctx); 246 | } 247 | } 248 | 249 | return errorHandlerMiddleware; 250 | } 251 | 252 | /** 253 | * An error thrown within Koa middleware following the 254 | * {@linkcode errorHandler} Koa middleware can have these special properties to 255 | * determine how the error appears in the GraphQL response body 256 | * {@linkcode GraphQLResponseBody.errors errors} array and the response HTTP 257 | * status code. 258 | * @see [GraphQL spec for errors](https://spec.graphql.org/October2021/#sec-Errors). 259 | * @see [Koa error handling docs](https://koajs.com/#error-handling). 260 | * @see [`http-errors`](https://npm.im/http-errors), also a Koa dependency. 261 | * @typedef {object} KoaMiddlewareError 262 | * @prop {string} message Error message. If the error 263 | * {@linkcode KoaMiddlewareError.expose expose} property isn’t `true` or the 264 | * {@linkcode KoaMiddlewareError.status status} property >= 500 (for non 265 | * GraphQL resolver errors), the message is replaced with 266 | * `Internal Server Error` in the GraphQL response body 267 | * {@linkcode GraphQLResponseBody.errors errors} array. 268 | * @prop {number} [status] Determines the response HTTP status code. Not usable 269 | * for GraphQL resolver errors as they shouldn’t prevent the GraphQL request 270 | * from having a 200 HTTP status code. 271 | * @prop {boolean} [expose] Should the original error 272 | * {@linkcode KoaMiddlewareError.message message} be exposed to the client. 273 | * @prop {{ [key: string]: unknown }} [extensions] A map of custom error data 274 | * that is exposed to the client in the GraphQL response body 275 | * {@linkcode GraphQLResponseBody.errors errors} array, regardless of the 276 | * error {@linkcode KoaMiddlewareError.expose expose} or 277 | * {@linkcode KoaMiddlewareError.status status} properties. 278 | */ 279 | 280 | /** 281 | * A GraphQL response body. 282 | * @see [GraphQL over HTTP spec](https://github.com/graphql/graphql-over-http). 283 | * @typedef {object} GraphQLResponseBody 284 | * @prop {Array} [errors] Errors. 285 | * @prop {{ [key: string]: unknown } | null} [data] Data. 286 | * @prop {{ [key: string]: unknown }} [extensions] Custom extensions to the 287 | * GraphQL over HTTP protocol. 288 | */ 289 | -------------------------------------------------------------------------------- /errorHandler.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { deepStrictEqual, ok, strictEqual } from "node:assert"; 4 | import { createServer } from "node:http"; 5 | 6 | import createHttpError from "http-errors"; 7 | import Koa from "koa"; 8 | 9 | import errorHandler from "./errorHandler.mjs"; 10 | import fetchGraphQL from "./test/fetchGraphQL.mjs"; 11 | import listen from "./test/listen.mjs"; 12 | 13 | /** 14 | * Adds `errorHandler` tests. 15 | * @param {import("test-director").default} tests Test director. 16 | */ 17 | export default (tests) => { 18 | tests.add( 19 | "`errorHandler` middleware handles a non enumerable object error.", 20 | async () => { 21 | let koaError; 22 | 23 | const app = new Koa() 24 | .use(errorHandler()) 25 | .use(() => { 26 | throw null; 27 | }) 28 | .on("error", (error) => { 29 | koaError = error; 30 | }); 31 | 32 | const { port, close } = await listen(createServer(app.callback())); 33 | 34 | try { 35 | const response = await fetchGraphQL(port); 36 | 37 | strictEqual(koaError, null); 38 | strictEqual( 39 | response.headers.get("Content-Type"), 40 | "application/graphql+json" 41 | ); 42 | deepStrictEqual(await response.json(), { 43 | errors: [{ message: "Internal Server Error" }], 44 | }); 45 | } finally { 46 | close(); 47 | } 48 | } 49 | ); 50 | 51 | tests.add("`errorHandler` middleware handles a standard error.", async () => { 52 | /** @type {import("http-errors").HttpError | undefined} */ 53 | let koaError; 54 | 55 | const app = new Koa() 56 | .use(errorHandler()) 57 | .use(() => { 58 | /** @type {import("./errorHandler.mjs").KoaMiddlewareError} */ 59 | const error = new Error("Message."); 60 | error.extensions = { a: true }; 61 | throw error; 62 | }) 63 | .on("error", (error) => { 64 | koaError = error; 65 | }); 66 | 67 | const { port, close } = await listen(createServer(app.callback())); 68 | 69 | try { 70 | const response = await fetchGraphQL(port); 71 | 72 | ok(koaError instanceof Error); 73 | strictEqual(koaError.name, "Error"); 74 | strictEqual(koaError.message, "Message."); 75 | strictEqual(koaError.status, 500); 76 | strictEqual(koaError.expose, false); 77 | strictEqual(response.status, 500); 78 | strictEqual( 79 | response.headers.get("Content-Type"), 80 | "application/graphql+json" 81 | ); 82 | deepStrictEqual(await response.json(), { 83 | errors: [ 84 | { 85 | message: "Internal Server Error", 86 | extensions: { a: true }, 87 | }, 88 | ], 89 | }); 90 | } finally { 91 | close(); 92 | } 93 | }); 94 | 95 | tests.add("`errorHandler` middleware handles a HTTP error.", async () => { 96 | /** @type {import("http-errors").HttpError | undefined} */ 97 | let koaError; 98 | 99 | const app = new Koa() 100 | .use(errorHandler()) 101 | .use(() => { 102 | throw createHttpError(403, "Message.", { extensions: { a: true } }); 103 | }) 104 | .on("error", (error) => { 105 | koaError = error; 106 | }); 107 | 108 | const { port, close } = await listen(createServer(app.callback())); 109 | 110 | try { 111 | const response = await fetchGraphQL(port); 112 | 113 | ok(koaError instanceof Error); 114 | strictEqual(koaError.name, "ForbiddenError"); 115 | strictEqual(koaError.message, "Message."); 116 | strictEqual(koaError.status, 403); 117 | strictEqual(koaError.expose, true); 118 | strictEqual(response.status, 403); 119 | strictEqual( 120 | response.headers.get("Content-Type"), 121 | "application/graphql+json" 122 | ); 123 | deepStrictEqual(await response.json(), { 124 | errors: [ 125 | { 126 | message: "Message.", 127 | extensions: { a: true }, 128 | }, 129 | ], 130 | }); 131 | } finally { 132 | close(); 133 | } 134 | }); 135 | 136 | tests.add( 137 | "`errorHandler` middleware handles an error after `ctx.response.body` was set, object.", 138 | async () => { 139 | /** @type {import("http-errors").HttpError | undefined} */ 140 | let koaError; 141 | 142 | const app = new Koa() 143 | .use(errorHandler()) 144 | .use((ctx) => { 145 | ctx.response.body = { data: {} }; 146 | 147 | /** @type {import("./errorHandler.mjs").KoaMiddlewareError} */ 148 | const error = new Error("Message."); 149 | error.extensions = { a: true }; 150 | throw error; 151 | }) 152 | .on("error", (error) => { 153 | koaError = error; 154 | }); 155 | 156 | const { port, close } = await listen(createServer(app.callback())); 157 | 158 | try { 159 | const response = await fetchGraphQL(port); 160 | 161 | ok(koaError instanceof Error); 162 | strictEqual(koaError.name, "Error"); 163 | strictEqual(koaError.message, "Message."); 164 | strictEqual(koaError.status, 500); 165 | strictEqual(koaError.expose, false); 166 | strictEqual(response.status, 500); 167 | strictEqual( 168 | response.headers.get("Content-Type"), 169 | "application/graphql+json" 170 | ); 171 | deepStrictEqual(await response.json(), { 172 | data: {}, 173 | errors: [ 174 | { 175 | message: "Internal Server Error", 176 | extensions: { a: true }, 177 | }, 178 | ], 179 | }); 180 | } finally { 181 | close(); 182 | } 183 | } 184 | ); 185 | 186 | tests.add( 187 | "`errorHandler` middleware handles an error after `ctx.response.body` was set, null.", 188 | async () => { 189 | /** @type {import("http-errors").HttpError | undefined} */ 190 | let koaError; 191 | 192 | const app = new Koa() 193 | .use(errorHandler()) 194 | .use((ctx) => { 195 | ctx.response.body = null; 196 | 197 | /** @type {import("./errorHandler.mjs").KoaMiddlewareError} */ 198 | const error = new Error("Message."); 199 | error.extensions = { a: true }; 200 | throw error; 201 | }) 202 | .on("error", (error) => { 203 | koaError = error; 204 | }); 205 | 206 | const { port, close } = await listen(createServer(app.callback())); 207 | 208 | try { 209 | const response = await fetchGraphQL(port); 210 | 211 | ok(koaError instanceof Error); 212 | strictEqual(koaError.name, "Error"); 213 | strictEqual(koaError.message, "Message."); 214 | strictEqual(koaError.status, 500); 215 | strictEqual(koaError.expose, false); 216 | strictEqual(response.status, 500); 217 | strictEqual( 218 | response.headers.get("Content-Type"), 219 | "application/graphql+json" 220 | ); 221 | deepStrictEqual(await response.json(), { 222 | errors: [ 223 | { 224 | message: "Internal Server Error", 225 | extensions: { a: true }, 226 | }, 227 | ], 228 | }); 229 | } finally { 230 | close(); 231 | } 232 | } 233 | ); 234 | 235 | tests.add( 236 | "`errorHandler` middleware handles an error after `ctx.response.body` was set, boolean.", 237 | async () => { 238 | /** @type {import("http-errors").HttpError | undefined} */ 239 | let koaError; 240 | 241 | const app = new Koa() 242 | .use(errorHandler()) 243 | .use((ctx) => { 244 | ctx.response.body = true; 245 | 246 | /** @type {import("./errorHandler.mjs").KoaMiddlewareError} */ 247 | const error = new Error("Message."); 248 | error.extensions = { a: true }; 249 | throw error; 250 | }) 251 | .on("error", (error) => { 252 | koaError = error; 253 | }); 254 | 255 | const { port, close } = await listen(createServer(app.callback())); 256 | 257 | try { 258 | const response = await fetchGraphQL(port); 259 | 260 | ok(koaError instanceof Error); 261 | strictEqual(koaError.name, "Error"); 262 | strictEqual(koaError.message, "Message."); 263 | strictEqual(koaError.status, 500); 264 | strictEqual(koaError.expose, false); 265 | strictEqual(response.status, 500); 266 | strictEqual( 267 | response.headers.get("Content-Type"), 268 | "application/graphql+json" 269 | ); 270 | deepStrictEqual(await response.json(), { 271 | errors: [ 272 | { 273 | message: "Internal Server Error", 274 | extensions: { a: true }, 275 | }, 276 | ], 277 | }); 278 | } finally { 279 | close(); 280 | } 281 | } 282 | ); 283 | }; 284 | -------------------------------------------------------------------------------- /execute.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | execute as graphqlExecute, 5 | parse, 6 | Source, 7 | specifiedRules, 8 | validate, 9 | } from "graphql"; 10 | import createHttpError from "http-errors"; 11 | 12 | import assertKoaContextRequestGraphQL from "./assertKoaContextRequestGraphQL.mjs"; 13 | import checkGraphQLSchema from "./checkGraphQLSchema.mjs"; 14 | import checkGraphQLValidationRules from "./checkGraphQLValidationRules.mjs"; 15 | import checkOptions from "./checkOptions.mjs"; 16 | import GraphQLAggregateError from "./GraphQLAggregateError.mjs"; 17 | 18 | /** 19 | * List of {@linkcode ExecuteOptions} keys allowed for per request override 20 | * config, for validation purposes. 21 | */ 22 | const ALLOWED_EXECUTE_OPTIONS_OVERRIDE = /** @type {const} */ ([ 23 | "schema", 24 | "validationRules", 25 | "rootValue", 26 | "contextValue", 27 | "fieldResolver", 28 | "execute", 29 | ]); 30 | 31 | /** 32 | * List of {@linkcode ExecuteOptions} keys allowed for static config, for 33 | * validation purposes. 34 | */ 35 | const ALLOWED_EXECUTE_OPTIONS_STATIC = /** @type {const} */ ([ 36 | ...ALLOWED_EXECUTE_OPTIONS_OVERRIDE, 37 | "override", 38 | ]); 39 | 40 | /** 41 | * Creates Koa middleware to execute GraphQL. Use after the `errorHandler` and 42 | * [body parser](https://npm.im/koa-bodyparser) middleware. 43 | * @template [KoaContextState=import("koa").DefaultState] 44 | * @template [KoaContext=import("koa").DefaultContext] 45 | * @param {ExecuteOptions & { 46 | * override?: ( 47 | * context: import("./assertKoaContextRequestGraphQL.mjs") 48 | * .KoaContextRequestGraphQL 49 | * ) => Partial | Promise>, 50 | * }} options Options. 51 | * @returns Koa middleware. 52 | * @example 53 | * A basic GraphQL API: 54 | * 55 | * ```js 56 | * import Koa from "koa"; 57 | * import bodyParser from "koa-bodyparser"; 58 | * import errorHandler from "graphql-api-koa/errorHandler.mjs"; 59 | * import execute from "graphql-api-koa/execute.mjs"; 60 | * import schema from "./schema.mjs"; 61 | * 62 | * const app = new Koa() 63 | * .use(errorHandler()) 64 | * .use( 65 | * bodyParser({ 66 | * extendTypes: { 67 | * json: "application/graphql+json", 68 | * }, 69 | * }) 70 | * ) 71 | * .use(execute({ schema })); 72 | * ``` 73 | * @example 74 | * {@linkcode execute} middleware options that sets the schema once but 75 | * populates the user in the GraphQL context from the Koa context each request: 76 | * 77 | * ```js 78 | * import schema from "./schema.mjs"; 79 | * 80 | * const executeOptions = { 81 | * schema, 82 | * override: (ctx) => ({ 83 | * contextValue: { 84 | * user: ctx.state.user, 85 | * }, 86 | * }), 87 | * }; 88 | * ``` 89 | * @example 90 | * An {@linkcode execute} middleware options override that populates the user in 91 | * the GraphQL context from the Koa context: 92 | * 93 | * ```js 94 | * const executeOptionsOverride = (ctx) => ({ 95 | * contextValue: { 96 | * user: ctx.state.user, 97 | * }, 98 | * }); 99 | * ``` 100 | */ 101 | export default function execute(options) { 102 | if (typeof options === "undefined") 103 | throw createHttpError(500, "GraphQL execute middleware options missing."); 104 | 105 | checkOptions( 106 | options, 107 | ALLOWED_EXECUTE_OPTIONS_STATIC, 108 | "GraphQL execute middleware" 109 | ); 110 | 111 | if (typeof options.schema !== "undefined") 112 | checkGraphQLSchema( 113 | options.schema, 114 | "GraphQL execute middleware `schema` option" 115 | ); 116 | 117 | if (typeof options.validationRules !== "undefined") 118 | checkGraphQLValidationRules( 119 | options.validationRules, 120 | "GraphQL execute middleware `validationRules` option" 121 | ); 122 | 123 | if ( 124 | typeof options.execute !== "undefined" && 125 | typeof options.execute !== "function" 126 | ) 127 | throw createHttpError( 128 | 500, 129 | "GraphQL execute middleware `execute` option must be a function." 130 | ); 131 | 132 | const { override, ...staticOptions } = options; 133 | 134 | if (typeof override !== "undefined" && typeof override !== "function") 135 | throw createHttpError( 136 | 500, 137 | "GraphQL execute middleware `override` option must be a function." 138 | ); 139 | 140 | /** 141 | * Koa middleware to execute GraphQL. 142 | * @param {import("koa").ParameterizedContext< 143 | * KoaContextState, 144 | * KoaContext 145 | * >} ctx Koa context. The `ctx.request.body` property is conventionally added 146 | * by Koa body parser middleware such as 147 | * [`koa-bodyparser`](https://npm.im/koa-bodyparser). 148 | * @param {() => Promise} next 149 | */ 150 | async function executeMiddleware(ctx, next) { 151 | assertKoaContextRequestGraphQL(ctx); 152 | 153 | /** 154 | * Parsed GraphQL operation query. 155 | * @type {import("graphql").DocumentNode} 156 | */ 157 | let document; 158 | 159 | try { 160 | document = parse(new Source(ctx.request.body.query)); 161 | } catch (error) { 162 | throw new GraphQLAggregateError( 163 | [/** @type {import("graphql").GraphQLError} */ (error)], 164 | "GraphQL query syntax errors.", 165 | 400, 166 | true 167 | ); 168 | } 169 | 170 | let overrideOptions; 171 | 172 | if (override) { 173 | overrideOptions = await override(ctx); 174 | 175 | checkOptions( 176 | overrideOptions, 177 | ALLOWED_EXECUTE_OPTIONS_OVERRIDE, 178 | "GraphQL execute middleware `override` option resolved" 179 | ); 180 | 181 | if (typeof overrideOptions.schema !== "undefined") 182 | checkGraphQLSchema( 183 | overrideOptions.schema, 184 | "GraphQL execute middleware `override` option resolved `schema` option" 185 | ); 186 | 187 | if (typeof overrideOptions.validationRules !== "undefined") 188 | checkGraphQLValidationRules( 189 | overrideOptions.validationRules, 190 | "GraphQL execute middleware `override` option resolved `validationRules` option" 191 | ); 192 | 193 | if ( 194 | typeof overrideOptions.execute !== "undefined" && 195 | typeof overrideOptions.execute !== "function" 196 | ) 197 | throw createHttpError( 198 | 500, 199 | "GraphQL execute middleware `override` option resolved `execute` option must be a function." 200 | ); 201 | } 202 | 203 | const requestOptions = { 204 | validationRules: [], 205 | execute: graphqlExecute, 206 | ...staticOptions, 207 | ...overrideOptions, 208 | }; 209 | 210 | if (typeof requestOptions.schema === "undefined") 211 | throw createHttpError( 212 | 500, 213 | "GraphQL execute middleware requires a GraphQL schema." 214 | ); 215 | 216 | const queryValidationErrors = validate(requestOptions.schema, document, [ 217 | ...specifiedRules, 218 | ...requestOptions.validationRules, 219 | ]); 220 | 221 | if (queryValidationErrors.length) 222 | throw new GraphQLAggregateError( 223 | queryValidationErrors, 224 | "GraphQL query validation errors.", 225 | 400, 226 | true 227 | ); 228 | 229 | const { data, errors } = await requestOptions.execute({ 230 | schema: requestOptions.schema, 231 | rootValue: requestOptions.rootValue, 232 | contextValue: requestOptions.contextValue, 233 | fieldResolver: requestOptions.fieldResolver, 234 | document, 235 | variableValues: ctx.request.body.variables, 236 | operationName: ctx.request.body.operationName, 237 | }); 238 | 239 | if (data) 240 | ctx.response.body = 241 | /** @type {import("./errorHandler.mjs").GraphQLResponseBody} */ ({ 242 | data, 243 | }); 244 | 245 | ctx.response.status = 200; 246 | 247 | if (errors) { 248 | // By convention GraphQL execution errors shouldn’t result in an error 249 | // HTTP status code. 250 | throw new GraphQLAggregateError( 251 | errors, 252 | "GraphQL execution errors.", 253 | 200, 254 | true 255 | ); 256 | } 257 | 258 | // Set the content-type. 259 | ctx.response.type = "application/graphql+json"; 260 | 261 | await next(); 262 | } 263 | 264 | return executeMiddleware; 265 | } 266 | 267 | /** 268 | * {@linkcode execute} Koa middleware options. 269 | * @typedef {object} ExecuteOptions 270 | * @prop {import("graphql").GraphQLSchema} schema GraphQL schema. 271 | * @prop {ReadonlyArray} [validationRules] 272 | * Validation rules for GraphQL.js {@linkcode validate}, in addition to the 273 | * default GraphQL.js {@linkcode specifiedRules}. 274 | * @prop {any} [rootValue] Value passed to the first resolver. 275 | * @prop {any} [contextValue] Execution context (usually an object) passed to 276 | * resolvers. 277 | * @prop {import("graphql").GraphQLFieldResolver} [fieldResolver] 278 | * Custom default field resolver. 279 | * @prop {typeof graphqlExecute} [execute] Replacement for 280 | * [GraphQL.js `execute`](https://graphql.org/graphql-js/execution/#execute). 281 | */ 282 | -------------------------------------------------------------------------------- /execute.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { deepStrictEqual, ok, strictEqual, throws } from "node:assert"; 4 | import { createServer } from "node:http"; 5 | 6 | import { 7 | execute as graphqlExecute, 8 | GraphQLError, 9 | GraphQLNonNull, 10 | GraphQLObjectType, 11 | GraphQLSchema, 12 | GraphQLString, 13 | } from "graphql"; 14 | import Koa from "koa"; 15 | import bodyParser from "koa-bodyparser"; 16 | 17 | import errorHandler from "./errorHandler.mjs"; 18 | import execute from "./execute.mjs"; 19 | import GraphQLAggregateError from "./GraphQLAggregateError.mjs"; 20 | import fetchGraphQL from "./test/fetchGraphQL.mjs"; 21 | import listen from "./test/listen.mjs"; 22 | 23 | const schema = new GraphQLSchema({ 24 | query: new GraphQLObjectType({ 25 | name: "Query", 26 | fields: { 27 | test: { 28 | type: GraphQLString, 29 | }, 30 | }, 31 | }), 32 | }); 33 | 34 | const bodyParserMiddleware = bodyParser({ 35 | extendTypes: { 36 | json: "application/graphql+json", 37 | }, 38 | }); 39 | 40 | /** 41 | * Adds `execute` tests. 42 | * @param {import("test-director").default} tests Test director. 43 | */ 44 | export default (tests) => { 45 | tests.add("`execute` middleware options missing.", () => { 46 | throws( 47 | () => 48 | // @ts-expect-error Testing invalid. 49 | execute(), 50 | { 51 | name: "InternalServerError", 52 | message: "GraphQL execute middleware options missing.", 53 | status: 500, 54 | expose: false, 55 | } 56 | ); 57 | }); 58 | 59 | tests.add("`execute` middleware options not an object.", () => { 60 | throws( 61 | () => 62 | execute( 63 | // @ts-expect-error Testing invalid. 64 | true 65 | ), 66 | { 67 | name: "InternalServerError", 68 | message: 69 | "GraphQL execute middleware options must be an enumerable object.", 70 | status: 500, 71 | expose: false, 72 | } 73 | ); 74 | }); 75 | 76 | tests.add("`execute` middleware options invalid.", () => { 77 | throws( 78 | () => 79 | execute({ 80 | schema, 81 | // @ts-expect-error Testing invalid. 82 | invalid1: true, 83 | invalid2: true, 84 | }), 85 | { 86 | name: "InternalServerError", 87 | message: 88 | "GraphQL execute middleware options invalid: `invalid1`, `invalid2`.", 89 | status: 500, 90 | expose: false, 91 | } 92 | ); 93 | }); 94 | 95 | tests.add("`execute` middleware option `override` not a function.", () => { 96 | throws( 97 | () => 98 | execute({ 99 | schema, 100 | // @ts-expect-error Testing invalid. 101 | override: true, 102 | }), 103 | { 104 | name: "InternalServerError", 105 | message: 106 | "GraphQL execute middleware `override` option must be a function.", 107 | status: 500, 108 | expose: false, 109 | } 110 | ); 111 | }); 112 | 113 | tests.add( 114 | "`execute` middleware option `override` returning not an object.", 115 | async () => { 116 | /** @type {import("http-errors").HttpError | undefined} */ 117 | let koaError; 118 | 119 | const app = new Koa() 120 | .use(errorHandler()) 121 | .use(bodyParserMiddleware) 122 | .use( 123 | execute({ 124 | schema, 125 | // @ts-expect-error Testing invalid. 126 | override: () => true, 127 | }) 128 | ) 129 | .on("error", (error) => { 130 | koaError = error; 131 | }); 132 | 133 | const { port, close } = await listen(createServer(app.callback())); 134 | 135 | try { 136 | const response = await fetchGraphQL(port, { 137 | body: JSON.stringify({ query: "{ test }" }), 138 | }); 139 | 140 | ok(koaError instanceof Error); 141 | strictEqual(koaError.name, "InternalServerError"); 142 | strictEqual( 143 | koaError.message, 144 | "GraphQL execute middleware `override` option resolved options must be an enumerable object." 145 | ); 146 | strictEqual(koaError.status, 500); 147 | strictEqual(koaError.expose, false); 148 | strictEqual(response.status, 500); 149 | strictEqual( 150 | response.headers.get("Content-Type"), 151 | "application/graphql+json" 152 | ); 153 | deepStrictEqual(await response.json(), { 154 | errors: [{ message: "Internal Server Error" }], 155 | }); 156 | } finally { 157 | close(); 158 | } 159 | } 160 | ); 161 | 162 | tests.add( 163 | "`execute` middleware option `override` returning options invalid.", 164 | async () => { 165 | /** @type {import("http-errors").HttpError | undefined} */ 166 | let koaError; 167 | 168 | const app = new Koa() 169 | .use(errorHandler()) 170 | .use(bodyParserMiddleware) 171 | .use( 172 | execute({ 173 | schema, 174 | // @ts-expect-error Testing invalid. 175 | override: () => ({ 176 | invalid: true, 177 | override: true, 178 | }), 179 | }) 180 | ) 181 | .on("error", (error) => { 182 | koaError = error; 183 | }); 184 | 185 | const { port, close } = await listen(createServer(app.callback())); 186 | 187 | try { 188 | const response = await fetchGraphQL(port, { 189 | body: JSON.stringify({ query: "{ test }" }), 190 | }); 191 | 192 | ok(koaError instanceof Error); 193 | strictEqual(koaError.name, "InternalServerError"); 194 | strictEqual( 195 | koaError.message, 196 | "GraphQL execute middleware `override` option resolved options invalid: `invalid`, `override`." 197 | ); 198 | strictEqual(koaError.status, 500); 199 | strictEqual(koaError.expose, false); 200 | strictEqual(response.status, 500); 201 | strictEqual( 202 | response.headers.get("Content-Type"), 203 | "application/graphql+json" 204 | ); 205 | deepStrictEqual(await response.json(), { 206 | errors: [{ message: "Internal Server Error" }], 207 | }); 208 | } finally { 209 | close(); 210 | } 211 | } 212 | ); 213 | 214 | tests.add( 215 | "`execute` middleware option `schema` not a GraphQLSchema instance.", 216 | () => { 217 | throws( 218 | () => 219 | execute({ 220 | // @ts-expect-error Testing invalid. 221 | schema: true, 222 | }), 223 | { 224 | name: "InternalServerError", 225 | message: 226 | "GraphQL execute middleware `schema` option GraphQL schema must be a `GraphQLSchema` instance.", 227 | status: 500, 228 | expose: false, 229 | } 230 | ); 231 | } 232 | ); 233 | 234 | tests.add( 235 | "`execute` middleware option `schema` undefined, without an override.", 236 | async () => { 237 | /** @type {import("http-errors").HttpError | undefined} */ 238 | let koaError; 239 | 240 | const app = new Koa() 241 | .use(errorHandler()) 242 | .use(bodyParserMiddleware) 243 | .use( 244 | execute({ 245 | // @ts-expect-error Testing invalid. 246 | schema: undefined, 247 | }) 248 | ) 249 | .on("error", (error) => { 250 | koaError = error; 251 | }); 252 | 253 | const { port, close } = await listen(createServer(app.callback())); 254 | 255 | try { 256 | const response = await fetchGraphQL(port, { 257 | body: JSON.stringify({ query: "{ test }" }), 258 | }); 259 | 260 | ok(koaError instanceof Error); 261 | strictEqual(koaError.name, "InternalServerError"); 262 | strictEqual( 263 | koaError.message, 264 | "GraphQL execute middleware requires a GraphQL schema." 265 | ); 266 | strictEqual(koaError.status, 500); 267 | strictEqual(koaError.expose, false); 268 | strictEqual(response.status, 500); 269 | strictEqual( 270 | response.headers.get("Content-Type"), 271 | "application/graphql+json" 272 | ); 273 | deepStrictEqual(await response.json(), { 274 | errors: [{ message: "Internal Server Error" }], 275 | }); 276 | } finally { 277 | close(); 278 | } 279 | } 280 | ); 281 | 282 | tests.add( 283 | "`execute` middleware option `schema` not a GraphQLSchema instance override.", 284 | async () => { 285 | /** @type {import("http-errors").HttpError | undefined} */ 286 | let koaError; 287 | 288 | const app = new Koa() 289 | .use(errorHandler()) 290 | .use(bodyParserMiddleware) 291 | .use( 292 | execute({ 293 | schema, 294 | // @ts-expect-error Testing invalid. 295 | override: () => ({ 296 | schema: true, 297 | }), 298 | }) 299 | ) 300 | .on("error", (error) => { 301 | koaError = error; 302 | }); 303 | 304 | const { port, close } = await listen(createServer(app.callback())); 305 | 306 | try { 307 | const response = await fetchGraphQL(port, { 308 | body: JSON.stringify({ query: "{ test }" }), 309 | }); 310 | 311 | ok(koaError instanceof Error); 312 | strictEqual(koaError.name, "InternalServerError"); 313 | strictEqual( 314 | koaError.message, 315 | "GraphQL execute middleware `override` option resolved `schema` option GraphQL schema must be a `GraphQLSchema` instance." 316 | ); 317 | strictEqual(koaError.status, 500); 318 | strictEqual(koaError.expose, false); 319 | strictEqual(response.status, 500); 320 | strictEqual( 321 | response.headers.get("Content-Type"), 322 | "application/graphql+json" 323 | ); 324 | deepStrictEqual(await response.json(), { 325 | errors: [{ message: "Internal Server Error" }], 326 | }); 327 | } finally { 328 | close(); 329 | } 330 | } 331 | ); 332 | 333 | tests.add("`execute` middleware option `schema` invalid GraphQL.", () => { 334 | throws( 335 | () => execute({ schema: new GraphQLSchema({}) }), 336 | new GraphQLAggregateError( 337 | [new GraphQLError("Query root type must be provided.")], 338 | "GraphQL execute middleware `schema` option has GraphQL schema validation errors.", 339 | 500, 340 | false 341 | ) 342 | ); 343 | }); 344 | 345 | tests.add( 346 | "`execute` middleware option `schema` invalid GraphQL override.", 347 | async () => { 348 | /** @type {GraphQLAggregateError | undefined} */ 349 | let koaError; 350 | 351 | const app = new Koa() 352 | .use(errorHandler()) 353 | .use(bodyParserMiddleware) 354 | .use( 355 | execute({ 356 | schema, 357 | override: () => ({ 358 | schema: new GraphQLSchema({}), 359 | }), 360 | }) 361 | ) 362 | .on("error", (error) => { 363 | koaError = error; 364 | }); 365 | 366 | const { port, close } = await listen(createServer(app.callback())); 367 | 368 | try { 369 | const response = await fetchGraphQL(port, { 370 | body: JSON.stringify({ query: "{ test }" }), 371 | }); 372 | 373 | ok(koaError instanceof GraphQLAggregateError); 374 | strictEqual( 375 | koaError.message, 376 | "GraphQL execute middleware `override` option resolved `schema` option has GraphQL schema validation errors." 377 | ); 378 | strictEqual(koaError.status, 500); 379 | strictEqual(koaError.expose, false); 380 | strictEqual(koaError.errors.length, 1); 381 | ok(koaError.errors[0] instanceof GraphQLError); 382 | strictEqual( 383 | koaError.errors[0].message, 384 | "Query root type must be provided." 385 | ); 386 | strictEqual(response.status, 500); 387 | strictEqual( 388 | response.headers.get("Content-Type"), 389 | "application/graphql+json" 390 | ); 391 | deepStrictEqual(await response.json(), { 392 | errors: [{ message: "Internal Server Error" }], 393 | }); 394 | } finally { 395 | close(); 396 | } 397 | } 398 | ); 399 | 400 | tests.add("`execute` middleware option `validationRules`.", async () => { 401 | /** @type {GraphQLAggregateError | undefined} */ 402 | let koaError; 403 | 404 | const error1 = { 405 | message: "Message.", 406 | locations: [{ line: 1, column: 1 }], 407 | }; 408 | const error2 = { 409 | message: 'Cannot query field "wrong" on type "Query".', 410 | locations: [{ line: 1, column: 3 }], 411 | }; 412 | 413 | const app = new Koa() 414 | .use(errorHandler()) 415 | .use(bodyParserMiddleware) 416 | .use( 417 | execute({ 418 | schema, 419 | validationRules: [ 420 | (context) => ({ 421 | OperationDefinition(node) { 422 | context.reportError( 423 | new GraphQLError(error1.message, { nodes: node }) 424 | ); 425 | }, 426 | }), 427 | ], 428 | }) 429 | ) 430 | .on("error", (error) => { 431 | koaError = error; 432 | }); 433 | 434 | const { port, close } = await listen(createServer(app.callback())); 435 | 436 | try { 437 | const response = await fetchGraphQL(port, { 438 | body: JSON.stringify({ query: "{ wrong }" }), 439 | }); 440 | 441 | ok(koaError instanceof GraphQLAggregateError); 442 | strictEqual(koaError.message, "GraphQL query validation errors."); 443 | strictEqual(koaError.status, 400); 444 | strictEqual(koaError.expose, true); 445 | strictEqual(koaError.errors.length, 2); 446 | ok(koaError.errors[0] instanceof GraphQLError); 447 | strictEqual(koaError.errors[0].message, error1.message); 448 | deepStrictEqual(koaError.errors[0].locations, error1.locations); 449 | ok(koaError.errors[1] instanceof GraphQLError); 450 | strictEqual(koaError.errors[1].message, error2.message); 451 | deepStrictEqual(koaError.errors[1].locations, error2.locations); 452 | strictEqual(response.status, 400); 453 | strictEqual( 454 | response.headers.get("Content-Type"), 455 | "application/graphql+json" 456 | ); 457 | deepStrictEqual(await response.json(), { 458 | errors: [error1, error2], 459 | }); 460 | } finally { 461 | close(); 462 | } 463 | }); 464 | 465 | tests.add( 466 | "`execute` middleware option `validationRules` override.", 467 | async () => { 468 | /** @type {GraphQLAggregateError | undefined} */ 469 | let koaError; 470 | 471 | const error1 = { 472 | message: "Message overridden.", 473 | locations: [{ line: 1, column: 1 }], 474 | }; 475 | const error2 = { 476 | message: 'Cannot query field "wrong" on type "Query".', 477 | locations: [{ line: 1, column: 3 }], 478 | }; 479 | 480 | const app = new Koa() 481 | .use(errorHandler()) 482 | .use(bodyParserMiddleware) 483 | .use( 484 | execute({ 485 | schema, 486 | validationRules: [ 487 | (context) => ({ 488 | OperationDefinition(node) { 489 | context.reportError( 490 | new GraphQLError("Message.", { nodes: node }) 491 | ); 492 | }, 493 | }), 494 | ], 495 | override: () => ({ 496 | validationRules: [ 497 | (context) => ({ 498 | OperationDefinition(node) { 499 | context.reportError( 500 | new GraphQLError("Message overridden.", { nodes: node }) 501 | ); 502 | }, 503 | }), 504 | ], 505 | }), 506 | }) 507 | ) 508 | .on("error", (error) => { 509 | koaError = error; 510 | }); 511 | 512 | const { port, close } = await listen(createServer(app.callback())); 513 | 514 | try { 515 | const response = await fetchGraphQL(port, { 516 | body: JSON.stringify({ query: "{ wrong }" }), 517 | }); 518 | 519 | ok(koaError instanceof GraphQLAggregateError); 520 | strictEqual(koaError.message, "GraphQL query validation errors."); 521 | strictEqual(koaError.status, 400); 522 | strictEqual(koaError.expose, true); 523 | strictEqual(koaError.errors.length, 2); 524 | ok(koaError.errors[0] instanceof GraphQLError); 525 | strictEqual(koaError.errors[0].message, error1.message); 526 | deepStrictEqual(koaError.errors[0].locations, error1.locations); 527 | ok(koaError.errors[1] instanceof GraphQLError); 528 | strictEqual(koaError.errors[1].message, error2.message); 529 | deepStrictEqual(koaError.errors[1].locations, error2.locations); 530 | strictEqual(response.status, 400); 531 | strictEqual( 532 | response.headers.get("Content-Type"), 533 | "application/graphql+json" 534 | ); 535 | deepStrictEqual(await response.json(), { 536 | errors: [error1, error2], 537 | }); 538 | } finally { 539 | close(); 540 | } 541 | } 542 | ); 543 | 544 | tests.add("`execute` middleware option `rootValue`.", async () => { 545 | const app = new Koa() 546 | .use(errorHandler()) 547 | .use(bodyParserMiddleware) 548 | .use( 549 | execute({ 550 | schema: new GraphQLSchema({ 551 | query: new GraphQLObjectType({ 552 | name: "Query", 553 | fields: { 554 | test: { 555 | type: GraphQLString, 556 | resolve: (value) => value, 557 | }, 558 | }, 559 | }), 560 | }), 561 | rootValue: "rootValue", 562 | }) 563 | ); 564 | 565 | const { port, close } = await listen(createServer(app.callback())); 566 | 567 | try { 568 | const response = await fetchGraphQL(port, { 569 | body: JSON.stringify({ query: "{ test }" }), 570 | }); 571 | 572 | strictEqual(response.status, 200); 573 | strictEqual( 574 | response.headers.get("Content-Type"), 575 | "application/graphql+json" 576 | ); 577 | deepStrictEqual(await response.json(), { data: { test: "rootValue" } }); 578 | } finally { 579 | close(); 580 | } 581 | }); 582 | 583 | tests.add( 584 | "`execute` middleware option `rootValue` override using Koa ctx.", 585 | async () => { 586 | /** @typedef {{ test?: string }} KoaContextStateTest */ 587 | 588 | /** @type {Koa} */ 589 | const app = new Koa(); 590 | 591 | app 592 | .use(errorHandler()) 593 | .use(bodyParserMiddleware) 594 | .use(async (ctx, next) => { 595 | ctx.state.test = "rootValueOverridden"; 596 | await next(); 597 | }) 598 | .use( 599 | /** @type {typeof execute} */ (execute)({ 600 | schema: new GraphQLSchema({ 601 | query: new GraphQLObjectType({ 602 | name: "Query", 603 | fields: { 604 | test: { 605 | type: GraphQLString, 606 | resolve: (value) => value, 607 | }, 608 | }, 609 | }), 610 | }), 611 | rootValue: "rootValue", 612 | override: (ctx) => ({ 613 | rootValue: ctx.state.test, 614 | }), 615 | }) 616 | ); 617 | 618 | const { port, close } = await listen(createServer(app.callback())); 619 | 620 | try { 621 | const response = await fetchGraphQL(port, { 622 | body: JSON.stringify({ query: "{ test }" }), 623 | }); 624 | 625 | strictEqual(response.status, 200); 626 | strictEqual( 627 | response.headers.get("Content-Type"), 628 | "application/graphql+json" 629 | ); 630 | deepStrictEqual(await response.json(), { 631 | data: { test: "rootValueOverridden" }, 632 | }); 633 | } finally { 634 | close(); 635 | } 636 | } 637 | ); 638 | 639 | tests.add("`execute` middleware option `contextValue`.", async () => { 640 | const app = new Koa() 641 | .use(errorHandler()) 642 | .use(bodyParserMiddleware) 643 | .use( 644 | execute({ 645 | schema: new GraphQLSchema({ 646 | query: new GraphQLObjectType({ 647 | name: "Query", 648 | fields: { 649 | test: { 650 | type: GraphQLString, 651 | resolve: (value, args, context) => context, 652 | }, 653 | }, 654 | }), 655 | }), 656 | contextValue: "contextValue", 657 | }) 658 | ); 659 | 660 | const { port, close } = await listen(createServer(app.callback())); 661 | 662 | try { 663 | const response = await fetchGraphQL(port, { 664 | body: JSON.stringify({ query: "{ test }" }), 665 | }); 666 | 667 | strictEqual(response.status, 200); 668 | strictEqual( 669 | response.headers.get("Content-Type"), 670 | "application/graphql+json" 671 | ); 672 | deepStrictEqual(await response.json(), { 673 | data: { test: "contextValue" }, 674 | }); 675 | } finally { 676 | close(); 677 | } 678 | }); 679 | 680 | tests.add( 681 | "`execute` middleware option `contextValue` override using Koa ctx.", 682 | async () => { 683 | const app = new Koa() 684 | .use(errorHandler()) 685 | .use(bodyParserMiddleware) 686 | .use(async (ctx, next) => { 687 | ctx.state.test = "contextValueOverridden"; 688 | await next(); 689 | }) 690 | .use( 691 | execute({ 692 | schema: new GraphQLSchema({ 693 | query: new GraphQLObjectType({ 694 | name: "Query", 695 | fields: { 696 | test: { 697 | type: GraphQLString, 698 | resolve: (value, args, context) => context, 699 | }, 700 | }, 701 | }), 702 | }), 703 | contextValue: "contextValue", 704 | override: (ctx) => ({ 705 | contextValue: ctx.state.test, 706 | }), 707 | }) 708 | ); 709 | 710 | const { port, close } = await listen(createServer(app.callback())); 711 | 712 | try { 713 | const response = await fetchGraphQL(port, { 714 | body: JSON.stringify({ query: "{ test }" }), 715 | }); 716 | 717 | strictEqual(response.status, 200); 718 | strictEqual( 719 | response.headers.get("Content-Type"), 720 | "application/graphql+json" 721 | ); 722 | deepStrictEqual(await response.json(), { 723 | data: { test: "contextValueOverridden" }, 724 | }); 725 | } finally { 726 | close(); 727 | } 728 | } 729 | ); 730 | 731 | tests.add( 732 | "`execute` middleware option `contextValue` override using Koa ctx async.", 733 | async () => { 734 | /** @typedef {{ test?: string }} KoaContextStateTest */ 735 | 736 | /** @type {Koa} */ 737 | const app = new Koa(); 738 | 739 | app 740 | .use(errorHandler()) 741 | .use(bodyParserMiddleware) 742 | .use(async (ctx, next) => { 743 | ctx.state.test = "contextValueOverridden"; 744 | await next(); 745 | }) 746 | .use( 747 | /** @type {typeof execute} */ (execute)({ 748 | schema: new GraphQLSchema({ 749 | query: new GraphQLObjectType({ 750 | name: "Query", 751 | fields: { 752 | test: { 753 | type: GraphQLString, 754 | resolve: (value, args, context) => context, 755 | }, 756 | }, 757 | }), 758 | }), 759 | contextValue: "contextValue", 760 | override: async (ctx) => ({ 761 | contextValue: ctx.state.test, 762 | }), 763 | }) 764 | ); 765 | 766 | const { port, close } = await listen(createServer(app.callback())); 767 | 768 | try { 769 | const response = await fetchGraphQL(port, { 770 | body: JSON.stringify({ query: "{ test }" }), 771 | }); 772 | 773 | strictEqual(response.status, 200); 774 | strictEqual( 775 | response.headers.get("Content-Type"), 776 | "application/graphql+json" 777 | ); 778 | deepStrictEqual(await response.json(), { 779 | data: { test: "contextValueOverridden" }, 780 | }); 781 | } finally { 782 | close(); 783 | } 784 | } 785 | ); 786 | 787 | tests.add("`execute` middleware option `fieldResolver`.", async () => { 788 | const app = new Koa() 789 | .use(errorHandler()) 790 | .use(bodyParserMiddleware) 791 | .use( 792 | execute({ 793 | schema, 794 | fieldResolver: () => "fieldResolver", 795 | }) 796 | ); 797 | 798 | const { port, close } = await listen(createServer(app.callback())); 799 | 800 | try { 801 | const response = await fetchGraphQL(port, { 802 | body: JSON.stringify({ query: "{ test }" }), 803 | }); 804 | 805 | strictEqual(response.status, 200); 806 | strictEqual( 807 | response.headers.get("Content-Type"), 808 | "application/graphql+json" 809 | ); 810 | deepStrictEqual(await response.json(), { 811 | data: { test: "fieldResolver" }, 812 | }); 813 | } finally { 814 | close(); 815 | } 816 | }); 817 | 818 | tests.add( 819 | "`execute` middleware option `fieldResolver` override using Koa ctx.", 820 | async () => { 821 | const app = new Koa() 822 | .use(errorHandler()) 823 | .use(bodyParserMiddleware) 824 | .use(async (ctx, next) => { 825 | ctx.state.test = "fieldResolverOverridden"; 826 | await next(); 827 | }) 828 | .use( 829 | execute({ 830 | schema, 831 | fieldResolver: () => "fieldResolver", 832 | override: (ctx) => ({ 833 | fieldResolver: () => ctx.state.test, 834 | }), 835 | }) 836 | ); 837 | 838 | const { port, close } = await listen(createServer(app.callback())); 839 | 840 | try { 841 | const response = await fetchGraphQL(port, { 842 | body: JSON.stringify({ query: "{ test }" }), 843 | }); 844 | 845 | strictEqual(response.status, 200); 846 | strictEqual( 847 | response.headers.get("Content-Type"), 848 | "application/graphql+json" 849 | ); 850 | deepStrictEqual(await response.json(), { 851 | data: { test: "fieldResolverOverridden" }, 852 | }); 853 | } finally { 854 | close(); 855 | } 856 | } 857 | ); 858 | 859 | tests.add("`execute` middleware option `execute`.", async () => { 860 | let executeRan; 861 | 862 | const app = new Koa() 863 | .use(errorHandler()) 864 | .use(bodyParserMiddleware) 865 | .use( 866 | execute({ 867 | schema, 868 | execute(...args) { 869 | executeRan = true; 870 | return graphqlExecute(...args); 871 | }, 872 | }) 873 | ); 874 | 875 | const { port, close } = await listen(createServer(app.callback())); 876 | 877 | try { 878 | const response = await fetchGraphQL(port, { 879 | body: JSON.stringify({ query: "{ test }" }), 880 | }); 881 | 882 | ok(executeRan); 883 | strictEqual(response.status, 200); 884 | strictEqual( 885 | response.headers.get("Content-Type"), 886 | "application/graphql+json" 887 | ); 888 | deepStrictEqual(await response.json(), { data: { test: null } }); 889 | } finally { 890 | close(); 891 | } 892 | }); 893 | 894 | tests.add("`execute` middleware option `execute` not a function.", () => { 895 | throws( 896 | () => 897 | execute({ 898 | schema, 899 | // @ts-expect-error Testing invalid. 900 | execute: true, 901 | }), 902 | { 903 | name: "InternalServerError", 904 | message: 905 | "GraphQL execute middleware `execute` option must be a function.", 906 | status: 500, 907 | expose: false, 908 | } 909 | ); 910 | }); 911 | 912 | tests.add("`execute` middleware option `execute` override.", async () => { 913 | let executeRan; 914 | 915 | const app = new Koa() 916 | .use(errorHandler()) 917 | .use(bodyParserMiddleware) 918 | .use( 919 | execute({ 920 | schema, 921 | override: () => ({ 922 | execute(...args) { 923 | executeRan = true; 924 | return graphqlExecute(...args); 925 | }, 926 | }), 927 | }) 928 | ); 929 | 930 | const { port, close } = await listen(createServer(app.callback())); 931 | 932 | try { 933 | const response = await fetchGraphQL(port, { 934 | body: JSON.stringify({ query: "{ test }" }), 935 | }); 936 | 937 | ok(executeRan); 938 | strictEqual(response.status, 200); 939 | strictEqual( 940 | response.headers.get("Content-Type"), 941 | "application/graphql+json" 942 | ); 943 | deepStrictEqual(await response.json(), { data: { test: null } }); 944 | } finally { 945 | close(); 946 | } 947 | }); 948 | 949 | tests.add( 950 | "`execute` middleware option `execute` override not a function.", 951 | async () => { 952 | /** @type {import("http-errors").HttpError | undefined} */ 953 | let koaError; 954 | 955 | const app = new Koa() 956 | .use(errorHandler()) 957 | .use(bodyParserMiddleware) 958 | .use( 959 | execute({ 960 | schema, 961 | // @ts-expect-error Testing invalid. 962 | override: () => ({ 963 | execute: true, 964 | }), 965 | }) 966 | ) 967 | .on("error", (error) => { 968 | koaError = error; 969 | }); 970 | 971 | const { port, close } = await listen(createServer(app.callback())); 972 | 973 | try { 974 | const response = await fetchGraphQL(port, { 975 | body: JSON.stringify({ query: "{ test }" }), 976 | }); 977 | 978 | ok(koaError instanceof Error); 979 | strictEqual(koaError.name, "InternalServerError"); 980 | strictEqual( 981 | koaError.message, 982 | "GraphQL execute middleware `override` option resolved `execute` option must be a function." 983 | ); 984 | strictEqual(koaError.status, 500); 985 | strictEqual(koaError.expose, false); 986 | strictEqual(response.status, 500); 987 | strictEqual( 988 | response.headers.get("Content-Type"), 989 | "application/graphql+json" 990 | ); 991 | deepStrictEqual(await response.json(), { 992 | errors: [{ message: "Internal Server Error" }], 993 | }); 994 | } finally { 995 | close(); 996 | } 997 | } 998 | ); 999 | 1000 | tests.add( 1001 | "`execute` middleware with request body missing due to absent body parser middleware.", 1002 | async () => { 1003 | /** @type {import("http-errors").HttpError | undefined} */ 1004 | let koaError; 1005 | 1006 | const app = new Koa() 1007 | .use(errorHandler()) 1008 | .use(execute({ schema })) 1009 | .on("error", (error) => { 1010 | koaError = error; 1011 | }); 1012 | 1013 | const { port, close } = await listen(createServer(app.callback())); 1014 | 1015 | try { 1016 | const response = await fetchGraphQL(port); 1017 | 1018 | ok(koaError instanceof Error); 1019 | strictEqual(koaError.name, "InternalServerError"); 1020 | strictEqual(koaError.message, "Request body missing."); 1021 | strictEqual(koaError.status, 500); 1022 | strictEqual(koaError.expose, false); 1023 | strictEqual(response.status, 500); 1024 | strictEqual( 1025 | response.headers.get("Content-Type"), 1026 | "application/graphql+json" 1027 | ); 1028 | deepStrictEqual(await response.json(), { 1029 | errors: [{ message: "Internal Server Error" }], 1030 | }); 1031 | } finally { 1032 | close(); 1033 | } 1034 | } 1035 | ); 1036 | 1037 | tests.add("`execute` middleware with request body invalid.", async () => { 1038 | /** @type {import("http-errors").HttpError | undefined} */ 1039 | let koaError; 1040 | 1041 | const errorMessage = "Request body must be a JSON object."; 1042 | 1043 | const app = new Koa() 1044 | .use(errorHandler()) 1045 | .use(bodyParserMiddleware) 1046 | .use(execute({ schema })) 1047 | .on("error", (error) => { 1048 | koaError = error; 1049 | }); 1050 | 1051 | const { port, close } = await listen(createServer(app.callback())); 1052 | 1053 | try { 1054 | const response = await fetchGraphQL(port, { body: "[]" }); 1055 | 1056 | ok(koaError instanceof Error); 1057 | strictEqual(koaError.name, "BadRequestError"); 1058 | strictEqual(koaError.message, errorMessage); 1059 | strictEqual(koaError.status, 400); 1060 | strictEqual(koaError.expose, true); 1061 | strictEqual(response.status, 400); 1062 | strictEqual( 1063 | response.headers.get("Content-Type"), 1064 | "application/graphql+json" 1065 | ); 1066 | deepStrictEqual(await response.json(), { 1067 | errors: [{ message: errorMessage }], 1068 | }); 1069 | } finally { 1070 | close(); 1071 | } 1072 | }); 1073 | 1074 | tests.add( 1075 | "`execute` middleware with operation field `query` missing.", 1076 | async () => { 1077 | /** @type {import("http-errors").HttpError | undefined} */ 1078 | let koaError; 1079 | 1080 | const errorMessage = "GraphQL operation field `query` missing."; 1081 | 1082 | const app = new Koa() 1083 | .use(errorHandler()) 1084 | .use(bodyParserMiddleware) 1085 | .use(execute({ schema })) 1086 | .on("error", (error) => { 1087 | koaError = error; 1088 | }); 1089 | 1090 | const { port, close } = await listen(createServer(app.callback())); 1091 | 1092 | try { 1093 | const response = await fetchGraphQL(port, { body: "{}" }); 1094 | 1095 | ok(koaError instanceof Error); 1096 | strictEqual(koaError.name, "BadRequestError"); 1097 | strictEqual(koaError.message, errorMessage); 1098 | strictEqual(koaError.status, 400); 1099 | strictEqual(koaError.expose, true); 1100 | strictEqual(response.status, 400); 1101 | strictEqual( 1102 | response.headers.get("Content-Type"), 1103 | "application/graphql+json" 1104 | ); 1105 | deepStrictEqual(await response.json(), { 1106 | errors: [{ message: errorMessage }], 1107 | }); 1108 | } finally { 1109 | close(); 1110 | } 1111 | } 1112 | ); 1113 | 1114 | tests.add( 1115 | "`execute` middleware with operation field `query` not a string.", 1116 | async () => { 1117 | /** @type {import("http-errors").HttpError | undefined} */ 1118 | let koaError; 1119 | 1120 | const errorMessage = "GraphQL operation field `query` must be a string."; 1121 | 1122 | const app = new Koa() 1123 | .use(errorHandler()) 1124 | .use(bodyParserMiddleware) 1125 | .use(execute({ schema })) 1126 | .on("error", (error) => { 1127 | koaError = error; 1128 | }); 1129 | 1130 | const { port, close } = await listen(createServer(app.callback())); 1131 | 1132 | try { 1133 | const response = await fetchGraphQL(port, { 1134 | body: JSON.stringify({ query: null }), 1135 | }); 1136 | 1137 | ok(koaError instanceof Error); 1138 | strictEqual(koaError.name, "BadRequestError"); 1139 | strictEqual(koaError.message, errorMessage); 1140 | strictEqual(koaError.status, 400); 1141 | strictEqual(koaError.expose, true); 1142 | strictEqual(response.status, 400); 1143 | strictEqual( 1144 | response.headers.get("Content-Type"), 1145 | "application/graphql+json" 1146 | ); 1147 | deepStrictEqual(await response.json(), { 1148 | errors: [{ message: errorMessage }], 1149 | }); 1150 | } finally { 1151 | close(); 1152 | } 1153 | } 1154 | ); 1155 | 1156 | tests.add( 1157 | "`execute` middleware with operation field `query` syntax errors.", 1158 | async () => { 1159 | /** @type {GraphQLAggregateError | undefined} */ 1160 | let koaError; 1161 | 1162 | const error = { 1163 | message: 'Syntax Error: Expected Name, found "{".', 1164 | locations: [{ line: 1, column: 2 }], 1165 | }; 1166 | 1167 | const app = new Koa() 1168 | .use(errorHandler()) 1169 | .use(bodyParserMiddleware) 1170 | .use(execute({ schema })) 1171 | .on("error", (error) => { 1172 | koaError = error; 1173 | }); 1174 | 1175 | const { port, close } = await listen(createServer(app.callback())); 1176 | 1177 | try { 1178 | const response = await fetchGraphQL(port, { 1179 | body: JSON.stringify({ query: "{{ test }" }), 1180 | }); 1181 | 1182 | ok(koaError instanceof GraphQLAggregateError); 1183 | strictEqual(koaError.message, "GraphQL query syntax errors."); 1184 | strictEqual(koaError.status, 400); 1185 | strictEqual(koaError.expose, true); 1186 | strictEqual(koaError.errors.length, 1); 1187 | ok(koaError.errors[0] instanceof GraphQLError); 1188 | strictEqual(koaError.errors[0].message, error.message); 1189 | deepStrictEqual(koaError.errors[0].locations, error.locations); 1190 | strictEqual(response.status, 400); 1191 | strictEqual( 1192 | response.headers.get("Content-Type"), 1193 | "application/graphql+json" 1194 | ); 1195 | deepStrictEqual(await response.json(), { 1196 | errors: [error], 1197 | }); 1198 | } finally { 1199 | close(); 1200 | } 1201 | } 1202 | ); 1203 | 1204 | tests.add( 1205 | "`execute` middleware with operation field `query` validation type errors.", 1206 | async () => { 1207 | /** @type {GraphQLAggregateError | undefined} */ 1208 | let koaError; 1209 | 1210 | const error1 = { 1211 | message: 'Cannot query field "wrongOne" on type "Query".', 1212 | locations: [{ line: 1, column: 9 }], 1213 | }; 1214 | const error2 = { 1215 | message: 'Cannot query field "wrongTwo" on type "Query".', 1216 | locations: [{ line: 1, column: 19 }], 1217 | }; 1218 | 1219 | const app = new Koa() 1220 | .use(errorHandler()) 1221 | .use(bodyParserMiddleware) 1222 | .use(execute({ schema })) 1223 | .on("error", (error) => { 1224 | koaError = error; 1225 | }); 1226 | 1227 | const { port, close } = await listen(createServer(app.callback())); 1228 | 1229 | try { 1230 | const response = await fetchGraphQL(port, { 1231 | body: JSON.stringify({ query: "{ test, wrongOne, wrongTwo }" }), 1232 | }); 1233 | 1234 | ok(koaError instanceof GraphQLAggregateError); 1235 | strictEqual(koaError.message, "GraphQL query validation errors."); 1236 | strictEqual(koaError.status, 400); 1237 | strictEqual(koaError.expose, true); 1238 | strictEqual(koaError.errors.length, 2); 1239 | ok(koaError.errors[0] instanceof GraphQLError); 1240 | strictEqual(koaError.errors[0].message, error1.message); 1241 | deepStrictEqual(koaError.errors[0].locations, error1.locations); 1242 | ok(koaError.errors[1] instanceof GraphQLError); 1243 | strictEqual(koaError.errors[1].message, error2.message); 1244 | deepStrictEqual(koaError.errors[1].locations, error2.locations); 1245 | strictEqual(response.status, 400); 1246 | strictEqual( 1247 | response.headers.get("Content-Type"), 1248 | "application/graphql+json" 1249 | ); 1250 | deepStrictEqual(await response.json(), { 1251 | errors: [error1, error2], 1252 | }); 1253 | } finally { 1254 | close(); 1255 | } 1256 | } 1257 | ); 1258 | 1259 | tests.add( 1260 | "`execute` middleware with operation field `variables` invalid, boolean.", 1261 | async () => { 1262 | /** @type {import("http-errors").HttpError | undefined} */ 1263 | let koaError; 1264 | 1265 | const errorMessage = 1266 | "Request body JSON `variables` field must be an object."; 1267 | 1268 | const app = new Koa() 1269 | .use(errorHandler()) 1270 | .use(bodyParserMiddleware) 1271 | .use(execute({ schema })) 1272 | .on("error", (error) => { 1273 | koaError = error; 1274 | }); 1275 | 1276 | const { port, close } = await listen(createServer(app.callback())); 1277 | 1278 | try { 1279 | const response = await fetchGraphQL(port, { 1280 | body: JSON.stringify({ 1281 | query: "{ test }", 1282 | variables: true, 1283 | }), 1284 | }); 1285 | 1286 | ok(koaError instanceof Error); 1287 | strictEqual(koaError.name, "BadRequestError"); 1288 | strictEqual(koaError.message, errorMessage); 1289 | strictEqual(koaError.status, 400); 1290 | strictEqual(koaError.expose, true); 1291 | strictEqual(response.status, 400); 1292 | strictEqual( 1293 | response.headers.get("Content-Type"), 1294 | "application/graphql+json" 1295 | ); 1296 | deepStrictEqual(await response.json(), { 1297 | errors: [{ message: errorMessage }], 1298 | }); 1299 | } finally { 1300 | close(); 1301 | } 1302 | } 1303 | ); 1304 | 1305 | tests.add( 1306 | "`execute` middleware with operation field `variables` invalid, array.", 1307 | async () => { 1308 | /** @type {import("http-errors").HttpError | undefined} */ 1309 | let koaError; 1310 | 1311 | const errorMessage = 1312 | "Request body JSON `variables` field must be an object."; 1313 | 1314 | const app = new Koa() 1315 | .use(errorHandler()) 1316 | .use(bodyParserMiddleware) 1317 | .use(execute({ schema })) 1318 | .on("error", (error) => { 1319 | koaError = error; 1320 | }); 1321 | 1322 | const { port, close } = await listen(createServer(app.callback())); 1323 | 1324 | try { 1325 | const response = await fetchGraphQL(port, { 1326 | body: JSON.stringify({ 1327 | query: "{ test }", 1328 | variables: [], 1329 | }), 1330 | }); 1331 | 1332 | ok(koaError instanceof Error); 1333 | strictEqual(koaError.name, "BadRequestError"); 1334 | strictEqual(koaError.message, errorMessage); 1335 | strictEqual(koaError.status, 400); 1336 | strictEqual(koaError.expose, true); 1337 | strictEqual(response.status, 400); 1338 | strictEqual( 1339 | response.headers.get("Content-Type"), 1340 | "application/graphql+json" 1341 | ); 1342 | deepStrictEqual(await response.json(), { 1343 | errors: [{ message: errorMessage }], 1344 | }); 1345 | } finally { 1346 | close(); 1347 | } 1348 | } 1349 | ); 1350 | 1351 | tests.add( 1352 | "`execute` middleware with operation field `variables` valid, undefined.", 1353 | async () => { 1354 | const app = new Koa() 1355 | .use(errorHandler()) 1356 | .use(bodyParserMiddleware) 1357 | .use(execute({ schema })); 1358 | 1359 | const { port, close } = await listen(createServer(app.callback())); 1360 | 1361 | try { 1362 | const response = await fetchGraphQL(port, { 1363 | body: JSON.stringify({ 1364 | query: "{ test }", 1365 | variables: undefined, 1366 | }), 1367 | }); 1368 | 1369 | strictEqual(response.status, 200); 1370 | strictEqual( 1371 | response.headers.get("Content-Type"), 1372 | "application/graphql+json" 1373 | ); 1374 | deepStrictEqual(await response.json(), { data: { test: null } }); 1375 | } finally { 1376 | close(); 1377 | } 1378 | } 1379 | ); 1380 | 1381 | tests.add( 1382 | "`execute` middleware with operation field `variables` valid, null.", 1383 | async () => { 1384 | const app = new Koa() 1385 | .use(errorHandler()) 1386 | .use(bodyParserMiddleware) 1387 | .use(execute({ schema })); 1388 | 1389 | const { port, close } = await listen(createServer(app.callback())); 1390 | 1391 | try { 1392 | const response = await fetchGraphQL(port, { 1393 | body: JSON.stringify({ 1394 | query: "{ test }", 1395 | variables: null, 1396 | }), 1397 | }); 1398 | 1399 | strictEqual(response.status, 200); 1400 | strictEqual( 1401 | response.headers.get("Content-Type"), 1402 | "application/graphql+json" 1403 | ); 1404 | deepStrictEqual(await response.json(), { data: { test: null } }); 1405 | } finally { 1406 | close(); 1407 | } 1408 | } 1409 | ); 1410 | 1411 | tests.add( 1412 | "`execute` middleware with operation field `variables` valid, object.", 1413 | async () => { 1414 | const app = new Koa() 1415 | .use(errorHandler()) 1416 | .use(bodyParserMiddleware) 1417 | .use( 1418 | execute({ 1419 | schema: new GraphQLSchema({ 1420 | query: new GraphQLObjectType({ 1421 | name: "Query", 1422 | fields: { 1423 | test: { 1424 | type: GraphQLString, 1425 | args: { 1426 | text: { 1427 | type: new GraphQLNonNull(GraphQLString), 1428 | }, 1429 | }, 1430 | resolve: (value, { text }) => text, 1431 | }, 1432 | }, 1433 | }), 1434 | }), 1435 | }) 1436 | ); 1437 | 1438 | const { port, close } = await listen(createServer(app.callback())); 1439 | 1440 | try { 1441 | const text = "abc"; 1442 | const response = await fetchGraphQL(port, { 1443 | body: JSON.stringify({ 1444 | query: "query ($text: String!) { test(text: $text) }", 1445 | variables: { text }, 1446 | }), 1447 | }); 1448 | 1449 | strictEqual(response.status, 200); 1450 | strictEqual( 1451 | response.headers.get("Content-Type"), 1452 | "application/graphql+json" 1453 | ); 1454 | deepStrictEqual(await response.json(), { data: { test: text } }); 1455 | } finally { 1456 | close(); 1457 | } 1458 | } 1459 | ); 1460 | 1461 | tests.add( 1462 | "`execute` middleware with operation field `operationName` invalid, boolean.", 1463 | async () => { 1464 | /** @type {import("http-errors").HttpError | undefined} */ 1465 | let koaError; 1466 | 1467 | const errorMessage = 1468 | "Request body JSON `operationName` field must be a string."; 1469 | 1470 | const app = new Koa() 1471 | .use(errorHandler()) 1472 | .use(bodyParserMiddleware) 1473 | .use(execute({ schema })) 1474 | .on("error", (error) => { 1475 | koaError = error; 1476 | }); 1477 | 1478 | const { port, close } = await listen(createServer(app.callback())); 1479 | 1480 | try { 1481 | const response = await fetchGraphQL(port, { 1482 | body: JSON.stringify({ 1483 | query: "{ test }", 1484 | operationName: true, 1485 | }), 1486 | }); 1487 | 1488 | ok(koaError instanceof Error); 1489 | strictEqual(koaError.name, "BadRequestError"); 1490 | strictEqual(koaError.message, errorMessage); 1491 | strictEqual(koaError.status, 400); 1492 | strictEqual(koaError.expose, true); 1493 | strictEqual(response.status, 400); 1494 | strictEqual( 1495 | response.headers.get("Content-Type"), 1496 | "application/graphql+json" 1497 | ); 1498 | deepStrictEqual(await response.json(), { 1499 | errors: [{ message: errorMessage }], 1500 | }); 1501 | } finally { 1502 | close(); 1503 | } 1504 | } 1505 | ); 1506 | 1507 | tests.add( 1508 | "`execute` middleware with operation field `operationName` valid, undefined.", 1509 | async () => { 1510 | const app = new Koa() 1511 | .use(errorHandler()) 1512 | .use(bodyParserMiddleware) 1513 | .use(execute({ schema })); 1514 | 1515 | const { port, close } = await listen(createServer(app.callback())); 1516 | 1517 | try { 1518 | const response = await fetchGraphQL(port, { 1519 | body: JSON.stringify({ 1520 | query: "{ test }", 1521 | operationName: undefined, 1522 | }), 1523 | }); 1524 | 1525 | strictEqual(response.status, 200); 1526 | strictEqual( 1527 | response.headers.get("Content-Type"), 1528 | "application/graphql+json" 1529 | ); 1530 | deepStrictEqual(await response.json(), { data: { test: null } }); 1531 | } finally { 1532 | close(); 1533 | } 1534 | } 1535 | ); 1536 | 1537 | tests.add( 1538 | "`execute` middleware with operation field `operationName` valid, null.", 1539 | async () => { 1540 | const app = new Koa() 1541 | .use(errorHandler()) 1542 | .use(bodyParserMiddleware) 1543 | .use(execute({ schema })); 1544 | 1545 | const { port, close } = await listen(createServer(app.callback())); 1546 | 1547 | try { 1548 | const response = await fetchGraphQL(port, { 1549 | body: JSON.stringify({ 1550 | query: "{ test }", 1551 | operationName: null, 1552 | }), 1553 | }); 1554 | 1555 | strictEqual(response.status, 200); 1556 | strictEqual( 1557 | response.headers.get("Content-Type"), 1558 | "application/graphql+json" 1559 | ); 1560 | deepStrictEqual(await response.json(), { data: { test: null } }); 1561 | } finally { 1562 | close(); 1563 | } 1564 | } 1565 | ); 1566 | 1567 | tests.add( 1568 | "`execute` middleware with operation field `operationName` valid, string.", 1569 | async () => { 1570 | const app = new Koa() 1571 | .use(errorHandler()) 1572 | .use(bodyParserMiddleware) 1573 | .use(execute({ schema })); 1574 | 1575 | const { port, close } = await listen(createServer(app.callback())); 1576 | 1577 | try { 1578 | const response = await fetchGraphQL(port, { 1579 | body: JSON.stringify({ 1580 | query: "query A { a: test } query B { b: test }", 1581 | operationName: "A", 1582 | }), 1583 | }); 1584 | 1585 | strictEqual(response.status, 200); 1586 | strictEqual( 1587 | response.headers.get("Content-Type"), 1588 | "application/graphql+json" 1589 | ); 1590 | deepStrictEqual(await response.json(), { data: { a: null } }); 1591 | } finally { 1592 | close(); 1593 | } 1594 | } 1595 | ); 1596 | 1597 | tests.add( 1598 | "`execute` middleware with a GraphQL execution error.", 1599 | async () => { 1600 | /** @type {import("http-errors").HttpError | undefined} */ 1601 | let koaError; 1602 | 1603 | const errorMessage = "Message."; 1604 | 1605 | const app = new Koa() 1606 | .use(errorHandler()) 1607 | .use(bodyParserMiddleware) 1608 | .use( 1609 | execute({ 1610 | schema, 1611 | execute() { 1612 | throw new Error(errorMessage); 1613 | }, 1614 | }) 1615 | ) 1616 | .on("error", (error) => { 1617 | koaError = error; 1618 | }); 1619 | 1620 | const { port, close } = await listen(createServer(app.callback())); 1621 | 1622 | try { 1623 | const response = await fetchGraphQL(port, { 1624 | body: JSON.stringify({ query: "{ test }" }), 1625 | }); 1626 | 1627 | ok(koaError instanceof Error); 1628 | strictEqual(koaError.name, "Error"); 1629 | strictEqual(koaError.message, errorMessage); 1630 | strictEqual(koaError.status, 500); 1631 | strictEqual(koaError.expose, false); 1632 | strictEqual(response.status, 500); 1633 | strictEqual( 1634 | response.headers.get("Content-Type"), 1635 | "application/graphql+json" 1636 | ); 1637 | deepStrictEqual(await response.json(), { 1638 | errors: [{ message: "Internal Server Error" }], 1639 | }); 1640 | } finally { 1641 | close(); 1642 | } 1643 | } 1644 | ); 1645 | 1646 | tests.add( 1647 | "`execute` middleware with a GraphQL resolver error unexposed.", 1648 | async () => { 1649 | /** @type {GraphQLAggregateError | undefined} */ 1650 | let koaError; 1651 | let resolverError; 1652 | 1653 | const app = new Koa() 1654 | .use(errorHandler()) 1655 | .use(bodyParserMiddleware) 1656 | .use( 1657 | execute({ 1658 | schema: new GraphQLSchema({ 1659 | query: new GraphQLObjectType({ 1660 | name: "Query", 1661 | fields: { 1662 | test: { 1663 | type: new GraphQLNonNull(GraphQLString), 1664 | resolve() { 1665 | resolverError = 1666 | /** 1667 | * @type {import("./errorHandler.mjs") 1668 | * .KoaMiddlewareError} 1669 | */ 1670 | (new Error("Unexposed message.")); 1671 | resolverError.extensions = { 1672 | a: true, 1673 | }; 1674 | throw resolverError; 1675 | }, 1676 | }, 1677 | }, 1678 | }), 1679 | }), 1680 | }) 1681 | ) 1682 | .on("error", (error) => { 1683 | koaError = error; 1684 | }); 1685 | 1686 | const { port, close } = await listen(createServer(app.callback())); 1687 | 1688 | try { 1689 | const response = await fetchGraphQL(port, { 1690 | body: JSON.stringify({ query: "{ test }" }), 1691 | }); 1692 | 1693 | ok(koaError instanceof GraphQLAggregateError); 1694 | strictEqual(koaError.message, "GraphQL execution errors."); 1695 | strictEqual(koaError.status, 200); 1696 | strictEqual(koaError.expose, true); 1697 | strictEqual(koaError.errors.length, 1); 1698 | ok(koaError.errors[0] instanceof GraphQLError); 1699 | strictEqual(koaError.errors[0].message, "Unexposed message."); 1700 | deepStrictEqual(koaError.errors[0].locations, [{ line: 1, column: 3 }]); 1701 | deepStrictEqual(koaError.errors[0].path, ["test"]); 1702 | deepStrictEqual(koaError.errors[0].extensions, { a: true }); 1703 | deepStrictEqual(koaError.errors[0].originalError, resolverError); 1704 | strictEqual(response.status, 200); 1705 | strictEqual( 1706 | response.headers.get("Content-Type"), 1707 | "application/graphql+json" 1708 | ); 1709 | deepStrictEqual(await response.json(), { 1710 | errors: [ 1711 | { 1712 | message: "Internal Server Error", 1713 | locations: [{ line: 1, column: 3 }], 1714 | path: ["test"], 1715 | extensions: { a: true }, 1716 | }, 1717 | ], 1718 | }); 1719 | } finally { 1720 | close(); 1721 | } 1722 | } 1723 | ); 1724 | 1725 | tests.add( 1726 | "`execute` middleware with a GraphQL resolver error exposed.", 1727 | async () => { 1728 | /** @type {GraphQLAggregateError | undefined} */ 1729 | let koaError; 1730 | let resolverError; 1731 | 1732 | const app = new Koa() 1733 | .use(errorHandler()) 1734 | .use(bodyParserMiddleware) 1735 | .use( 1736 | execute({ 1737 | schema: new GraphQLSchema({ 1738 | query: new GraphQLObjectType({ 1739 | name: "Query", 1740 | fields: { 1741 | test: { 1742 | type: new GraphQLNonNull(GraphQLString), 1743 | resolve() { 1744 | resolverError = 1745 | /** 1746 | * @type {import("./errorHandler.mjs") 1747 | * .KoaMiddlewareError} 1748 | */ 1749 | (new Error("Exposed message.")); 1750 | resolverError.expose = true; 1751 | resolverError.extensions = { a: true }; 1752 | 1753 | throw resolverError; 1754 | }, 1755 | }, 1756 | }, 1757 | }), 1758 | }), 1759 | }) 1760 | ) 1761 | .on("error", (error) => { 1762 | koaError = error; 1763 | }); 1764 | 1765 | const { port, close } = await listen(createServer(app.callback())); 1766 | 1767 | try { 1768 | const response = await fetchGraphQL(port, { 1769 | body: JSON.stringify({ query: "{ test }" }), 1770 | }); 1771 | 1772 | ok(koaError instanceof GraphQLAggregateError); 1773 | strictEqual(koaError.message, "GraphQL execution errors."); 1774 | strictEqual(koaError.status, 200); 1775 | strictEqual(koaError.expose, true); 1776 | strictEqual(koaError.errors.length, 1); 1777 | ok(koaError.errors[0] instanceof GraphQLError); 1778 | strictEqual(koaError.errors[0].message, "Exposed message."); 1779 | deepStrictEqual(koaError.errors[0].locations, [{ line: 1, column: 3 }]); 1780 | deepStrictEqual(koaError.errors[0].path, ["test"]); 1781 | deepStrictEqual(koaError.errors[0].extensions, { a: true }); 1782 | deepStrictEqual(koaError.errors[0].originalError, resolverError); 1783 | strictEqual(response.status, 200); 1784 | strictEqual( 1785 | response.headers.get("Content-Type"), 1786 | "application/graphql+json" 1787 | ); 1788 | deepStrictEqual(await response.json(), { 1789 | errors: [ 1790 | { 1791 | message: "Exposed message.", 1792 | locations: [{ line: 1, column: 3 }], 1793 | path: ["test"], 1794 | extensions: { a: true }, 1795 | }, 1796 | ], 1797 | }); 1798 | } finally { 1799 | close(); 1800 | } 1801 | } 1802 | ); 1803 | }; 1804 | -------------------------------------------------------------------------------- /graphql-api-koa-logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaydenseric/graphql-api-koa/508453c3fd06e162752a7d259c7bb89deedb78bb/graphql-api-koa-logo.sketch -------------------------------------------------------------------------------- /graphql-api-koa-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | graphql-api-koa logo 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "maxNodeModuleJsDepth": 10, 4 | "module": "nodenext", 5 | "noEmit": true, 6 | "strict": true 7 | }, 8 | "typeAcquisition": { 9 | "enable": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright Jayden Seric 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-api-koa", 3 | "version": "9.1.3", 4 | "description": "GraphQL execution and error handling middleware written from scratch for Koa.", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Jayden Seric", 8 | "email": "me@jaydenseric.com", 9 | "url": "https://jaydenseric.com" 10 | }, 11 | "repository": "github:jaydenseric/graphql-api-koa", 12 | "homepage": "https://github.com/jaydenseric/graphql-api-koa#readme", 13 | "bugs": "https://github.com/jaydenseric/graphql-api-koa/issues", 14 | "funding": "https://github.com/sponsors/jaydenseric", 15 | "keywords": [ 16 | "graphql", 17 | "api", 18 | "koa", 19 | "esm", 20 | "mjs" 21 | ], 22 | "files": [ 23 | "assertKoaContextRequestGraphQL.mjs", 24 | "checkGraphQLSchema.mjs", 25 | "checkGraphQLValidationRules.mjs", 26 | "checkOptions.mjs", 27 | "errorHandler.mjs", 28 | "execute.mjs", 29 | "GraphQLAggregateError.mjs" 30 | ], 31 | "sideEffects": false, 32 | "exports": { 33 | "./errorHandler.mjs": "./errorHandler.mjs", 34 | "./execute.mjs": "./execute.mjs", 35 | "./GraphQLAggregateError.mjs": "./GraphQLAggregateError.mjs", 36 | "./package.json": "./package.json" 37 | }, 38 | "engines": { 39 | "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" 40 | }, 41 | "peerDependencies": { 42 | "graphql": "^16.0.0" 43 | }, 44 | "dependencies": { 45 | "@types/http-errors": "^2.0.1", 46 | "@types/koa": "^2.13.5", 47 | "http-errors": "^2.0.0" 48 | }, 49 | "devDependencies": { 50 | "@types/koa-bodyparser": "^4.3.10", 51 | "@types/node": "^18.11.9", 52 | "coverage-node": "^8.0.0", 53 | "eslint": "^8.27.0", 54 | "eslint-plugin-simple-import-sort": "^8.0.0", 55 | "graphql": "^16.6.0", 56 | "koa": "^2.13.4", 57 | "koa-bodyparser": "^4.3.0", 58 | "node-fetch": "^3.2.10", 59 | "prettier": "^2.7.1", 60 | "test-director": "^10.0.0", 61 | "typescript": "^4.8.4" 62 | }, 63 | "scripts": { 64 | "eslint": "eslint .", 65 | "prettier": "prettier -c .", 66 | "types": "tsc -p jsconfig.json", 67 | "tests": "coverage-node test.mjs", 68 | "test": "npm run eslint && npm run prettier && npm run types && npm run tests", 69 | "prepublishOnly": "npm test" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![graphql-api-koa logo](https://cdn.jsdelivr.net/gh/jaydenseric/graphql-api-koa@1.1.1/graphql-api-koa-logo.svg) 2 | 3 | # graphql-api-koa 4 | 5 | [GraphQL](https://graphql.org) execution and error handling middleware written from scratch for [Koa](https://koajs.com). 6 | 7 | ## Installation 8 | 9 | To install [`graphql-api-koa`](https://npm.im/graphql-api-koa) and its [`graphql`](https://npm.im/graphql) peer dependency with [npm](https://npmjs.com/get-npm), run: 10 | 11 | ```sh 12 | npm install graphql-api-koa graphql 13 | ``` 14 | 15 | Setup the Koa middleware in this order: 16 | 17 | 1. [`errorHandler`](./errorHandler.mjs), to catch errors from following middleware for a correctly formatted [GraphQL response](https://spec.graphql.org/October2021/#sec-Errors). 18 | 2. A [GraphQL multipart request](https://github.com/jaydenseric/graphql-multipart-request-spec) processor like `graphqlUploadKoa` from [`graphql-upload`](https://npm.im/graphql-upload), to support file uploads (optional). 19 | 3. A request body parser like [`koa-bodyparser`](https://npm.im/koa-bodyparser). 20 | 4. [`execute`](./execute.mjs), to execute GraphQL. 21 | 22 | See the [`execute`](./execute.mjs) middleware examples to get started. 23 | 24 | ## Requirements 25 | 26 | Supported runtime environments: 27 | 28 | - [Node.js](https://nodejs.org) versions `^14.17.0 || ^16.0.0 || >= 18.0.0`. 29 | 30 | Projects must configure [TypeScript](https://typescriptlang.org) to use types from the ECMAScript modules that have a `// @ts-check` comment: 31 | 32 | - [`compilerOptions.allowJs`](https://typescriptlang.org/tsconfig#allowJs) should be `true`. 33 | - [`compilerOptions.maxNodeModuleJsDepth`](https://typescriptlang.org/tsconfig#maxNodeModuleJsDepth) should be reasonably large, e.g. `10`. 34 | - [`compilerOptions.module`](https://typescriptlang.org/tsconfig#module) should be `"node16"` or `"nodenext"`. 35 | 36 | ## Exports 37 | 38 | The [npm](https://npmjs.com) package [`graphql-api-koa`](https://npm.im/graphql-api-koa) features [optimal JavaScript module design](https://jaydenseric.com/blog/optimal-javascript-module-design). It doesn’t have a main index module, so use deep imports from the ECMAScript modules that are exported via the [`package.json`](./package.json) field [`exports`](https://nodejs.org/api/packages.html#exports): 39 | 40 | - [`errorHandler.mjs`](./errorHandler.mjs) 41 | - [`execute.mjs`](./execute.mjs) 42 | - [`GraphQLAggregateError.mjs`](./GraphQLAggregateError.mjs) 43 | -------------------------------------------------------------------------------- /test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import TestDirector from "test-director"; 4 | 5 | import testAssertKoaContextRequestGraphQL from "./assertKoaContextRequestGraphQL.test.mjs"; 6 | import testCheckGraphQLSchema from "./checkGraphQLSchema.test.mjs"; 7 | import testCheckGraphQLValidationRules from "./checkGraphQLValidationRules.test.mjs"; 8 | import testCheckOptions from "./checkOptions.test.mjs"; 9 | import testErrorHandler from "./errorHandler.test.mjs"; 10 | import testExecute from "./execute.test.mjs"; 11 | import testGraphQLAggregateError from "./GraphQLAggregateError.test.mjs"; 12 | 13 | const tests = new TestDirector(); 14 | 15 | testAssertKoaContextRequestGraphQL(tests); 16 | testCheckGraphQLSchema(tests); 17 | testCheckGraphQLValidationRules(tests); 18 | testCheckOptions(tests); 19 | testErrorHandler(tests); 20 | testExecute(tests); 21 | testGraphQLAggregateError(tests); 22 | 23 | tests.run(); 24 | -------------------------------------------------------------------------------- /test/fetchGraphQL.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import fetch from "node-fetch"; 4 | 5 | /** 6 | * Fetches GraphQL from a localhost port. 7 | * @param {number} port Localhost port. 8 | * @param {import("node-fetch").RequestInit} options Fetch options. 9 | * @returns Fetch response. 10 | */ 11 | export default function fetchGraphQL(port, options = {}) { 12 | return fetch(`http://localhost:${port}`, { 13 | method: "POST", 14 | headers: { 15 | "Content-Type": "application/graphql+json", 16 | Accept: "application/graphql+json", 17 | }, 18 | ...options, 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /test/listen.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Starts a Node.js HTTP server. 5 | * @param {import("node:http").Server} server Node.js HTTP server. 6 | * @returns Resolves the port the server is listening on, and a server close 7 | * function. 8 | */ 9 | export default async function listen(server) { 10 | await new Promise((resolve) => { 11 | server.listen(resolve); 12 | }); 13 | 14 | return { 15 | port: /** @type {import("node:net").AddressInfo} */ (server.address()).port, 16 | close: () => server.close(), 17 | }; 18 | } 19 | --------------------------------------------------------------------------------