diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 00000000000..5950ac406e1 --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,507 @@ +parser: babel-eslint +parserOptions: + sourceType: module +env: + es6: true + node: true +plugins: + - import + +rules: + ############################################################################## + # `eslint-plugin-import` rule list based on `v2.20.x` + ############################################################################## + + # Static analysis + # https://github.com/benmosher/eslint-plugin-import#static-analysis + import/no-unresolved: error + import/named: error + import/default: error + import/namespace: error + import/no-restricted-paths: off + import/no-absolute-path: error + import/no-dynamic-require: error + import/no-internal-modules: off + import/no-webpack-loader-syntax: error + import/no-self-import: error + import/no-cycle: error + import/no-useless-path-segments: error + import/no-relative-parent-imports: off + + # Helpful warnings + # https://github.com/benmosher/eslint-plugin-import#helpful-warnings + import/export: error + import/no-named-as-default: error + import/no-named-as-default-member: error + import/no-deprecated: error + import/no-extraneous-dependencies: [error, { devDependencies: false }] + import/no-mutable-exports: error + import/no-unused-modules: error + + # Module systems + # https://github.com/benmosher/eslint-plugin-import#module-systems + import/unambiguous: error + import/no-commonjs: error + import/no-amd: error + import/no-nodejs-modules: error + + # Style guide + # https://github.com/benmosher/eslint-plugin-import#style-guide + import/first: error + import/exports-last: off + import/no-duplicates: error + import/no-namespace: error + import/extensions: [error, never] # TODO: switch to ignorePackages + import/order: [error, { newlines-between: always-and-inside-groups }] + import/newline-after-import: error + import/prefer-default-export: off + import/max-dependencies: off + import/no-unassigned-import: error + import/no-named-default: error + import/no-default-export: off + import/no-named-export: off + import/no-anonymous-default-export: error + import/group-exports: off + import/dynamic-import-chunkname: off + + ############################################################################## + # ESLint builtin rules list based on `v6.8.x` + ############################################################################## + + # Possible Errors + # https://eslint.org/docs/rules/#possible-errors + + for-direction: error + getter-return: error + no-async-promise-executor: error + no-await-in-loop: error + no-compare-neg-zero: error + no-cond-assign: error + no-console: warn + no-constant-condition: error + no-control-regex: error + no-debugger: warn + no-dupe-args: error + no-dupe-else-if: error + no-dupe-keys: error + no-duplicate-case: error + no-empty: error + no-empty-character-class: error + no-ex-assign: error + no-extra-boolean-cast: error + no-func-assign: error + no-import-assign: error + no-inner-declarations: [error, both] + no-invalid-regexp: error + no-irregular-whitespace: error + no-misleading-character-class: error + no-obj-calls: error + no-prototype-builtins: error + no-regex-spaces: error + no-setter-return: error + no-sparse-arrays: error + no-template-curly-in-string: error + no-unreachable: error + no-unsafe-finally: error + no-unsafe-negation: error + require-atomic-updates: error + use-isnan: error + valid-typeof: error + + # Best Practices + # https://eslint.org/docs/rules/#best-practices + + accessor-pairs: error + array-callback-return: error + block-scoped-var: error + class-methods-use-this: off + complexity: off + consistent-return: off + curly: error + default-case: off + default-param-last: error + dot-notation: off + eqeqeq: [error, smart] + grouped-accessor-pairs: error + guard-for-in: error + max-classes-per-file: off + no-alert: error + no-caller: error + no-case-declarations: error + no-constructor-return: error + no-div-regex: error + no-else-return: error + no-empty-function: error + no-empty-pattern: error + no-eq-null: off + no-eval: error + no-extend-native: error + no-extra-bind: error + no-extra-label: error + no-fallthrough: error + no-global-assign: error + no-implicit-coercion: error + no-implicit-globals: off + no-implied-eval: error + no-invalid-this: off + no-iterator: error + no-labels: error + no-lone-blocks: error + no-loop-func: error + no-magic-numbers: off + no-multi-str: error + no-new: error + no-new-func: error + no-new-wrappers: error + no-octal: error + no-octal-escape: error + no-param-reassign: error + no-proto: error + no-redeclare: error + no-restricted-properties: off + no-return-assign: error + no-return-await: error + no-script-url: error + no-self-assign: error + no-self-compare: off # TODO + no-sequences: error + no-throw-literal: error + no-unmodified-loop-condition: error + no-unused-expressions: error + no-unused-labels: error + no-useless-call: error + no-useless-catch: error + no-useless-concat: error + no-useless-escape: error + no-useless-return: error + no-void: error + no-warning-comments: off + no-with: error + prefer-named-capture-group: off # ES2018 + prefer-promise-reject-errors: error + prefer-regex-literals: error + radix: error + require-await: error + require-unicode-regexp: off + vars-on-top: error + yoda: [error, never, { exceptRange: true }] + + # Strict Mode + # https://eslint.org/docs/rules/#strict-mode + + strict: error + + # Variables + # https://eslint.org/docs/rules/#variables + + init-declarations: off + no-delete-var: error + no-label-var: error + no-restricted-globals: off + no-shadow: error + no-shadow-restricted-names: error + no-undef: error + no-undef-init: error + no-undefined: off + no-unused-vars: [error, { vars: all, args: all, argsIgnorePattern: '^_' }] + no-use-before-define: off + + # Node.js and CommonJS + # https://eslint.org/docs/rules/#nodejs-and-commonjs + + callback-return: error + global-require: error + handle-callback-err: [error, error] + no-buffer-constructor: error + no-mixed-requires: error + no-new-require: error + no-path-concat: error + no-process-env: off + no-process-exit: off + no-restricted-modules: off + no-sync: error + + # Stylistic Issues + # https://eslint.org/docs/rules/#stylistic-issues + + camelcase: error + capitalized-comments: off # maybe + consistent-this: off + func-name-matching: off + func-names: off + func-style: off + id-blacklist: off + id-length: off + id-match: [error, '^(?:_?[a-zA-Z0-9]*)|[_A-Z0-9]+$'] + line-comment-position: off + lines-around-comment: off + lines-between-class-members: [error, always, { exceptAfterSingleLine: true }] + max-depth: off + max-lines: off + max-lines-per-function: off + max-nested-callbacks: off + max-params: off + max-statements: off + max-statements-per-line: off + multiline-comment-style: off + new-cap: off # TODO + no-array-constructor: error + no-bitwise: off + no-continue: off + no-inline-comments: off + no-lonely-if: error + no-multi-assign: off + no-negated-condition: off + no-nested-ternary: off + no-new-object: error + no-plusplus: off + no-restricted-syntax: + - error + - selector: 'FunctionDeclaration[async=true]' + message: > + async functions are not allowed inside package source code because + older versions of NodeJS do not support them without additional + runtime dependencies. Instead, use explicit Promises. + no-tabs: error + no-ternary: off + no-underscore-dangle: off + no-unneeded-ternary: error + one-var: [error, never] + operator-assignment: error + padding-line-between-statements: off + prefer-exponentiation-operator: error + prefer-object-spread: error + quotes: [error, single, { avoidEscape: true }] + sort-keys: off + sort-vars: off + spaced-comment: error + + # ECMAScript 6 + # https://eslint.org/docs/rules/#ecmascript-6 + + arrow-body-style: error + constructor-super: error + no-class-assign: error + no-const-assign: error + no-dupe-class-members: error + no-duplicate-imports: error + no-new-symbol: error + no-restricted-imports: off + no-this-before-super: error + no-useless-computed-key: error + no-useless-constructor: error + no-useless-rename: error + no-var: error + object-shorthand: error + prefer-arrow-callback: error + prefer-const: error + prefer-destructuring: off + prefer-numeric-literals: error + prefer-rest-params: off # TODO + prefer-spread: error + prefer-template: off + require-yield: error + sort-imports: off + symbol-description: off + + # Bellow rules are disabled because coflicts with Prettier, see: + # https://github.com/prettier/eslint-config-prettier/blob/master/index.js + array-bracket-newline: off + array-bracket-spacing: off + array-element-newline: off + arrow-parens: off + arrow-spacing: off + block-spacing: off + brace-style: off + comma-dangle: off + comma-spacing: off + comma-style: off + computed-property-spacing: off + dot-location: off + eol-last: off + func-call-spacing: off + function-call-argument-newline: off + function-paren-newline: off + generator-star-spacing: off + implicit-arrow-linebreak: off + indent: off + jsx-quotes: off + key-spacing: off + keyword-spacing: off + linebreak-style: off + max-len: off + multiline-ternary: off + newline-per-chained-call: off + new-parens: off + no-confusing-arrow: off + no-extra-parens: off + no-extra-semi: off + no-floating-decimal: off + no-mixed-operators: off + no-mixed-spaces-and-tabs: off + no-multi-spaces: off + no-multiple-empty-lines: off + no-trailing-spaces: off + no-unexpected-multiline: off + no-whitespace-before-property: off + nonblock-statement-body-position: off + object-curly-newline: off + object-curly-spacing: off + object-property-newline: off + one-var-declaration-per-line: off + operator-linebreak: off + padded-blocks: off + quote-props: off + rest-spread-spacing: off + semi: off + semi-spacing: off + semi-style: off + space-before-blocks: off + space-before-function-paren: off + space-in-parens: off + space-infix-ops: off + space-unary-ops: off + switch-colon-spacing: off + template-curly-spacing: off + template-tag-spacing: off + unicode-bom: off + wrap-iife: off + wrap-regex: off + yield-star-spacing: off + +overrides: + - files: '**/*.ts' + parser: '@typescript-eslint/parser' + parserOptions: + tsconfigRootDir: './' + project: ['tsconfig.json'] + plugins: + - '@typescript-eslint' + rules: + import/named: off + import/namespace: off + import/default: off + import/no-named-as-default-member: off + import/no-named-as-default: off + import/no-cycle: off + import/no-unused-modules: off + import/no-deprecated: off + import/no-unresolved: off + + ########################################################################## + # `@typescript-eslint/eslint-plugin` rule list based on `v2.17.x` + ########################################################################## + + # Supported Rules + # https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules + '@typescript-eslint/adjacent-overload-signatures': error + '@typescript-eslint/array-type': [error, { default: generic }] + '@typescript-eslint/await-thenable': error + '@typescript-eslint/ban-ts-comment': error + '@typescript-eslint/ban-types': error + '@typescript-eslint/consistent-type-assertions': + [error, { assertionStyle: as, objectLiteralTypeAssertions: never }] + '@typescript-eslint/consistent-type-definitions': off # TODO consider + '@typescript-eslint/explicit-function-return-type': off # TODO consider + '@typescript-eslint/explicit-member-accessibility': off # TODO consider + '@typescript-eslint/explicit-module-boundary-types': off # TODO consider + '@typescript-eslint/member-ordering': off # TODO consider + '@typescript-eslint/naming-convention': off # TODO consider + '@typescript-eslint/no-dynamic-delete': off + '@typescript-eslint/no-empty-interface': error + '@typescript-eslint/no-explicit-any': off # TODO error + '@typescript-eslint/no-extra-non-null-assertion': error + '@typescript-eslint/no-extraneous-class': off # TODO consider + '@typescript-eslint/no-floating-promises': error + '@typescript-eslint/no-for-in-array': error + '@typescript-eslint/no-implied-eval': error + '@typescript-eslint/no-inferrable-types': + [error, { ignoreParameters: true, ignoreProperties: true }] + '@typescript-eslint/no-misused-new': error + '@typescript-eslint/no-misused-promises': error + '@typescript-eslint/no-namespace': error + '@typescript-eslint/no-non-null-asserted-optional-chain': error + '@typescript-eslint/no-non-null-assertion': error + '@typescript-eslint/no-parameter-properties': error + '@typescript-eslint/no-require-imports': error + '@typescript-eslint/no-this-alias': error + '@typescript-eslint/no-throw-literal': error + '@typescript-eslint/no-type-alias': off # TODO consider + '@typescript-eslint/no-unnecessary-boolean-literal-compare': error + '@typescript-eslint/no-unnecessary-condition': error + '@typescript-eslint/no-unnecessary-qualifier': error + '@typescript-eslint/no-unnecessary-type-arguments': error + '@typescript-eslint/no-unnecessary-type-assertion': error + '@typescript-eslint/no-unused-vars-experimental': off + '@typescript-eslint/no-var-requires': error + '@typescript-eslint/prefer-as-const': off # TODO consider + '@typescript-eslint/prefer-for-of': off # TODO switch to error after TS migration + '@typescript-eslint/prefer-function-type': error + '@typescript-eslint/prefer-includes': off # TODO switch to error after IE11 drop + '@typescript-eslint/prefer-namespace-keyword': error + '@typescript-eslint/prefer-nullish-coalescing': error + '@typescript-eslint/prefer-optional-chain': error + '@typescript-eslint/prefer-readonly': error + '@typescript-eslint/prefer-regexp-exec': error + '@typescript-eslint/prefer-string-starts-ends-with': off # TODO switch to error after IE11 drop + '@typescript-eslint/promise-function-async': off + '@typescript-eslint/require-array-sort-compare': error + '@typescript-eslint/restrict-plus-operands': + [error, { checkCompoundAssignments: true }] + '@typescript-eslint/restrict-template-expressions': error + '@typescript-eslint/strict-boolean-expressions': off # TODO consider + '@typescript-eslint/switch-exhaustiveness-check': error + '@typescript-eslint/triple-slash-reference': error + '@typescript-eslint/typedef': off + '@typescript-eslint/unbound-method': off # TODO consider + '@typescript-eslint/unified-signatures': error + + # Extension Rules + # https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#extension-rules + + # Disable conflicting ESLint rules and enable TS-compatible ones + default-param-last: off + no-array-constructor: off + no-dupe-class-members: off + no-empty-function: off + no-unused-expressions: off + no-unused-vars: off + no-useless-constructor: off + require-await: off + no-return-await: off + '@typescript-eslint/default-param-last': error + '@typescript-eslint/no-dupe-class-members': error + '@typescript-eslint/no-array-constructor': error + '@typescript-eslint/no-empty-function': error + '@typescript-eslint/no-unused-expressions': error + '@typescript-eslint/no-unused-vars': + [error, { vars: all, args: all, argsIgnorePattern: '^_' }] + '@typescript-eslint/no-useless-constructor': error + '@typescript-eslint/require-await': error + '@typescript-eslint/return-await': error + + # Disable for JS and TS + '@typescript-eslint/no-magic-numbers': off + '@typescript-eslint/no-use-before-define': off + + # Bellow rules are disabled because coflicts with Prettier, see: + # https://github.com/prettier/eslint-config-prettier/blob/master/%40typescript-eslint.js + '@typescript-eslint/quotes': off + '@typescript-eslint/brace-style': off + '@typescript-eslint/comma-spacing': off + '@typescript-eslint/func-call-spacing': off + '@typescript-eslint/indent': off + '@typescript-eslint/member-delimiter-style': off + '@typescript-eslint/no-extra-parens': off + '@typescript-eslint/no-extra-semi': off + '@typescript-eslint/semi': off + '@typescript-eslint/space-before-function-paren': off + '@typescript-eslint/type-annotation-spacing': off + overrides: + - files: 'src/test/**/*.ts' + env: + mocha: true + rules: + import/no-extraneous-dependencies: off + import/no-nodejs-modules: off + no-restricted-syntax: off diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..6313b56c578 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index 7ac4c259186..f55623756b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,16 @@ -node_modules -coverage -npm-debug.log -dist +node_modules/ +coverage/ +dist/ + *.tgz .DS_Store + +yarn.lock package-lock.json + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +.eslintcache +.nyc_output diff --git a/.npmignore b/.npmignore deleted file mode 100644 index e0e4eaecc5a..00000000000 --- a/.npmignore +++ /dev/null @@ -1,9 +0,0 @@ -* -!dist/ -!dist/* -!dist/stitching/* -!dist/transforms/* -!dist/generate/* -!package.json -!*.md -!*.png diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000000..1dab4ed4c30 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact = true diff --git a/.travis.yml b/.travis.yml index 62262c34e04..4a51eeaf7c5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,38 @@ language: node_js node_js: - - "8" + - "13" + - "12" - "10" +env: + - GRAPHQL_VERSION='0.12' + - GRAPHQL_VERSION='0.13' + - GRAPHQL_VERSION='14.0' + - GRAPHQL_VERSION='14.1' + - GRAPHQL_VERSION='14.2' + - GRAPHQL_VERSION='14.3' + - GRAPHQL_VERSION='14.4' + - GRAPHQL_VERSION='14.5' + - GRAPHQL_VERSION='14.6' + - GRAPHQL_VERSION='rc' + install: - npm config set spin=false - - npm install -g coveralls - npm install script: - - npm test - - npm run lint + - node_version=$(node -v); if [[ ${node_version:1:2} == "13" && $GRAPHQL_VERSION == "14.6" ]]; then + npm run lint; + fi + - node_version=$(node -v); if [[ ${node_version:1:2} == "13" && $GRAPHQL_VERSION == "14.6" ]]; then + npm run prettier:check; + fi + - npm run compile + - npm install graphql@$GRAPHQL_VERSION + - npm run testonly:cover + +after_success: - npm run coverage - - coveralls < ./coverage/lcov.info || true # if coveralls doesn't have it covered # Allow Travis tests to run in containers. sudo: false diff --git a/.vscode/settings.json b/.vscode/settings.json index 65e6eb88a5c..c1a50609bf2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,7 +15,6 @@ "node_modules": true, "test-lib": true, "lib": true, - "dist": true, "coverage": true, "npm": true }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 545599261a0..1ced43a707d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Change log +### Next + +#### Features + +* Adds [graphql-upload](https://github.com/jaydenseric/graphql-upload) compatible scalar and link for proxying remote file uploads [#671](https://github.com/apollographql/graphql-tools/issues/671) +* Add ability to merge fields from types from different schemas +* Adds transforms to wrap, extract, and rename fields [#1183](https://github.com/apollographql/graphql-tools/issues/1183) +* Adds transform to filter object fields [#819](https://github.com/apollographql/graphql-tools/issues/819) +* Exports visitSchema, SchemaVisitor, healSchema, healTypes, cloneSchema, cloneType, cloneDirective to enable more custom transforms. [#1070](https://github.com/apollographql/graphql-tools/issues/1070) +* Allows removing extra delegation layers by passing fetcher/link options directly to delegateToSchema, mergeSchemas, and transformSchema and by filtering directly with filterSchema without additional transformation round [#1165](https://github.com/apollographql/graphql-tools/issues/1165) + +#### Bug Fixes + +* Filter unused variables from map when proxying requests +* Preserve subscription errors when using makeRemoteExecutableSchema +* Preserve extensions when transforming schemas +* Fix merging and transforming of custom scalars and enums [#501](https://github.com/apollographql/graphql-tools/issues/501), [#1056](https://github.com/apollographql/graphql-tools/issues/1056), [#1200](https://github.com/apollographql/graphql-tools/issues/1200) +* Allow renaming of subscription root fields [#997](https://github.com/apollographql/graphql-tools/issues/997), [#1002](https://github.com/apollographql/graphql-tools/issues/1002) +* Fix alias resolution to no longer incorrectly fallback to non-aliased field when null [#1171](https://github.com/apollographql/graphql-tools/issues/1171) +* Do not remove default directives (skip, include, deprecated) when not merging custom directives [#1159](https://github.com/apollographql/graphql-tools/issues/1159) +* Fixes errors support [#743](https://github.com/apollographql/graphql-tools/issues/743), [#1037](https://github.com/apollographql/graphql-tools/issues/1037), [#1046](https://github.com/apollographql/graphql-tools/issues/1046) +* Fix mergeSchemas to allow resolvers to return fields defined as functions [#1061](https://github.com/apollographql/graphql-tools/issues/1061) +* Fix default values with mergeSchemas and addResolveFunctionsToSchema [#1121](https://github.com/apollographql/graphql-tools/issues/1121) +* Fix interface and union healing +* Fix stitching unions of types with enums +* Fix mocking to work when schema stitching +* Fix lost directives when adding an enum resolver + ### 4.0.7 * Filter `extensions` prior to passing them to `buildASTSchema`, in an effort to provide minimum compatibilty for `graphql@14`-compatible schemas with the upcoming `graphql@15` release. This PR does not, however, bring support for newer `graphql@15` features like interfaces implementing interfaces. [#1284](https://github.com/apollographql/graphql-tools/pull/1284) @@ -18,6 +46,12 @@ [@freiksenet](https://github.com/freiksenet) in [#1003](https://github.com/apollographql/graphql-tools/pull/1003) * Allow user-provided `buildSchema` options.
[@trevor-scheer](https://github.com/trevor-scheer) in [#1154](https://github.com/apollographql/graphql-tools/pull/1154) +* Fix `delegateToSchema` to allow delegation to subscriptions with different root field names, allows + the use of the `RenameRootFields` transform with subscriptions, + pull request [#1104](https://github.com/apollographql/graphql-tools/pull/1104), fixes + [#997](https://github.com/apollographql/graphql-tools/issues/997).
+* Add transformers to rename, filter, and arbitrarily transform object fields.
+ Fixes [#819](https://github.com/apollographql/graphql-tools/issues/819). ### 4.0.4 diff --git a/designs/connectors.md b/designs/connectors.md index a2764ffa1dd..e565c233bfc 100644 --- a/designs/connectors.md +++ b/designs/connectors.md @@ -9,7 +9,7 @@ This document is intended as a design document for people who want to write conn This is a draft at the moment, and not the final document. Chances are that the spec will change as we learn about the better ways to build GraphQL servers. It should be pretty close to the final version though, so if you want to get started and build connectors for specific backends, this document is a good starting point. -Technically you could write a GraphQL server without connectors and models by writing all your logic directly into the resolve functions, but in most cases that's not ideal. Connectors and models are a way of organizing code in a GraphQL server, and you should use them to keep your server modular. If the need arises, you can always write optimized queries directly in your resolvers or models. +Technically you could write a GraphQL server without connectors and models by writing all your logic directly into the resolvers, but in most cases that's not ideal. Connectors and models are a way of organizing code in a GraphQL server, and you should use them to keep your server modular. If the need arises, you can always write optimized queries directly in your resolvers or models. Let's use an example schema, because it's always easier to explain things with examples: ``` @@ -60,7 +60,7 @@ Both batching and caching are more important in GraphQL than in traditional endp Models are the glue between connectors - which are backend-specific - and GraphQL types - which are app-specific. They are very similar to models in ORMs, such as Rails' Active Record. -Let's say for example that you have two types, Author and Post, which are both stored in MySQL. Rather than calling the MySQL connector directly from your resolve functions, you should create models for Author and Post, which use the MySQL connector. This additional level of abstraction helps separate the data fetching logic from the GraphQL schema, which makes reusing and refactoring it easier. +Let's say for example that you have two types, Author and Post, which are both stored in MySQL. Rather than calling the MySQL connector directly from your resolvers, you should create models for Author and Post, which use the MySQL connector. This additional level of abstraction helps separate the data fetching logic from the GraphQL schema, which makes reusing and refactoring it easier. In the example schema above, the Authors model would have the following methods: ``` @@ -150,7 +150,7 @@ app.use('/graphql', apolloServer({ }); ``` -Step 4: Calling models in resolve functions +Step 4: Calling models in resolvers ``` function resolve(author, args, ctx){ return ctx.models.Author.getById(author.id, ctx); diff --git a/designs/graphql-decorator-spec.md b/designs/graphql-decorator-spec.md index ab6fd7f65f0..b663e8b7b95 100644 --- a/designs/graphql-decorator-spec.md +++ b/designs/graphql-decorator-spec.md @@ -73,9 +73,9 @@ Decorators can be selectively applied to: * A specific field * An argument -Decorators can modify the behavior of the parts of the schema they are applied to. Sometimes that requires modifying other parts of the schema. For instance, the @validateRange decorator modifies the behavior of the containing field's resolve function. +Decorators can modify the behavior of the parts of the schema they are applied to. Sometimes that requires modifying other parts of the schema. For instance, the @validateRange decorator modifies the behavior of the containing field's resolver. -In general, decorators either add, remove or modify an attribute of the thing they wrap. The most common type of decorator (e.g. @adminOnly, @log, @connector) will wrap one or more field's resolve functions to alter the execution behavior of the GraphQL schema, but other decorators (e.g. @description) may add attributes to a type, field or argument. It is also possible for a type decorator to add a field to the type (e.g. @id(fields: ["uuid"]) can add the __id field). +In general, decorators either add, remove or modify an attribute of the thing they wrap. The most common type of decorator (e.g. @adminOnly, @log, @connector) will wrap one or more field resolvers to alter the execution behavior of the GraphQL schema, but other decorators (e.g. @description) may add attributes to a type, field or argument. It is also possible for a type decorator to add a field to the type (e.g. @id(fields: ["uuid"]) can add the __id field). ## Schema decorator API @@ -120,7 +120,7 @@ class SampleFieldDecorator extends SchemaDecorator { return (wrappedThing, { schema, type, field, context }) => { // use this.config ... // use args - // modify wrappedThing's properties, resolve functions, etc. + // modify wrappedThing's properties, resolvers, etc. } } } diff --git a/docs/package-lock.json b/docs/package-lock.json index 84cd975d655..55a5cbcefcc 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -1,3 +1,4 @@ + { "requires": true, "lockfileVersion": 1, diff --git a/docs/source/directive-resolvers.md b/docs/source/directive-resolvers.md index 1e7e7c1faf8..9c4e988398f 100644 --- a/docs/source/directive-resolvers.md +++ b/docs/source/directive-resolvers.md @@ -2,7 +2,6 @@ ## Directive example Let's take a look at how we can create `@upper` Directive to upper-case a string returned from resolve on Field -[See a complete runnable example on Launchpad.](https://launchpad.graphql.com/p00rw37qx0) To start, let's grab the schema definition string from the `makeExecutableSchema` example [in the "Generating a schema" article](/generate-schema/#example). @@ -65,7 +64,6 @@ graphql(schema, query).then((result) => console.log('Got result', result)); ## Multi-Directives example Multi-Directives on a field will be apply with LTR order. -[See a complete runnable example on Launchpad.](https://launchpad.graphql.com/nx945rq1x7) ```js // graphql-tools combines a schema string with resolvers. diff --git a/docs/source/generate-schema.md b/docs/source/generate-schema.md index f95b952d5f7..3dd10bcb2ee 100644 --- a/docs/source/generate-schema.md +++ b/docs/source/generate-schema.md @@ -7,8 +7,6 @@ The graphql-tools package allows you to create a GraphQL.js GraphQLSchema instan ## Example -[See the complete live example in Apollo Launchpad.](https://launchpad.graphql.com/1jzxrj179) - When using `graphql-tools`, you describe the schema as a GraphQL type language string: ```js @@ -212,14 +210,14 @@ const jsSchema = makeExecutableSchema({ - `parseOptions` is an optional argument which allows customization of parse when specifying `typeDefs` as a string. -- `allowUndefinedInResolve` is an optional argument, which is `true` by default. When set to `false`, causes your resolve functions to throw errors if they return undefined, which can help make debugging easier. +- `allowUndefinedInResolve` is an optional argument, which is `true` by default. When set to `false`, causes your resolver to throw errors if they return undefined, which can help make debugging easier. - `resolverValidationOptions` is an optional argument which accepts an `ResolverValidationOptions` object which has the following boolean properties: - - `requireResolversForArgs` will cause `makeExecutableSchema` to throw an error if no resolve function is defined for a field that has arguments. + - `requireResolversForArgs` will cause `makeExecutableSchema` to throw an error if no resolver is defined for a field that has arguments. - `requireResolversForNonScalar` will cause `makeExecutableSchema` to throw an error if a non-scalar field has no resolver defined. Setting this to `true` can be helpful in catching errors, but defaults to `false` to avoid confusing behavior for those coming from other GraphQL libraries. - - `requireResolversForAllFields` asserts that *all* fields have a valid resolve function. + - `requireResolversForAllFields` asserts that *all* fields have valid resolvers. - `requireResolversForResolveType` will require a `resolveType()` method for Interface and Union types. This can be passed in with the field resolvers as `__resolveType()`. False to disable the warning. diff --git a/docs/source/index.mdx b/docs/source/index.mdx index a232a2f641d..20f88979499 100644 --- a/docs/source/index.mdx +++ b/docs/source/index.mdx @@ -3,13 +3,13 @@ title: graphql-tools description: A set of utilities to build your JavaScript GraphQL schema in a concise and powerful way. --- -GraphQL Tools is an npm package and an opinionated structure for how to build a GraphQL schema and resolvers in JavaScript, following the GraphQL-first development workflow. +GraphQL Tools is an npm package and an opinionated structure for how to build a GraphQL schema and resolvers in JavaScript, following the GraphQL-first development workflow, authored originally by the Apollo team. ```txt npm install graphql-tools graphql ``` -Functions in the `graphql-tools` package are not just useful for building servers. They can also be used in the browser, for example to mock a backend during development or testing. +Functions in the `graphql-tools` packages are not just useful for building servers. They can also be used in the browser, for example to mock a backend during development or testing. Even though we recommend a specific way of building GraphQL servers, you can use these tools even if you don't follow our structure; they work with any GraphQL-JS schema, and each tool can be useful on its own. @@ -24,5 +24,5 @@ JavaScript GraphQL servers are often developed with `graphql-tools` and `apollo- This package enables a specific workflow for developing a GraphQL server, where the GraphQL schema is the first thing you design, and acts as the contract between your frontend and backend. It's not necessarily for everyone, but it can be a great way to get a server up and running with a very clear separation of concerns. These concerns are aligned with Facebook's direction about the best way to use GraphQL, and our own findings after thinking about the best way to architect a JavaScript GraphQL API codebase. 1. **Use the GraphQL schema language.** The [official GraphQL documentation](http://graphql.org/learn/schema/) explains schema concepts using a concise and easy to read language. The [getting started guide](http://graphql.org/graphql-js/) for GraphQL.js now uses the schema to introduce new developers to GraphQL. `graphql-tools` enables you to use this language alongside with all of the features of GraphQL including resolvers, interfaces, custom scalars, and more, so that you can have a seamless flow from design to mocking to implementation. For a more complete overview of the benefits, check out Nick Nance's talk, [Managing GraphQL Development at Scale](https://www.youtube.com/watch?v=XOM8J4LaYFg). -2. **Separate business logic from the schema.** As Dan Schafer covered in his talk, [GraphQL at Facebook](https://medium.com/apollo-stack/graphql-at-facebook-by-dan-schafer-38d65ef075af#.jduhdwudr), it's a good idea to treat GraphQL as a thin API and routing layer. This means that your actual business logic, permissions, and other concerns should not be part of your GraphQL schema. For large apps, we suggest splitting your GraphQL server code into 4 components: Schema, Resolvers, Models, and Connectors, which each handle a specific part of the work. +2. **Separate business logic from the schema.** As Dan Schafer covered in his talk, [GraphQL at Facebook](https://medium.com/apollo-stack/graphql-at-facebook-by-dan-schafer-38d65ef075af#.jduhdwudr), it's a good idea to treat GraphQL as a thin API and routing layer. This means that your actual business logic, permissions, and other concerns should not be part of your GraphQL schema. For large apps, we suggest splitting your GraphQL server code into 4 components: Schema, Resolvers, Models, and Connectors, which each handle a specific part of the work. You can see this in action in the server part of our [GitHunt example app](https://github.com/apollostack/GitHunt-API/blob/master/api/schema.js). 3. **Use standard libraries for auth and other special concerns.** There's no need to reinvent the login process in GraphQL. Every server framework already has a wealth of technologies for auth, file uploads, and more. It's prudent to use those standard solutions even if your data is being served through a GraphQL endpoint, and it is okay to have non-GraphQL endpoints on your server when it's the most practical solution. diff --git a/docs/source/mocking.md b/docs/source/mocking.md index 5ffdf97f825..fc6b1867ab9 100644 --- a/docs/source/mocking.md +++ b/docs/source/mocking.md @@ -14,7 +14,7 @@ Let's take a look at how we can mock a GraphQL schema with just one line of code To start, let's grab the schema definition string from the `makeExecutableSchema` example [in the "Generating a schema" article](/generate-schema/#example). ```js -import { makeExecutableSchema, addMockFunctionsToSchema } from 'graphql-tools'; +import { makeExecutableSchema, addMocksToSchema } from 'graphql-tools'; import { graphql } from 'graphql'; // Fill this in with the schema string @@ -24,7 +24,7 @@ const schemaString = `...`; const schema = makeExecutableSchema({ typeDefs: schemaString }); // Add mocks, modifies schema in place -addMockFunctionsToSchema({ schema }); +addMocksToSchema({ schema }); const query = ` query tasksForUser { @@ -126,13 +126,13 @@ You can read some background and flavor on this approach in our blog post, ["Moc ## Mocking interfaces -You will need resolvers to mock interfaces. By default [`addMockFunctionsToSchema`](#addmockfunctionstoschema) will overwrite resolver functions. +You will need resolvers to mock interfaces. By default [`addMocksToSchema`](#addmockfunctionstoschema) will overwrite resolver functions. By setting the property `preserveResolvers` on the options object to `true`, the type resolvers will be preserved. ```js import { makeExecutableSchema, - addMockFunctionsToSchema + addMocksToSchema } from 'graphql-tools' import mocks from './mocks' // your mock functions @@ -188,7 +188,7 @@ const schema = makeExecutableSchema({ typeResolvers }) -addMockFunctionsToSchema({ +addMocksToSchema({ schema, mocks, preserveResolvers: true @@ -209,24 +209,24 @@ import * as introspectionResult from 'schema.json'; const schema = buildClientSchema(introspectionResult); -addMockFunctionsToSchema({schema}); +addMocksToSchema({schema}); ``` ## API -### addMockFunctionsToSchema +### addMocksToSchema ```js -import { addMockFunctionsToSchema } from 'graphql-tools'; +import { addMocksToSchema } from 'graphql-tools'; -addMockFunctionsToSchema({ +addMocksToSchema({ schema, mocks: {}, preserveResolvers: false, }); ``` -Given an instance of GraphQLSchema and a mock object, `addMockFunctionsToSchema` modifies the schema in place to return mock data for any valid query that is sent to the server. If `mocks` is not passed, the defaults will be used for each of the scalar types. If `preserveResolvers` is set to `true`, existing resolve functions will not be overwritten to provide mock data. This can be used to mock some parts of the server and not others. +Given an instance of GraphQLSchema and a mock object, `addMocksToSchema` modifies the schema in place to return mock data for any valid query that is sent to the server. If `mocks` is not passed, the defaults will be used for each of the scalar types. If `preserveResolvers` is set to `true`, existing resolvers will not be overwritten to provide mock data. This can be used to mock some parts of the server and not others. ### MockList @@ -247,7 +247,7 @@ import { mockServer } from 'graphql-tools'; // or a GraphQLSchema object (eg the result of `buildSchema` from `graphql`) const schema = `...` -// Same mocks object that `addMockFunctionsToSchema` takes above +// Same mocks object that `addMocksToSchema` takes above const mocks = {} preserveResolvers = false @@ -262,6 +262,6 @@ server.query(query, variables) }) ``` -`mockServer` is just a convenience wrapper on top of `addMockFunctionsToSchema`. It adds your mock resolvers to your schema and returns a client that will correctly execute +`mockServer` is just a convenience wrapper on top of `addMocksToSchema`. It adds your mock resolvers to your schema and returns a client that will correctly execute your query with variables. **Note**: when executing queries from the returned server, `context` and `root` will both equal `{}`. diff --git a/docs/source/resolvers.md b/docs/source/resolvers.md index d5aef29311c..e70a47e4d9a 100644 --- a/docs/source/resolvers.md +++ b/docs/source/resolvers.md @@ -10,7 +10,7 @@ Keep in mind that GraphQL resolvers can return [promises](https://developer.mozi ## Resolver map -In order to respond to queries, a schema needs to have resolve functions for all fields. Resolve functions cannot be included in the GraphQL schema language, so they must be added separately. This collection of functions is called the "resolver map". +In order to respond to queries, a schema needs to have resolvers for all fields. Resolvers are per field functions that are given a parent object, arguments, and the execution context, and are responsible for returning a result for that field. Resolvers cannot be included in the GraphQL schema language, so they must be added separately. The collection of resolvers is called the "resolver map". The `resolverMap` object (`IResolvers`) should have a map of resolvers for each relevant GraphQL Object Type. The following is an example of a valid `resolverMap` object: @@ -28,7 +28,7 @@ const resolverMap = { }, }; ``` -> Note: If you are using mocking, the `preserveResolvers` argument of [`addMockFunctionsToSchema`](/mocking/#addmockfunctionstoschema) must be set to `true` if you don't want your resolvers to be overwritten by mock resolvers. +> Note: If you are using mocking, the `preserveResolvers` argument of [`addMocksToSchema`](/mocking/#addmockfunctionstoschema) must be set to `true` if you don't want your resolvers to be overwritten by mock resolvers. Note that you don't have to put all of your resolvers in one object. Refer to the ["modularizing the schema"](/generate-schema/) section to learn how to combine multiple resolver maps into one. @@ -53,7 +53,7 @@ Resolvers in GraphQL can return different kinds of results which are treated dif 1. `null` or `undefined` - this indicates the object could not be found. If your schema says that field is _nullable_, then the result will have a `null` value at that position. If the field is `non-null`, the result will "bubble up" to the nearest nullable field and that result will be set to `null`. This is to ensure that the API consumer never gets a `null` value when they were expecting a result. 2. An array - this is only valid if the schema indicates that the result of a field should be a list. The sub-selection of the query will run once for every item in this array. -3. A promise - resolvers often do asynchronous actions like fetching from a database or backend API, so they can return promises. This can be combined with arrays, so a resolver can return: +3. A promise - resolvers often do asynchronous actions like fetching from a database or backend API, so they can return promises. This can be combined with arrays, so a resolver can return: 1. A promise that resolves an array 2. An array of promises 4. A scalar or object value - a resolver can also return any other kind of value, which doesn't have any special meaning but is simply passed down into any nested resolvers, as described in the next section. @@ -144,13 +144,13 @@ const resolverMap = { In addition to using a resolver map with `makeExecutableSchema`, you can use it with any GraphQL.js schema by importing the following function from `graphql-tools`: -### addResolveFunctionsToSchema({ schema, resolvers, resolverValidationOptions?, inheritResolversFromInterfaces? }) +### addResolversToSchema({ schema, resolvers, resolverValidationOptions?, inheritResolversFromInterfaces? }) -`addResolveFunctionsToSchema` takes an options object of `IAddResolveFunctionsToSchemaOptions` and modifies the schema in place by attaching the resolvers to the relevant types. +`addResolversToSchema` takes an options object of `IAddResolveFunctionsToSchemaOptions` and modifies the schema in place by attaching the resolvers to the relevant types. ```js -import { addResolveFunctionsToSchema } from 'graphql-tools'; +import { addResolversToSchema } from 'graphql-tools'; const resolvers = { RootQuery: { @@ -162,7 +162,7 @@ const resolvers = { }, }; -addResolveFunctionsToSchema({ schema, resolvers }); +addResolversToSchema({ schema, resolvers }); ``` The `IAddResolveFunctionsToSchemaOptions` object has 4 properties that are described in [`makeExecutableSchema`](/generate-schema/#makeexecutableschemaoptions). @@ -175,9 +175,9 @@ export interface IAddResolveFunctionsToSchemaOptions { } ``` -### addSchemaLevelResolveFunction(schema, rootResolveFunction) +### addSchemaLevelResolver(schema, rootResolveFunction) -Some operations, such as authentication, need to be done only once per query. Logically, these operations belong in an obj resolve function, but unfortunately GraphQL-JS does not let you define one. `addSchemaLevelResolveFunction` solves this by modifying the GraphQLSchema that is passed as the first argument. +Some operations, such as authentication, need to be done only once per query. Logically, these operations belong in a schema level resolver field resolver, but unfortunately GraphQL-JS does not let you define one. `addSchemaLevelResolver` solves this by modifying the GraphQLSchema that is passed as the first argument. ## Companion tools diff --git a/docs/source/schema-delegation.md b/docs/source/schema-delegation.md index 0a8949a8638..360c349fa82 100644 --- a/docs/source/schema-delegation.md +++ b/docs/source/schema-delegation.md @@ -3,7 +3,12 @@ title: Schema delegation description: Forward queries to other schemas automatically --- -Schema delegation is a way to automatically forward a query (or a part of a query) from a parent schema to another schema (called a _subschema_) that is able to execute the query. Delegation is useful when the parent schema shares a significant part of its data model with the subschema. For example, the parent schema might be powering a GraphQL gateway that connects multiple existing endpoints together, each with its own schema. This kind of architecture could be implemented using schema delegation. +Schema delegation is a way to automatically forward a query (or a part of a query) from a parent schema to another schema (called a _subschema_) that is able to execute the query. Delegation is useful when the parent schema shares a significant part of its data model with the subschema. For example: + +* A GraphQL gateway that connects multiple existing endpoints together, each with its own schema, could be implemented as a parent schema that delegates portions of queries to the relevant subschemas. +* Any local schema can directly wrap remote schemas and optionally extend them with additional fields. As long as schema delegation is unidirectional, no gateway is necessary. Simple examples are schemas that wrap other autogenerated schemas (e.g. Postgraphile, Hasura, Prisma) to add custom functionality. + +Delegation is performed by one function, `delegateToSchema`, called from within a resolver function of the parent schema. The `delegateToSchema` function sends the query subtree received by the parent resolver to the subschema that knows how to execute it. Fields for the merged types use the `defaultMergedResolver` resolver to extract the correct data from the query response. The `graphql-tools` package provides several related tools for managing schema delegation: @@ -11,8 +16,6 @@ The `graphql-tools` package provides several related tools for managing schema d * [Schema transforms](/schema-transforms/) - modifying existing schemas to make delegation easier * [Schema stitching](/schema-stitching/) - merging multiple schemas into one -Delegation is performed by one function, `delegateToSchema`, called from within a resolver function of the parent schema. The `delegateToSchema` function sends the query subtree received by the parent resolver to a subschema that knows how to execute it, then returns the result as if the parent resolver had executed the query. - ## Motivational example Let's consider two schemas, a subschema and a parent schema that reuses parts of a subschema. While the parent schema reuses the *definitions* of the subschema, we want to keep the implementations separate, so that the subschema can be tested independently, or even used as a remote service. @@ -102,11 +105,13 @@ query($id: ID!) { Delegation also removes the fields that don't exist on the subschema, such as `user`. This field would be retrieved from the parent schema using normal GraphQL resolvers. +Each field on the `Repository` and `Issue` types should use the `defaultMergedResolver` to properly extract data from the delegated response. Although in the simplest case, the default resolver can be used for the merged types, `defaultMergedResolver` resolves aliases, converts custom scalars and enums to their internal representations, and maps errors. + ## API ### delegateToSchema -The `delegateToSchema` method can be found on the `info.mergeInfo` object within any resolver function, and should be called with the following named options: +The `delegateToSchema` method should be called with the following named options: ``` delegateToSchema(options: { @@ -165,7 +170,7 @@ If we delegate at `User.bookings` to `Query.bookingsByUser`, we want to preserve const resolvers = { User: { bookings(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: subschema, operation: 'query', fieldName: 'bookingsByUser', @@ -190,14 +195,6 @@ GraphQL context that is going to be past to subschema execution or subsciption c GraphQL resolve info of the current resolver. Provides access to the subquery that starts at the current resolver. -Also provides the `info.mergeInfo.delegateToSchema` function discussed above. - #### transforms: Array -[Transforms](/schema-transforms/) to apply to the query and results. Should be the same transforms that were used to transform the schema, if any. After transformation, `transformedSchema.transforms` contains the transforms that were applied. - -## Additional considerations - -### Aliases - -Delegation preserves aliases that are passed from the parent query. However that presents problems, because default GraphQL resolvers retrieve field from parent based on their name, not aliases. This way results with aliases will be missing from the delegated result. `mergeSchemas` and `transformSchemas` go around that by using `src/stitching/defaultMergedResolver` for all fields without explicit resolver. When building new libraries around delegation, one should consider how the aliases will be handled. +Any additional operation [transforms](/schema-transforms/) to apply to the query and results. Could be the same operation transforms used in conjunction with schema transformation. For convenience, after schema transformation, `transformedSchema.transforms` contains the transforms that were applied. diff --git a/docs/source/schema-directives.md b/docs/source/schema-directives.md index d563fb0f212..f6bcd2e185d 100644 --- a/docs/source/schema-directives.md +++ b/docs/source/schema-directives.md @@ -460,11 +460,10 @@ class LengthDirective extends SchemaDirectiveVisitor { // Replace field.type with a custom GraphQLScalarType that enforces the // length restriction. wrapType(field) { - if (field.type instanceof GraphQLNonNull && - field.type.ofType instanceof GraphQLScalarType) { + if (isNonNullType(field.type) && isScalarType(field.type.ofType)) { field.type = new GraphQLNonNull( new LimitedLengthType(field.type.ofType, this.args.max)); - } else if (field.type instanceof GraphQLScalarType) { + } else if (isScalarType(field.type)) { field.type = new LimitedLengthType(field.type, this.args.max); } else { throw new Error(`Not a scalar type: ${field.type}`); diff --git a/docs/source/schema-stitching.md b/docs/source/schema-stitching.md index 1770fa66bc3..f3320f10a63 100644 --- a/docs/source/schema-stitching.md +++ b/docs/source/schema-stitching.md @@ -1,5 +1,5 @@ --- -title: Schema stitching (deprecated) +title: Schema stitching (still going strong) description: Combining multiple GraphQL APIs into one --- @@ -7,24 +7,18 @@ description: Combining multiple GraphQL APIs into one Schema stitching is the process of creating a single GraphQL schema from multiple underlying GraphQL APIs. -One of the main benefits of GraphQL is that we can query all of our data as part of one schema, and get everything we need in one request. But as the schema grows, it might become cumbersome to manage it all as one codebase, and it starts to make sense to split it into different modules. We may also want to decompose your schema into separate microservices, which can be developed and deployed independently. +One of the main benefits of GraphQL is that we can query all of our data as part of one schema, and get everything we need in one request. But as the schema grows, it might become cumbersome to manage it all as one codebase, and it starts to make sense to split it into different modules. We may also want to decompose your schema into separate microservices, which can be developed and deployed independently. We may also want to integrate our own schema with remote schemas. -In both cases, we use `mergeSchemas` to combine multiple GraphQL schemas together and produce a merged schema that knows how to delegate parts of the query to the relevant subschemas. These subschemas can be either local to the server, or running on a remote server. They can even be services offered by 3rd parties, allowing us to connect to external data and create mashups. - -## Working with remote schemas - -In order to merge with a remote schema, we first call [makeRemoteExecutableSchema](/remote-schemas/) to create a local proxy for the schema that knows how to call the remote endpoint. We then merge that local proxy schema the same way we would merge any other locally implemented schema. +In these cases, we use `mergeSchemas` to combine multiple GraphQL schemas together and produce a new schema that knows how to delegate parts of the query to the relevant subschemas. These subschemas can be either local to the server, or running on a remote server. They can even be services offered by 3rd parties, allowing us to connect to external data and create mashups. ## Basic example -In this example we'll stitch together two very simple schemas. It doesn't matter whether these are local or proxies created with `makeRemoteExecutableSchema`, because the merging itself would be the same. - -In this case, we're dealing with two schemas that implement a system with users and "chirps"—small snippets of text that users can post. +In this example we'll stitch together two very simple schemas. In this case, we're dealing with two schemas that implement a system with users and "chirps"—small snippets of text that users can post. ```js import { makeExecutableSchema, - addMockFunctionsToSchema, + addMocksToSchema, mergeSchemas, } from 'graphql-tools'; @@ -46,7 +40,7 @@ const chirpSchema = makeExecutableSchema({ ` }); -addMockFunctionsToSchema({ schema: chirpSchema }); +addMocksToSchema({ schema: chirpSchema }); // Mocked author schema const authorSchema = makeExecutableSchema({ @@ -62,17 +56,19 @@ const authorSchema = makeExecutableSchema({ ` }); -addMockFunctionsToSchema({ schema: authorSchema }); +addMocksToSchema({ schema: authorSchema }); export const schema = mergeSchemas({ - schemas: [ - chirpSchema, - authorSchema, + subschemas: [ + { schema: chirpSchema, }, + { schema: authorSchema, }, ], }); ``` -[Run the above example on Launchpad.](https://launchpad.graphql.com/1nkk8vqj9) +Note the new `subschemas` property with an array of subschema configuration objects. This syntax is a bit more verbose, but we shall see how it provides multiple benefits: +1. transforms can be specified on the subschema config object, avoiding creation of a new schema with a new round of delegation in order to transform a schema prior to merging. +2. remote schema configuration options can be specified, also avoiding an additional round of schema proxying. This gives us a new schema with the root fields on `Query` from both schemas (along with the `User` and `Chirp` types): @@ -107,38 +103,40 @@ const linkTypeDefs = ` We can now merge these three schemas together: ```js -mergeSchemas({ - schemas: [ - chirpSchema, - authorSchema, - linkTypeDefs, +export const schema = mergeSchemas({ + subschemas: [ + { schema: chirpSchema, }, + { schema: authorSchema, }, ], + typeDefs: linkTypeDefs, }); ``` +Note the new `typeDefs` option in parallel to the new `subschemas` option, which better expresses that these typeDefs are defined only within the outer gateway schemas. + We won't be able to query `User.chirps` or `Chirp.author` yet, however, because we still need to define resolvers for these new fields. How should these resolvers be implemented? When we resolve `User.chirps` or `Chirp.author`, we want to _delegate_ to the relevant root fields. To get from a user to the user's chirps, for example, we'll want to use the `id` of the user to call `Query.chirpsByAuthorId`. And to get from a chirp to its author, we can use the chirp's `authorId` field to call the existing `Query.userById` field. -Resolvers for fields in schemas created by `mergeSchema` have access to a handy `delegateToSchema` function (exposed via `info.mergeInfo.delegateToSchema`) that allows forwarding parts of queries (or even whole new queries) to one of the subschemas that was passed to `mergeSchemas`. +Resolvers can use the `delegateToSchema` function to forward parts of queries (or even whole new queries) to one of the subschemas that was passed to `mergeSchemas` (or any other schema). In order to delegate to these root fields, we'll need to make sure we've actually requested the `id` of the user or the `authorId` of the chirp. To avoid forcing users to add these fields to their queries manually, resolvers on a merged schema can define a `fragment` property that specifies the required fields, and they will be added to the query automatically. A complete implementation of schema stitching for these schemas might look like this: ```js -const mergedSchema = mergeSchemas({ - schemas: [ - chirpSchema, - authorSchema, - linkTypeDefs, +const schema = mergeSchemas({ + subschemas: [ + { schema: chirpSchema, }, + { schema: authorSchema, }, ], + typeDefs: linkTypeDefs, resolvers: { User: { chirps: { fragment: `... on User { id }`, resolve(user, args, context, info) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: chirpSchema, operation: 'query', fieldName: 'chirpsByAuthorId', @@ -155,7 +153,7 @@ const mergedSchema = mergeSchemas({ author: { fragment: `... on Chirp { authorId }`, resolve(chirp, args, context, info) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: authorSchema, operation: 'query', fieldName: 'userById', @@ -172,22 +170,17 @@ const mergedSchema = mergeSchemas({ }); ``` -[Run the above example on Launchpad.](https://launchpad.graphql.com/8r11mk9jq) - ## Using with Transforms -Often, when creating a GraphQL gateway that combines multiple existing schemas, we might want to modify one of the schemas. The most common tasks include renaming some of the types, and filtering the root fields. By using [transforms](/schema-transforms/) with schema stitching, we can easily tweak the subschemas before merging them together. - -Before, when we were simply merging schemas without first transforming them, we would typically delegate directly to one of the merged schemas. Once we add transforms to the mix, there are times when we want to delegate to fields of the new, transformed schemas, and other times when we want to delegate to the original, untransformed schemas. +Often, when creating a GraphQL gateway that combines multiple existing schemas, we might want to modify one of the schemas. The most common tasks include renaming some of the types, and filtering the root fields. By using [transforms](/schema-transforms/) with schema stitching, we can easily tweak the subschemas before merging them together. (In earlier versions of graphql-tools, this required an additional round of delegation prior to merging, but transforms can now be specifying directly when merging using the new subschema configuration objects.) For example, suppose we transform the `chirpSchema` by removing the `chirpsByAuthorId` field and add a `Chirp_` prefix to all types and field names, in order to make it very clear which types and fields came from `chirpSchema`: ```ts import { makeExecutableSchema, - addMockFunctionsToSchema, + addMocksToSchema, mergeSchemas, - transformSchema, FilterRootFields, RenameTypes, RenameRootFields, @@ -211,37 +204,43 @@ const chirpSchema = makeExecutableSchema({ ` }); -addMockFunctionsToSchema({ schema: chirpSchema }); +addMocksToSchema({ schema: chirpSchema }); -// create transform schema +// create transforms -const transformedChirpSchema = transformSchema(chirpSchema, [ +const chirpSchemaTransforms = [ new FilterRootFields( (operation: string, rootField: string) => rootField !== 'chirpsByAuthorId' ), new RenameTypes((name: string) => `Chirp_${name}`), new RenameRootFields((operation: 'Query' | 'Mutation' | 'Subscription', name: string) => `Chirp_${name}`), -]); +]; ``` -Now we have a schema that has all fields and types prefixed with `Chirp_` and has only the `chirpById` root field. Note that the original schema has not been modified, and remains fully functional. We've simply created a new, slightly different schema, which hopefully will be more convenient for merging with our other subschemas. +We will now have a schema that has all fields and types prefixed with `Chirp_` and has only the `chirpById` root field. Now let's implement the resolvers: -```js -const mergedSchema = mergeSchemas({ - schemas: [ - transformedChirpSchema, - authorSchema, - linkTypeDefs, +```ts +const chirpSubschema = { + schema: chirpSchema, + transforms: chirpSchemaTransforms, +} + +export const schema = mergeSchemas({ + subschemas: [ + chirpSubschema, + { schema: authorSchema }, ], + typeDefs: linkTypeDefs, + resolvers: { User: { chirps: { fragment: `... on User { id }`, resolve(user, args, context, info) { - return info.mergeInfo.delegateToSchema({ - schema: chirpSchema, + return delegateToSchema({ + schema: chirpSubschema, operation: 'query', fieldName: 'chirpsByAuthorId', args: { @@ -249,7 +248,6 @@ const mergedSchema = mergeSchemas({ }, context, info, - transforms: transformedChirpSchema.transforms, }); }, }, @@ -258,7 +256,7 @@ const mergedSchema = mergeSchemas({ author: { fragment: `... on Chirp { authorId }`, resolve(chirp, args, context, info) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: authorSchema, operation: 'query', fieldName: 'userById', @@ -277,23 +275,56 @@ const mergedSchema = mergeSchemas({ Notice that `resolvers.Chirp_Chirp` has been renamed from just `Chirp`, but `resolvers.Chirp_Chirp.author.fragment` still refers to the original `Chirp` type and `authorId` field, rather than `Chirp_Chirp` and `Chirp_authorId`. -Also, when we call `info.mergeInfo.delegateToSchema` in the `User.chirps` resolvers, we can delegate to the original `chirpsByAuthorId` field, even though it has been filtered out of the final schema. That's because we're delegating to the original `chirpSchema`, which has not been modified by the transforms. +Also, when we call `delegateToSchema` in the `User.chirps` resolvers, we can delegate to the original `chirpsByAuthorId` field, even though it has been filtered out of the final schema. -## Complex example +## Working with remote schemas -For a more complicated example involving properties and bookings, with implementations of all of the resolvers, check out the Launchpad links below: +In order to merge with a remote schema, we specify different options within the subschema configuration object that describe how to connect to the remote schema. For example: -* [Property schema](https://launchpad.graphql.com/v7l45qkw3) -* [Booking schema](https://launchpad.graphql.com/41p4j4309) -* [Merged schema](https://launchpad.graphql.com/q5kq9z15p) +```ts + subschemas: [ + { + schema: nonExecutableChirpSchema, + link: chirpSchemaLink + transforms: chirpSchemaTransforms, + }, + { schema: authorSchema }, + ], +``` + +The remote schema may be obtained either via introspection or any other source. A link is a generic ApolloLink method of connecting to a schema, also used by Apollo Client. + +Specifying the remote schema options within the `mergeSchemas` call itself allows for skipping an additional round of delegation. The old method of using [makeRemoteExecutableSchema](/remote-schemas/) to create a local proxy for the remote schema would still work, and the same arguments are supported. See the [remote schema](/remote-schemas/) docs for further description of the options available. Subschema configuration allows for specifying an ApolloLink `link`, any fetcher method (if not using subscriptions), or a dispatcher function that takes the graphql `context` object as an argument and dynamically returns a link object or fetcher method. ## API -### mergeSchemas +### schemas ```ts + +export type SubschemaConfig = { + schema: GraphQLSchema; + rootValue?: Record; + executor?: Delegator; + subscriber?: Delegator; + link?: ApolloLink; + fetcher?: Fetcher; + dispatcher?: Dispatcher; + transforms?: Array; +}; + +export type SchemaLikeObject = + SubschemaConfig | + GraphQLSchema | + string | + DocumentNode | + Array; + mergeSchemas({ - schemas: Array>; + subschemas: Array; + types: Array; + typeDefs: string | DocumentNode; + schemas: Array; resolvers?: Array | IResolvers; onTypeConflict?: ( left: GraphQLNamedType, @@ -316,7 +347,7 @@ This is the main function that implements schema stitching. Read below for a des #### schemas -`schemas` is an array of `GraphQLSchema` objects, schema strings, or lists of `GraphQLNamedType`s. Strings can contain type extensions or GraphQL types, which will be added to resulting schema. Note that type extensions are always applied last, while types are defined in the order in which they are provided. +`schemas` is an array of `GraphQLSchema` objects, schema strings, or lists of `GraphQLNamedType`s. Strings can contain type extensions or GraphQL types, which will be added to resulting schema. Note that type extensions are always applied last, while types are defined in the order in which they are provided. Using the `subschemas` and `typeDefs` parameters is preferred, as these parameter names better describe whether the includes types will be wrapped or will be imported directly into the outer schema. #### resolvers @@ -328,7 +359,7 @@ resolvers: { property: { fragment: '... on Booking { propertyId }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: bookingSchema, operation: 'query', fieldName: 'propertyById', @@ -344,14 +375,12 @@ resolvers: { } ``` -#### mergeInfo and delegateToSchema +#### delegateToSchema -The `info.mergeInfo` object provides the `delegateToSchema` method: +The `delegateToSchema` method: ```js -type MergeInfo = { - delegateToSchema(options: IDelegateToSchemaOptions): any; -} +delegateToSchema(options: IDelegateToSchemaOptions): any; interface IDelegateToSchemaOptions right; +const onTypeConflict = (left, right) => left; ``` And here's how we might select the type whose schema has the latest `version`: @@ -413,4 +442,4 @@ When using schema transforms, `onTypeConflict` is often unnecessary, since trans #### inheritResolversFromInterfaces -The `inheritResolversFromInterfaces` option is simply passed through to `addResolveFunctionsToSchema`, which is called when adding resolvers to the schema under the covers. See [`addResolveFunctionsToSchema`](/resolvers/#addresolvefunctionstoschema-schema-resolvers-resolvervalidationoptions-inheritresolversfrominterfaces-) for more info. +The `inheritResolversFromInterfaces` option is simply passed through to `addResolversToSchema`, which is called when adding resolvers to the schema under the covers. See [`addResolversToSchema`](/resolvers/#addresolvefunctionstoschema-schema-resolvers-resolvervalidationoptions-inheritresolversfrominterfaces-) for more info. diff --git a/docs/source/schema-transforms.md b/docs/source/schema-transforms.md index 3102105c9a0..078ec4a2a54 100644 --- a/docs/source/schema-transforms.md +++ b/docs/source/schema-transforms.md @@ -3,13 +3,11 @@ title: Schema transforms description: Automatically transforming schemas --- -Schema transforms are a tool for making modified copies of `GraphQLSchema` objects, while preserving the possibility of delegating back to original schema. +Schema transforms are a tool for making modified copies of `GraphQLSchema` objects, without changing the original schema implementation. This is especially useful when the original schema _cannot_ be changed, i.e. when using [remote schemas](/remote-schemas/). -Transforms are useful when working with [remote schemas](/remote-schemas/), building GraphQL gateways that combine multiple schemas, and/or using [schema stitching](/schema-stitching/) to combine schemas together without conflicts between types or fields. +Schema transforms can be useful when building GraphQL gateways that combine multiple schemas using [schema stitching](/schema-stitching/) to combine schemas together without conflicts between types or fields. -While it's possible to modify a schema by hand, the manual approach requires a deep understanding of all the relationships between `GraphQLSchema` properties, which makes it error-prone and labor-intensive. Transforms provide a generic abstraction over all those details, which improves code quality and saves time, not only now but also in the future, because transforms are designed to be reused again and again. - -Each `Transform` may define three different kinds of transform functions: +Schema transforms work by wrapping the original schema in a new 'gateway' schema that simply delegates all operations to the original subschema. Each schema transform includes a function that changes the gateway schema. It may also include an operation transform, i.e. functions that either modify the operation prior to delegation or modify the result prior to its return. ```ts interface Transform = { @@ -19,8 +17,6 @@ interface Transform = { }; ``` -The most commonly used transform function is `transformSchema`. However, some transforms require modifying incoming requests and/or outgoing results as well, especially if `transformSchema` adds or removes types or fields, since such changes require mapping new types/fields to the original types/fields at runtime. - For example, let's consider changing the name of the type in a simple schema. Imagine we've written a function that takes a `GraphQLSchema` and replaces all instances of type `Test` with `NewTest`. ```graphql @@ -46,7 +42,7 @@ type Query { } ``` -At runtime, we want the `NewTest` type to be automatically mapped to the old `Test` type. +On delegation to the original subschema, we want the `NewTest` type to be automatically mapped to the old `Test` type. At first glance, it might seem as though most queries work the same way as before: @@ -102,11 +98,17 @@ type Result = ExecutionResult & { }; ``` -### transformSchema +### wrapSchema Given a `GraphQLSchema` and an array of `Transform` objects, produce a new schema with those transforms applied. -Delegating resolvers will also be generated to map from new schema root fields to old schema root fields. Often these automatic resolvers are sufficient, so you don't have to implement your own. +Delegating resolvers are generated to map from new schema root fields to old schema root fields. These automatic resolvers should be sufficient, so you don't have to implement your own. + +The delegating resolvers will apply the operation transforms defined by the `Transform` objects. Each provided `transformRequest` functions will be applies in reverse order, until the request matches the original schema. The `tranformResult` functions will be applied in the opposite order until the result matches the final gateway schema. + +### transformSchema + +For convenience, when using `transformSchema`, after schema transformation, the `transforms` property on a returned `transformedSchema` object will contains the operation transforms that were applied. This could be useful when manually delegating to the transformed schema, but has been deprecated in favor of specifying the transforms within a subschema configuration object. See the [schema stitching](/schema-stitching/) docs for further details. ## Built-in transforms @@ -170,7 +172,57 @@ RenameRootFields( ) ``` -### Other +### Modifying object fields + +* `TransformObjectFields(objectFieldTransformer: ObjectFieldTransformer, fieldNodeTransformer?: FieldNodeTransformer))`: Given an object field transformer, arbitrarily transform fields. The `objectFieldTransformer` can return a `GraphQLFieldConfig` definition, a object with new `name` and a `field`, `null` to remove the field, or `undefined` to leave the field unchanged. The optional `fieldNodeTransformer`, if specified, is called upon any field of that type in the request; result transformation can be specified by wrapping the field's resolver within the `objectFieldTransformer`. In this way, a field can be fully arbitrarily modified in place. + +```ts +TransformObjectFields(objectFieldTransformer: ObjectFieldTransformer, fieldNodeTransformer: FieldNodeTransformer) + +type ObjectFieldTransformer = ( + typeName: string, + fieldName: string, + field: GraphQLField, +) => + | GraphQLFieldConfig + | { name: string; field: GraphQLFieldConfig } + | null + | void; + +type FieldNodeTransformer = ( + typeName: string, + fieldName: string, + fieldNode: FieldNode +) => FieldNode; +``` + +* `FilterObjectFields(filter: ObjectFilter)`: Removes object fields for which the `filter` function returns `false`. + +```ts +FilterObjectFields(filter: ObjectFilter) + +type ObjectFilter = ( + typeName: string, + fieldName: string, + field: GraphQLField, +) => boolean; +``` + +* `RenameObjectFields(renamer)`: Rename object fields, by applying the `renamer` function to their names. + +```ts +RenameObjectFields( + renamer: ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => string, +) +``` + +### Additional Operation Transforms + +It may be sometimes useful to add additional transforms to manually change an operation request or result when using `delegateToSchema`. Common use cases may be move selections around or to wrap them. The following built-in transforms may be useful in those cases. * `ExtractField({ from: Array, to: Array })` - move selection at `from` path to `to` path. @@ -243,15 +295,19 @@ transforms: [ }) ``` -* `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, fragments: Array<{ field: string; fragment: string; }>)`: Replace the given fields with an inline fragment. Used by `mergeSchemas` to handle the `fragment` option. - -## delegateToSchema transforms +## delegateToSchema (delegation) transforms -The following transforms are automatically applied by `delegateToSchema` during schema delegation, to translate between new and old types and fields: +The following transforms are automatically applied by `delegateToSchema` during schema delegation, to translate between source and target types and fields: -* `AddArgumentsAsVariables`: Given a schema and arguments passed to a root field, make those arguments document variables. -* `FilterToSchema`: Given a schema and document, remove all fields, variables and fragments for types that don't exist in that schema. -* `AddTypenameToAbstract`: Add `__typename` to all abstract types in the document. +* `ExpandAbstractTypes`: If an abstract type within a document does not exist within the target schema, expand the type to each and any of its implementations that do exist. +* `FilterToSchema`: Remove all fields, variables and fragments for types that don't exist within the target schema. +* `AddTypenameToAbstract`: Add `__typename` to all abstract types in the document, necessary for type resolution of interfaces within the source schema to work. * `CheckResultAndHandleErrors`: Given a result from a subschema, propagate errors so that they match the correct subfield. Also provide the correct key if aliases are used. -By passing a custom `transforms` array to `delegateToSchema`, it's possible to run additional transforms before these default transforms, though it is currently not possible to disable the default transforms. +By passing a custom `transforms` array to `delegateToSchema`, it's possible to run additional operation (request/result) transforms before these default transforms. + +## mergeSchemas (gateway/stitching) transforms + +* `AddReplacementSelectionSets(schema: GraphQLSchema, mapping: ReplacementSelectionSetMapping)`: `mergeSchemas` adds selection sets on outgoing requests from the gateway, enabling delegation from fields specified on the gateway using fields obtained from the original requests. The selection sets can be added depending on the presence of fields within the request using the `selectionSet` option within the resolver map. `mergeSchemas` creates the mapping at gateway startup. Selection sets are used instead of fragments as the selections are added prior to transformation (in case type names are changed). +* `AddMergedTypeSelectionSets(schema: GraphQLSchema, mapping: Record)`: `mergeSchemas` adds selection sets on outgoing requests from the gateway, enabling type merging from the initial result using any fields initially obtained. The mapping is created at gateway startup. +* Deprecated: `ReplaceFieldWithFragment(targetSchema: GraphQLSchema, fragments: Array<{ field: string; fragment: string; }>)`: Replace the given fields with an inline fragment. Used by original `mergeSchemas` to add prespecified fragments to root fields, enabling delegation `fragment` option. Array was parsed at each delegation. diff --git a/package.json b/package.json index 3ec61534073..37224cd007d 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,32 @@ { "name": "graphql-tools", - "version": "4.0.7", + "version": "5.0.0-alpha.0", "description": "Useful tools to create and manipulate GraphQL schemas.", "main": "dist/index.js", - "typings": "dist/index.d.ts", - "typescript": { - "definition": "dist/index.d.ts" - }, - "directories": { - "test": "test" - }, + "types": "dist/index.d.ts", + "files": [ + "/dist", + "!/dist/test" + ], + "sideEffects": false, "scripts": { "clean": "rimraf dist", - "compile": "npx tsc", - "typings": "typings install", + "build": "npm run compile", + "precompile": "npm run clean", + "compile": "tsc", "pretest": "npm run clean && npm run compile", - "test": "npm run testonly --", - "posttest": "npm run lint", - "lint": "tslint src/**/*.ts", + "test": "npm run testonly", + "posttest": "npm run lint && npm run prettier:check", + "lint": "eslint --ext .js,.ts src", + "lint:watch": "esw --watch --cache --ext .js,.ts src", "watch": "tsc -w", - "testonly": "mocha --reporter spec --full-trace ./dist/test/tests.js", - "testonly:watch": "mocha -w --reporter spec --full-trace ./dist/test/tests.js", - "coverage": "istanbul cover _mocha -- --reporter dot --full-trace ./dist/test/tests.js", - "postcoverage": "remap-istanbul --input coverage/coverage.json --type lcovonly --output coverage/lcov.info", + "testonly": "mocha --reporter spec --full-trace ./dist/test/**.js --require source-map-support/register", + "testonly:cover": "nyc npm run testonly", + "testonly:watch": "mocha -w --reporter spec --full-trace ./dist/test/**.js --require source-map-support/register", + "coverage": "nyc report --reporter=text-lcov | coveralls", "prepublishOnly": "npm run compile", - "prerelease": "npm test", - "prettier": "prettier --trailing-comma all --single-quote --write 'src/**/*.ts'", - "release": "standard-version" + "prettier": "prettier --trailing-comma all --single-quote --write src/**/*.ts", + "prettier:check": "prettier --trailing-comma all --single-quote --check src/**/*.ts" }, "repository": { "type": "git", @@ -49,36 +49,53 @@ }, "homepage": "https://github.com/apollostack/graphql-tools#readme", "dependencies": { - "apollo-link": "^1.2.3", - "apollo-utilities": "^1.0.1", + "apollo-link": "^1.2.13", + "apollo-link-http-common": "^0.2.15", "deprecated-decorator": "^0.1.6", - "iterall": "^1.1.3", - "uuid": "^3.1.0" + "extract-files": "^7.0.0", + "form-data": "^3.0.0", + "iterall": "^1.3.0", + "node-fetch": "^2.6.0", + "tslib": "^1.11.0", + "uuid": "^7.0.2" }, "peerDependencies": { - "graphql": "^0.13.0 || ^14.0.0" + "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0-rc" }, "devDependencies": { - "@types/chai": "4.0.10", - "@types/dateformat": "^1.0.1", - "@types/mocha": "^2.2.44", - "@types/node": "^8.0.47", - "@types/uuid": "^3.4.3", - "@types/zen-observable": "^0.5.3", - "body-parser": "^1.18.2", - "chai": "^4.1.2", - "dateformat": "^3.0.3", - "express": "^4.16.2", - "graphql": "^14.5.8", - "graphql-subscriptions": "^1.0.0", - "graphql-type-json": "^0.1.4", - "istanbul": "^0.4.5", - "mocha": "^4.0.1", - "prettier": "^1.7.4", - "remap-istanbul": "0.9.6", - "rimraf": "^2.6.2", - "source-map-support": "^0.5.0", - "tslint": "^5.8.0", - "typescript": "^3.6.4" + "@types/chai": "4.2.11", + "@types/dateformat": "3.0.1", + "@types/express": "4.17.3", + "@types/extract-files": "3.1.0", + "@types/graphql-type-json": "0.3.2", + "@types/graphql-upload": "8.0.3", + "@types/mocha": "7.0.2", + "@types/node": "13.9.3", + "@types/node-fetch": "2.5.5", + "@types/uuid": "7.0.2", + "@typescript-eslint/eslint-plugin": "2.25.0", + "@typescript-eslint/parser": "2.25.0", + "babel-eslint": "10.1.0", + "body-parser": "1.19.0", + "chai": "4.2.0", + "coveralls": "3.0.11", + "dataloader": "2.0.0", + "dateformat": "3.0.3", + "eslint": "6.8.0", + "eslint-plugin-import": "2.20.1", + "eslint-watch": "6.0.1", + "express": "4.17.1", + "express-graphql": "0.9.0", + "graphql": "14.6.0", + "graphql-subscriptions": "1.1.0", + "graphql-type-json": "0.3.1", + "graphql-upload": "10.0.0", + "mocha": "7.1.1", + "nyc": "15.0.0", + "prettier": "2.0.2", + "rimraf": "3.0.2", + "source-map-support": "0.5.16", + "typescript": "3.8.3", + "zen-observable-ts": "0.8.20" } } diff --git a/src/Interfaces.ts b/src/Interfaces.ts index d5e513c0814..9a176068eeb 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -2,22 +2,40 @@ import { GraphQLSchema, GraphQLField, ExecutionResult, + GraphQLInputType, GraphQLType, + GraphQLNamedType, GraphQLFieldResolver, GraphQLResolveInfo, GraphQLIsTypeOfFn, GraphQLTypeResolver, GraphQLScalarType, - GraphQLNamedType, DocumentNode, - ASTNode, + FieldNode, + GraphQLEnumValue, + GraphQLEnumType, + GraphQLUnionType, + GraphQLArgument, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + InlineFragmentNode, + GraphQLOutputType, + SelectionSetNode, + GraphQLDirective, + GraphQLFieldConfig, + FragmentDefinitionNode, + SelectionNode, + VariableDefinitionNode, } from 'graphql'; -import { SchemaDirectiveVisitor } from './schemaVisitor'; +import { TypeMap } from 'graphql/type/schema'; +import { ApolloLink } from 'apollo-link'; -/* TODO: Add documentation */ +import { SchemaVisitor } from './utils/SchemaVisitor'; +import { SchemaDirectiveVisitor } from './utils/SchemaDirectiveVisitor'; -export type UnitOrList = Type | Array; export interface IResolverValidationOptions { requireResolversForArgs?: boolean; requireResolversForNonScalar?: boolean; @@ -26,9 +44,19 @@ export interface IResolverValidationOptions { allowResolversNotInSchema?: boolean; } +// for backwards compatibility export interface IAddResolveFunctionsToSchemaOptions { schema: GraphQLSchema; resolvers: IResolvers; + defaultFieldResolver: IFieldResolver; + resolverValidationOptions: IResolverValidationOptions; + inheritResolversFromInterfaces: boolean; +} + +export interface IAddResolversToSchemaOptions { + schema: GraphQLSchema; + resolvers: IResolvers; + defaultFieldResolver?: IFieldResolver; resolverValidationOptions?: IResolverValidationOptions; inheritResolversFromInterfaces?: boolean; } @@ -41,28 +69,155 @@ export interface IResolverOptions { __isTypeOf?: GraphQLIsTypeOfFn; } -export type Transform = { +export interface Transform { transformSchema?: (schema: GraphQLSchema) => GraphQLSchema; transformRequest?: (originalRequest: Request) => Request; transformResult?: (result: Result) => Result; +} + +export type FieldTransformer = ( + typeName: string, + fieldName: string, + field: GraphQLField, +) => GraphQLFieldConfig | RenamedField | null | undefined; + +export type FieldNodeTransformer = ( + typeName: string, + fieldName: string, + fieldNode: FieldNode, + fragments: Record, +) => SelectionNode | Array; + +export type RenamedField = { + name: string; + field?: GraphQLFieldConfig; }; +export type FieldFilter = ( + typeName?: string, + fieldName?: string, + field?: GraphQLField, +) => boolean; + +export type RootFieldFilter = ( + operation?: 'Query' | 'Mutation' | 'Subscription', + rootFieldName?: string, + field?: GraphQLField, +) => boolean; + export interface IGraphQLToolsResolveInfo extends GraphQLResolveInfo { mergeInfo?: MergeInfo; } -export interface IDelegateToSchemaOptions { +export type Fetcher = ( + operation: IFetcherOperation, +) => Promise; + +export interface IFetcherOperation { + query: DocumentNode; + operationName?: string; + variables?: { [key: string]: any }; + context?: { [key: string]: any }; +} + +export type Dispatcher = (context: any) => ApolloLink | Fetcher; + +export interface SubschemaConfig { schema: GraphQLSchema; - operation: Operation; - fieldName: string; + rootValue?: Record; + executor?: Delegator; + subscriber?: Delegator; + link?: ApolloLink; + fetcher?: Fetcher; + dispatcher?: Dispatcher; + transforms?: Array; + merge?: Record; +} + +export interface MergedTypeConfig { + selectionSet?: string; + fieldName?: string; + args?: (originalResult: any) => Record; + resolve?: MergedTypeResolver; +} + +export type MergedTypeResolver = ( + originalResult: any, + context: Record, + info: IGraphQLToolsResolveInfo, + subschema: GraphQLSchema | SubschemaConfig, + selectionSet: SelectionSetNode, +) => any; + +export interface GraphQLSchemaWithTransforms extends GraphQLSchema { + transforms?: Array; +} + +export type SchemaLikeObject = + | SubschemaConfig + | GraphQLSchema + | string + | DocumentNode + | Array; + +export function isSubschemaConfig( + value: SchemaLikeObject, +): value is SubschemaConfig { + return Boolean((value as SubschemaConfig).schema); +} + +export interface IDelegateToSchemaOptions { + schema: GraphQLSchema | SubschemaConfig; + operation?: Operation; + fieldName?: string; + returnType?: GraphQLOutputType; args?: { [key: string]: any }; - context: TContext; + selectionSet?: SelectionSetNode; + fieldNodes?: ReadonlyArray; + context?: TContext; info: IGraphQLToolsResolveInfo; + rootValue?: Record; transforms?: Array; skipValidation?: boolean; + skipTypeMerging?: boolean; } -export type MergeInfo = { +export interface ICreateRequestFromInfo { + info: IGraphQLToolsResolveInfo; + operation: Operation; + fieldName: string; + selectionSet?: SelectionSetNode; + fieldNodes?: ReadonlyArray; +} + +export interface ICreateRequest { + sourceSchema: GraphQLSchema; + sourceParentType: GraphQLObjectType; + sourceFieldName: string; + fragments: Record; + variableDefinitions: ReadonlyArray; + variableValues: Record; + targetOperation: Operation; + targetFieldName: string; + selectionSet: SelectionSetNode; + fieldNodes: ReadonlyArray; +} + +export interface IDelegateRequestOptions extends IDelegateToSchemaOptions { + request: Request; +} + +export type Delegator = ({ + document, + context, + variables, +}: { + document: DocumentNode; + context?: { [key: string]: any }; + variables?: { [key: string]: any }; +}) => any; + +export interface MergeInfo { delegate: ( type: 'query' | 'mutation' | 'subscription', fieldName: string, @@ -71,29 +226,56 @@ export type MergeInfo = { info: GraphQLResolveInfo, transforms?: Array, ) => any; - delegateToSchema(options: IDelegateToSchemaOptions): any; fragments: Array<{ field: string; fragment: string; }>; -}; + replacementSelectionSets: ReplacementSelectionSetMapping; + replacementFragments: ReplacementFragmentMapping; + mergedTypes: Record; + delegateToSchema(options: IDelegateToSchemaOptions): any; +} + +export interface ReplacementSelectionSetMapping { + [typeName: string]: { [fieldName: string]: SelectionSetNode }; +} + +export interface ReplacementFragmentMapping { + [typeName: string]: { [fieldName: string]: InlineFragmentNode }; +} + +export interface MergedTypeInfo { + subschemas: Array; + selectionSet?: SelectionSetNode; + uniqueFields: Record; + nonUniqueFields: Record>; + typeMaps: Map; + selectionSets: Map; + containsSelectionSet: Map>; +} export type IFieldResolver> = ( source: TSource, args: TArgs, context: TContext, - info: GraphQLResolveInfo & { mergeInfo: MergeInfo }, + info: IGraphQLToolsResolveInfo, ) => any; -export type ITypedef = (() => ITypedef[]) | string | DocumentNode | ASTNode; -export type ITypeDefinitions = ITypedef | ITypedef[]; -export type IResolverObject = { +export type ITypedef = (() => Array) | string | DocumentNode; + +export type ITypeDefinitions = ITypedef | Array; + +export interface IResolverObject { [key: string]: | IFieldResolver | IResolverOptions | IResolverObject; -}; -export type IEnumResolver = { [key: string]: string | number }; +} + +export interface IEnumResolver { + [key: string]: string | number; +} + export interface IResolvers { [key: string]: | (() => any) @@ -102,26 +284,27 @@ export interface IResolvers { | GraphQLScalarType | IEnumResolver; } + export type IResolversParameter = | Array IResolvers)> | IResolvers | ((mergeInfo: MergeInfo) => IResolvers); export interface ILogger { - log: (message: string | Error) => void; + log: (error: Error) => void; } -export interface IConnectorCls { - new (context?: TContext): any; -} +export type IConnectorCls = new (context?: TContext) => any; + export type IConnectorFn = (context?: TContext) => any; + export type IConnector = | IConnectorCls | IConnectorFn; -export type IConnectors = { +export interface IConnectors { [key: string]: IConnector; -}; +} export interface IExecutableSchemaDefinition { typeDefs: ITypeDefinitions; @@ -142,7 +325,13 @@ export type IFieldIteratorFn = ( fieldName: string, ) => void; +export type IDefaultValueIteratorFn = ( + type: GraphQLInputType, + value: any, +) => void; + export type NextResolverFn = () => Promise; + export type DirectiveResolverFn = ( next: NextResolverFn, source: TSource, @@ -157,7 +346,11 @@ export interface IDirectiveResolvers { /* XXX on mocks, args are optional, Not sure if a bug. */ export type IMockFn = GraphQLFieldResolver; -export type IMocks = { [key: string]: IMockFn }; + +export interface IMocks { + [key: string]: IMockFn; +} + export type IMockTypeFn = ( type: GraphQLType, typeName?: string, @@ -165,7 +358,7 @@ export type IMockTypeFn = ( ) => GraphQLFieldResolver; export interface IMockOptions { - schema: GraphQLSchema; + schema?: GraphQLSchema; mocks?: IMocks; preserveResolvers?: boolean; } @@ -177,40 +370,226 @@ export interface IMockServer { ) => Promise; } -export type MergeTypeCandidate = { - schema?: GraphQLSchema; - type: GraphQLNamedType; -}; - -export type TypeWithResolvers = { - type: GraphQLNamedType; - resolvers?: IResolvers; -}; - -export type VisitTypeResult = GraphQLNamedType | TypeWithResolvers | null; - -export type VisitType = ( - name: string, - candidates: Array, -) => VisitTypeResult; +export type OnTypeConflict = ( + left: GraphQLNamedType, + right: GraphQLNamedType, + info?: { + left: { + schema?: GraphQLSchema | SubschemaConfig; + }; + right: { + schema?: GraphQLSchema | SubschemaConfig; + }; + }, +) => GraphQLNamedType; export type Operation = 'query' | 'mutation' | 'subscription'; -export type Request = { +export interface Request { document: DocumentNode; variables: Record; extensions?: Record; -}; +} -export type Result = ExecutionResult & { +export interface Result extends ExecutionResult { extensions?: Record; -}; - -export type ResolveType = (type: T) => T; +} -export type GraphQLParseOptions = { +export interface GraphQLParseOptions { noLocation?: boolean; allowLegacySDLEmptyFields?: boolean; allowLegacySDLImplementsInterfaces?: boolean; experimentalFragmentVariables?: boolean; -}; +} + +export type IndexedObject = { [key: string]: V } | ReadonlyArray; + +export type VisitableSchemaType = + | GraphQLSchema + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLInputObjectType + | GraphQLNamedType + | GraphQLScalarType + | GraphQLField + | GraphQLInputField + | GraphQLArgument + | GraphQLUnionType + | GraphQLEnumType + | GraphQLEnumValue; + +export type VisitorSelector = ( + type: VisitableSchemaType, + methodName: string, +) => Array; + +export enum VisitSchemaKind { + TYPE = 'VisitSchemaKind.TYPE', + SCALAR_TYPE = 'VisitSchemaKind.SCALAR_TYPE', + ENUM_TYPE = 'VisitSchemaKind.ENUM_TYPE', + COMPOSITE_TYPE = 'VisitSchemaKind.COMPOSITE_TYPE', + OBJECT_TYPE = 'VisitSchemaKind.OBJECT_TYPE', + INPUT_OBJECT_TYPE = 'VisitSchemaKind.INPUT_OBJECT_TYPE', + ABSTRACT_TYPE = 'VisitSchemaKind.ABSTRACT_TYPE', + UNION_TYPE = 'VisitSchemaKind.UNION_TYPE', + INTERFACE_TYPE = 'VisitSchemaKind.INTERFACE_TYPE', + ROOT_OBJECT = 'VisitSchemaKind.ROOT_OBJECT', + QUERY = 'VisitSchemaKind.QUERY', + MUTATION = 'VisitSchemaKind.MUTATION', + SUBSCRIPTION = 'VisitSchemaKind.SUBSCRIPTION', +} + +export interface SchemaVisitorMap { + [VisitSchemaKind.TYPE]?: NamedTypeVisitor; + [VisitSchemaKind.SCALAR_TYPE]?: ScalarTypeVisitor; + [VisitSchemaKind.ENUM_TYPE]?: EnumTypeVisitor; + [VisitSchemaKind.COMPOSITE_TYPE]?: CompositeTypeVisitor; + [VisitSchemaKind.OBJECT_TYPE]?: ObjectTypeVisitor; + [VisitSchemaKind.INPUT_OBJECT_TYPE]?: InputObjectTypeVisitor; + [VisitSchemaKind.ABSTRACT_TYPE]?: AbstractTypeVisitor; + [VisitSchemaKind.UNION_TYPE]?: UnionTypeVisitor; + [VisitSchemaKind.INTERFACE_TYPE]?: InterfaceTypeVisitor; + [VisitSchemaKind.ROOT_OBJECT]?: ObjectTypeVisitor; + [VisitSchemaKind.QUERY]?: ObjectTypeVisitor; + [VisitSchemaKind.MUTATION]?: ObjectTypeVisitor; + [VisitSchemaKind.SUBSCRIPTION]?: ObjectTypeVisitor; +} + +export type NamedTypeVisitor = ( + type: GraphQLNamedType, + schema: GraphQLSchema, +) => GraphQLNamedType | null | undefined; + +export type ScalarTypeVisitor = ( + type: GraphQLScalarType, + schema: GraphQLSchema, +) => GraphQLScalarType | null | undefined; + +export type EnumTypeVisitor = ( + type: GraphQLEnumType, + schema: GraphQLSchema, +) => GraphQLEnumType | null | undefined; + +export type CompositeTypeVisitor = ( + type: GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType, + schema: GraphQLSchema, +) => + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | null + | undefined; + +export type ObjectTypeVisitor = ( + type: GraphQLObjectType, + schema: GraphQLSchema, +) => GraphQLObjectType | null | undefined; + +export type InputObjectTypeVisitor = ( + type: GraphQLInputObjectType, + schema: GraphQLSchema, +) => GraphQLInputObjectType | null | undefined; + +export type AbstractTypeVisitor = ( + type: GraphQLInterfaceType | GraphQLUnionType, + schema: GraphQLSchema, +) => GraphQLInterfaceType | GraphQLUnionType | null | undefined; + +export type UnionTypeVisitor = ( + type: GraphQLUnionType, + schema: GraphQLSchema, +) => GraphQLUnionType | null | undefined; + +export type InterfaceTypeVisitor = ( + type: GraphQLInterfaceType, + schema: GraphQLSchema, +) => GraphQLInterfaceType | null | undefined; + +export enum MapperKind { + TYPE = 'MapperKind.TYPE', + SCALAR_TYPE = 'MapperKind.SCALAR_TYPE', + ENUM_TYPE = 'MapperKind.ENUM_TYPE', + COMPOSITE_TYPE = 'MapperKind.COMPOSITE_TYPE', + OBJECT_TYPE = 'MapperKind.OBJECT_TYPE', + INPUT_OBJECT_TYPE = 'MapperKind.INPUT_OBJECT_TYPE', + ABSTRACT_TYPE = 'MapperKind.ABSTRACT_TYPE', + UNION_TYPE = 'MapperKind.UNION_TYPE', + INTERFACE_TYPE = 'MapperKind.INTERFACE_TYPE', + ROOT_OBJECT = 'MapperKind.ROOT_OBJECT', + QUERY = 'MapperKind.QUERY', + MUTATION = 'MapperKind.MUTATION', + SUBSCRIPTION = 'MapperKind.SUBSCRIPTION', + DIRECTIVE = 'MapperKind.DIRECTIVE', +} + +export interface SchemaMapper { + [MapperKind.TYPE]?: NamedTypeMapper; + [MapperKind.SCALAR_TYPE]?: ScalarTypeMapper; + [MapperKind.ENUM_TYPE]?: EnumTypeMapper; + [MapperKind.COMPOSITE_TYPE]?: CompositeTypeMapper; + [MapperKind.OBJECT_TYPE]?: ObjectTypeMapper; + [MapperKind.INPUT_OBJECT_TYPE]?: InputObjectTypeMapper; + [MapperKind.ABSTRACT_TYPE]?: AbstractTypeMapper; + [MapperKind.UNION_TYPE]?: UnionTypeMapper; + [MapperKind.INTERFACE_TYPE]?: InterfaceTypeMapper; + [MapperKind.ROOT_OBJECT]?: ObjectTypeMapper; + [MapperKind.QUERY]?: ObjectTypeMapper; + [MapperKind.MUTATION]?: ObjectTypeMapper; + [MapperKind.SUBSCRIPTION]?: ObjectTypeMapper; + [MapperKind.DIRECTIVE]?: DirectiveMapper; +} + +export type NamedTypeMapper = ( + type: GraphQLNamedType, + schema: GraphQLSchema, +) => GraphQLNamedType | null | undefined; + +export type ScalarTypeMapper = ( + type: GraphQLScalarType, + schema: GraphQLSchema, +) => GraphQLScalarType | null | undefined; + +export type EnumTypeMapper = ( + type: GraphQLEnumType, + schema: GraphQLSchema, +) => GraphQLEnumType | null | undefined; + +export type CompositeTypeMapper = ( + type: GraphQLObjectType | GraphQLInterfaceType | GraphQLUnionType, + schema: GraphQLSchema, +) => + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | null + | undefined; + +export type ObjectTypeMapper = ( + type: GraphQLObjectType, + schema: GraphQLSchema, +) => GraphQLObjectType | null | undefined; + +export type InputObjectTypeMapper = ( + type: GraphQLInputObjectType, + schema: GraphQLSchema, +) => GraphQLInputObjectType | null | undefined; + +export type AbstractTypeMapper = ( + type: GraphQLInterfaceType | GraphQLUnionType, + schema: GraphQLSchema, +) => GraphQLInterfaceType | GraphQLUnionType | null | undefined; + +export type UnionTypeMapper = ( + type: GraphQLUnionType, + schema: GraphQLSchema, +) => GraphQLUnionType | null | undefined; + +export type InterfaceTypeMapper = ( + type: GraphQLInterfaceType, + schema: GraphQLSchema, +) => GraphQLInterfaceType | null | undefined; + +export type DirectiveMapper = ( + directive: GraphQLDirective, + schema: GraphQLSchema, +) => GraphQLDirective | null | undefined; diff --git a/src/delegate/addTypenameToAbstract.ts b/src/delegate/addTypenameToAbstract.ts new file mode 100644 index 00000000000..362cb9d7f2a --- /dev/null +++ b/src/delegate/addTypenameToAbstract.ts @@ -0,0 +1,45 @@ +import { + GraphQLType, + DocumentNode, + TypeInfo, + visit, + visitWithTypeInfo, + SelectionSetNode, + Kind, + GraphQLSchema, + isAbstractType, +} from 'graphql'; + +export function addTypenameToAbstract( + targetSchema: GraphQLSchema, + document: DocumentNode, +): DocumentNode { + const typeInfo = new TypeInfo(targetSchema); + return visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]( + node: SelectionSetNode, + ): SelectionSetNode | null | undefined { + const parentType: GraphQLType = typeInfo.getParentType(); + let selections = node.selections; + if (parentType != null && isAbstractType(parentType)) { + selections = selections.concat({ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }); + } + + if (selections !== node.selections) { + return { + ...node, + selections, + }; + } + }, + }), + ); +} diff --git a/src/delegate/checkResultAndHandleErrors.ts b/src/delegate/checkResultAndHandleErrors.ts new file mode 100644 index 00000000000..99c2e9e06b7 --- /dev/null +++ b/src/delegate/checkResultAndHandleErrors.ts @@ -0,0 +1,317 @@ +import { + GraphQLResolveInfo, + responsePathAsArray, + getNullableType, + isCompositeType, + isLeafType, + isListType, + ExecutionResult, + GraphQLCompositeType, + GraphQLError, + GraphQLList, + GraphQLOutputType, + GraphQLType, + GraphQLSchema, + FieldNode, + isAbstractType, + GraphQLObjectType, +} from 'graphql'; +import { collectFields, ExecutionContext } from 'graphql/execution/execute'; + +import { + SubschemaConfig, + IGraphQLToolsResolveInfo, + isSubschemaConfig, + MergedTypeInfo, +} from '../Interfaces'; + +import { + relocatedError, + combineErrors, + getErrorsByPathSegment, +} from '../stitch/errors'; +import { getResponseKeyFromInfo } from '../stitch/getResponseKeyFromInfo'; +import resolveFromParentTypename from '../stitch/resolveFromParentTypename'; +import { setErrors, setObjectSubschema } from '../stitch/proxiedResult'; +import { mergeFields } from '../stitch/mergeFields'; + +export function checkResultAndHandleErrors( + result: ExecutionResult, + context: Record, + info: GraphQLResolveInfo, + responseKey: string = getResponseKeyFromInfo(info), + subschema?: GraphQLSchema | SubschemaConfig, + returnType: GraphQLOutputType = info.returnType, + skipTypeMerging?: boolean, +): any { + const errors = result.errors != null ? result.errors : []; + const data = result.data != null ? result.data[responseKey] : undefined; + + return handleResult( + data, + errors, + subschema, + context, + info, + returnType, + skipTypeMerging, + ); +} + +export function handleResult( + result: any, + errors: ReadonlyArray, + subschema: GraphQLSchema | SubschemaConfig, + context: Record, + info: IGraphQLToolsResolveInfo, + returnType = info.returnType, + skipTypeMerging?: boolean, +): any { + const type = getNullableType(returnType); + + if (result == null) { + return handleNull(info.fieldNodes, responsePathAsArray(info.path), errors); + } + + if (isLeafType(type)) { + return type.parseValue(result); + } else if (isCompositeType(type)) { + return handleObject( + type, + result, + errors, + subschema, + context, + info, + skipTypeMerging, + ); + } else if (isListType(type)) { + return handleList( + type, + result, + errors, + subschema, + context, + info, + skipTypeMerging, + ); + } +} + +function handleList( + type: GraphQLList, + list: Array, + errors: ReadonlyArray, + subschema: GraphQLSchema | SubschemaConfig, + context: Record, + info: IGraphQLToolsResolveInfo, + skipTypeMerging?: boolean, +) { + const childErrors = getErrorsByPathSegment(errors); + + return list.map((listMember, index) => + handleListMember( + getNullableType(type.ofType), + listMember, + index, + childErrors[index] != null ? childErrors[index] : [], + subschema, + context, + info, + skipTypeMerging, + ), + ); +} + +function handleListMember( + type: GraphQLType, + listMember: any, + index: number, + errors: ReadonlyArray, + subschema: GraphQLSchema | SubschemaConfig, + context: Record, + info: IGraphQLToolsResolveInfo, + skipTypeMerging?: boolean, +): any { + if (listMember == null) { + return handleNull( + info.fieldNodes, + [...responsePathAsArray(info.path), index], + errors, + ); + } + + if (isLeafType(type)) { + return type.parseValue(listMember); + } else if (isCompositeType(type)) { + return handleObject( + type, + listMember, + errors, + subschema, + context, + info, + skipTypeMerging, + ); + } else if (isListType(type)) { + return handleList( + type, + listMember, + errors, + subschema, + context, + info, + skipTypeMerging, + ); + } +} + +export function handleObject( + type: GraphQLCompositeType, + object: any, + errors: ReadonlyArray, + subschema: GraphQLSchema | SubschemaConfig, + context: Record, + info: IGraphQLToolsResolveInfo, + skipTypeMerging?: boolean, +) { + setErrors( + object, + errors.map((error) => + relocatedError( + error, + error.nodes, + error.path != null ? error.path.slice(1) : undefined, + ), + ), + ); + + setObjectSubschema(object, subschema); + + if (skipTypeMerging || !info.mergeInfo) { + return object; + } + + const typeName = isAbstractType(type) + ? info.schema.getTypeMap()[resolveFromParentTypename(object)].name + : type.name; + const mergedTypeInfo = info.mergeInfo.mergedTypes[typeName]; + let targetSubschemas: Array; + + if (mergedTypeInfo != null) { + targetSubschemas = mergedTypeInfo.subschemas; + } + + if (!targetSubschemas) { + return object; + } + + targetSubschemas = targetSubschemas.filter((s) => s !== subschema); + if (!targetSubschemas.length) { + return object; + } + + const subFields = collectSubFields(info, object.__typename); + + const selections = getFieldsNotInSubschema( + subFields, + subschema, + mergedTypeInfo, + object.__typename, + ); + + return mergeFields( + mergedTypeInfo, + typeName, + object, + selections, + [subschema as SubschemaConfig], + targetSubschemas, + context, + info, + ); +} + +function collectSubFields(info: IGraphQLToolsResolveInfo, typeName: string) { + let subFieldNodes: Record> = Object.create(null); + const visitedFragmentNames = Object.create(null); + info.fieldNodes.forEach((fieldNode) => { + subFieldNodes = collectFields( + ({ + schema: info.schema, + variableValues: info.variableValues, + fragments: info.fragments, + } as unknown) as ExecutionContext, + info.schema.getType(typeName) as GraphQLObjectType, + fieldNode.selectionSet, + subFieldNodes, + visitedFragmentNames, + ); + }); + return subFieldNodes; +} + +function getFieldsNotInSubschema( + subFieldNodes: Record>, + subschema: GraphQLSchema | SubschemaConfig, + mergedTypeInfo: MergedTypeInfo, + typeName: string, +): Array { + const typeMap = isSubschemaConfig(subschema) + ? mergedTypeInfo.typeMaps.get(subschema) + : subschema.getTypeMap(); + const fields = (typeMap[typeName] as GraphQLObjectType).getFields(); + + const fieldsNotInSchema: Array = []; + Object.keys(subFieldNodes).forEach((responseName) => { + subFieldNodes[responseName].forEach((subFieldNode) => { + if (!fields[subFieldNode.name.value]) { + fieldsNotInSchema.push(subFieldNode); + } + }); + }); + + return fieldsNotInSchema; +} + +export function handleNull( + fieldNodes: ReadonlyArray, + path: Array, + errors: ReadonlyArray, +) { + if (errors.length) { + if (errors.some((error) => !error.path || error.path.length < 2)) { + return relocatedError(combineErrors(errors), fieldNodes, path); + } else if (errors.some((error) => typeof error.path[1] === 'string')) { + const childErrors = getErrorsByPathSegment(errors); + + const result = Object.create(null); + Object.keys(childErrors).forEach((pathSegment) => { + result[pathSegment] = handleNull( + fieldNodes, + [...path, pathSegment], + childErrors[pathSegment], + ); + }); + + return result; + } + + const childErrors = getErrorsByPathSegment(errors); + + const result: Array = []; + Object.keys(childErrors).forEach((pathSegment) => { + result.push( + handleNull( + fieldNodes, + [...path, parseInt(pathSegment, 10)], + childErrors[pathSegment], + ), + ); + }); + + return result; + } + + return null; +} diff --git a/src/delegate/createRequest.ts b/src/delegate/createRequest.ts new file mode 100644 index 00000000000..28ef8504ba9 --- /dev/null +++ b/src/delegate/createRequest.ts @@ -0,0 +1,192 @@ +import { + ArgumentNode, + FieldNode, + FragmentDefinitionNode, + Kind, + OperationDefinitionNode, + SelectionNode, + GraphQLSchema, + GraphQLObjectType, + OperationTypeNode, + typeFromAST, + NamedTypeNode, + GraphQLInputType, + GraphQLArgument, + VariableDefinitionNode, + SelectionSetNode, +} from 'graphql'; + +import { ICreateRequestFromInfo, Request, ICreateRequest } from '../Interfaces'; +import { serializeInputValue } from '../utils/index'; +import { updateArgument } from '../utils/updateArgument'; + +export function getDelegatingOperation( + parentType: GraphQLObjectType, + schema: GraphQLSchema, +): OperationTypeNode { + if (parentType === schema.getMutationType()) { + return 'mutation'; + } else if (parentType === schema.getSubscriptionType()) { + return 'subscription'; + } + + return 'query'; +} + +export function createRequestFromInfo({ + info, + operation = getDelegatingOperation(info.parentType, info.schema), + fieldName = info.fieldName, + selectionSet, + fieldNodes, +}: ICreateRequestFromInfo): Request { + return createRequest({ + sourceSchema: info.schema, + sourceParentType: info.parentType, + sourceFieldName: info.fieldName, + fragments: info.fragments, + variableDefinitions: info.operation.variableDefinitions, + variableValues: info.variableValues, + targetOperation: operation, + targetFieldName: fieldName, + selectionSet, + fieldNodes: + selectionSet != null + ? undefined + : fieldNodes != null + ? fieldNodes + : info.fieldNodes, + }); +} + +export function createRequest({ + sourceSchema, + sourceParentType, + sourceFieldName, + fragments, + variableDefinitions, + variableValues, + targetOperation, + targetFieldName, + selectionSet, + fieldNodes, +}: ICreateRequest): Request { + let argumentNodes: ReadonlyArray; + let newSelectionSet: SelectionSetNode = selectionSet; + if (!selectionSet && fieldNodes != null) { + const selections: Array = fieldNodes.reduce( + (acc, fieldNode) => + fieldNode.selectionSet != null + ? acc.concat(fieldNode.selectionSet.selections) + : acc, + [], + ); + + newSelectionSet = selections.length + ? { + kind: Kind.SELECTION_SET, + selections, + } + : undefined; + + argumentNodes = fieldNodes[0].arguments; + } else { + argumentNodes = []; + } + + const newVariables = {}; + const variableDefinitionMap = {}; + variableDefinitions.forEach((def) => { + const varName = def.variable.name.value; + variableDefinitionMap[varName] = def; + const varType = typeFromAST( + sourceSchema, + def.type as NamedTypeNode, + ) as GraphQLInputType; + newVariables[varName] = serializeInputValue( + varType, + variableValues[varName], + ); + }); + + const argumentNodeMap: Record = {}; + argumentNodes.forEach((argument: ArgumentNode) => { + argumentNodeMap[argument.name.value] = argument; + }); + + updateArgumentsWithDefaults( + sourceParentType, + sourceFieldName, + argumentNodeMap, + variableDefinitionMap, + newVariables, + ); + + const rootfieldNode: FieldNode = { + kind: Kind.FIELD, + alias: null, + arguments: Object.keys(argumentNodeMap).map( + (argName) => argumentNodeMap[argName], + ), + selectionSet: newSelectionSet, + name: { + kind: Kind.NAME, + value: targetFieldName || fieldNodes[0].name.value, + }, + }; + + const operationDefinition: OperationDefinitionNode = { + kind: Kind.OPERATION_DEFINITION, + operation: targetOperation, + variableDefinitions: Object.keys(variableDefinitionMap).map( + (varName) => variableDefinitionMap[varName], + ), + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [rootfieldNode], + }, + }; + + const fragmentDefinitions: Array = Object.keys( + fragments, + ).map((fragmentName) => fragments[fragmentName]); + + const document = { + kind: Kind.DOCUMENT, + definitions: [operationDefinition, ...fragmentDefinitions], + }; + + return { + document, + variables: newVariables, + }; +} + +function updateArgumentsWithDefaults( + sourceParentType: GraphQLObjectType, + sourceFieldName: string, + argumentNodeMap: Record, + variableDefinitionMap: Record, + variableValues: Record, +): void { + const sourceField = sourceParentType.getFields()[sourceFieldName]; + sourceField.args.forEach((argument: GraphQLArgument) => { + const argName = argument.name; + const sourceArgType = argument.type; + + if (argumentNodeMap[argName] === undefined) { + const defaultValue = argument.defaultValue; + + if (defaultValue !== undefined) { + updateArgument( + argName, + sourceArgType, + argumentNodeMap, + variableDefinitionMap, + variableValues, + serializeInputValue(sourceArgType, defaultValue), + ); + } + } + }); +} diff --git a/src/delegate/delegateToSchema.ts b/src/delegate/delegateToSchema.ts new file mode 100644 index 00000000000..0fd2387483d --- /dev/null +++ b/src/delegate/delegateToSchema.ts @@ -0,0 +1,347 @@ +import { isAsyncIterable } from 'iterall'; +import { ApolloLink, execute as executeLink } from 'apollo-link'; +import { + subscribe, + execute, + validate, + GraphQLSchema, + ExecutionResult, + GraphQLOutputType, + isSchema, +} from 'graphql'; + +import { + IDelegateToSchemaOptions, + IDelegateRequestOptions, + Fetcher, + Delegator, + SubschemaConfig, + isSubschemaConfig, + IGraphQLToolsResolveInfo, + Transform, +} from '../Interfaces'; +import { + ExpandAbstractTypes, + FilterToSchema, + AddReplacementSelectionSets, + AddReplacementFragments, + AddMergedTypeSelectionSets, + AddTypenameToAbstract, + CheckResultAndHandleErrors, + applyRequestTransforms, + applyResultTransforms, + AddArgumentsAsVariables, +} from '../wrap/index'; + +import linkToFetcher from '../stitch/linkToFetcher'; +import { observableToAsyncIterable } from '../stitch/observableToAsyncIterable'; +import mapAsyncIterator from '../stitch/mapAsyncIterator'; +import { combineErrors } from '../stitch/errors'; + +import { createRequestFromInfo, getDelegatingOperation } from './createRequest'; + +export default function delegateToSchema( + options: IDelegateToSchemaOptions | GraphQLSchema, +): any { + if (isSchema(options)) { + throw new Error( + 'Passing positional arguments to delegateToSchema is deprecated. ' + + 'Please pass named parameters instead.', + ); + } + + const { + info, + operation = getDelegatingOperation(info.parentType, info.schema), + fieldName = info.fieldName, + returnType = info.returnType, + selectionSet, + fieldNodes, + } = options; + + const request = createRequestFromInfo({ + info, + operation, + fieldName, + selectionSet, + fieldNodes, + }); + + return delegateRequest({ + ...options, + request, + operation, + fieldName, + returnType, + }); +} + +function buildDelegationTransforms( + subschemaOrSubschemaConfig: GraphQLSchema | SubschemaConfig, + info: IGraphQLToolsResolveInfo, + context: Record, + targetSchema: GraphQLSchema, + fieldName: string, + args: Record, + returnType: GraphQLOutputType, + transforms: Array, + skipTypeMerging: boolean, +): Array { + let delegationTransforms: Array = [ + new CheckResultAndHandleErrors( + info, + fieldName, + subschemaOrSubschemaConfig, + context, + returnType, + skipTypeMerging, + ), + ]; + + if (info.mergeInfo != null) { + delegationTransforms.push( + new AddReplacementSelectionSets( + info.schema, + info.mergeInfo.replacementSelectionSets, + ), + new AddMergedTypeSelectionSets(info.schema, info.mergeInfo.mergedTypes), + ); + } + + delegationTransforms = delegationTransforms.concat(transforms); + + delegationTransforms.push(new ExpandAbstractTypes(info.schema, targetSchema)); + + if (info.mergeInfo != null) { + delegationTransforms.push( + new AddReplacementFragments( + targetSchema, + info.mergeInfo.replacementFragments, + ), + ); + } + + if (args != null) { + delegationTransforms.push(new AddArgumentsAsVariables(targetSchema, args)); + } + + delegationTransforms.push( + new FilterToSchema(targetSchema), + new AddTypenameToAbstract(targetSchema), + ); + + return delegationTransforms; +} + +export function delegateRequest({ + request, + schema: subschemaOrSubschemaConfig, + rootValue, + info, + operation = getDelegatingOperation(info.parentType, info.schema), + fieldName = info.fieldName, + args, + returnType = info.returnType, + context, + transforms = [], + skipValidation, + skipTypeMerging, +}: IDelegateRequestOptions): any { + let targetSchema: GraphQLSchema; + let targetRootValue: Record; + let requestTransforms: Array = transforms.slice(); + let subschemaConfig: SubschemaConfig; + + if (isSubschemaConfig(subschemaOrSubschemaConfig)) { + subschemaConfig = subschemaOrSubschemaConfig; + targetSchema = subschemaConfig.schema; + targetRootValue = + rootValue != null + ? rootValue + : subschemaConfig.rootValue != null + ? subschemaConfig.rootValue + : info.rootValue; + if (subschemaConfig.transforms != null) { + requestTransforms = requestTransforms.concat(subschemaConfig.transforms); + } + } else { + targetSchema = subschemaOrSubschemaConfig; + targetRootValue = rootValue != null ? rootValue : info.rootValue; + } + + const delegationTransforms = buildDelegationTransforms( + subschemaOrSubschemaConfig, + info, + context, + targetSchema, + fieldName, + args, + returnType, + requestTransforms.reverse(), + skipTypeMerging, + ); + + const processedRequest = applyRequestTransforms( + request, + delegationTransforms, + ); + + if (!skipValidation) { + const errors = validate(targetSchema, processedRequest.document); + if (errors.length > 0) { + const combinedError: Error = combineErrors(errors); + throw combinedError; + } + } + + if (operation === 'query' || operation === 'mutation') { + const executor = createExecutor( + targetSchema, + targetRootValue, + context, + subschemaConfig, + ); + + const executionResult: + | ExecutionResult + | Promise = executor({ + document: processedRequest.document, + context, + variables: processedRequest.variables, + }); + + if (executionResult instanceof Promise) { + return executionResult.then((originalResult: any) => + applyResultTransforms(originalResult, delegationTransforms), + ); + } + return applyResultTransforms(executionResult, delegationTransforms); + } + + const subscriber = createSubscriber( + targetSchema, + targetRootValue, + context, + subschemaConfig, + ); + + return subscriber({ + document: processedRequest.document, + context, + variables: processedRequest.variables, + }).then( + ( + subscriptionResult: + | AsyncIterableIterator + | ExecutionResult, + ) => { + if (isAsyncIterable(subscriptionResult)) { + // "subscribe" to the subscription result and map the result through the transforms + return mapAsyncIterator( + subscriptionResult, + (result) => { + const transformedResult = applyResultTransforms( + result, + delegationTransforms, + ); + // wrap with fieldName to return for an additional round of resolutioon + // with payload as rootValue + return { + [info.fieldName]: transformedResult, + }; + }, + ); + } + + return applyResultTransforms(subscriptionResult, delegationTransforms); + }, + ); +} + +function createExecutor( + schema: GraphQLSchema, + rootValue: Record, + context: Record, + subschemaConfig?: SubschemaConfig, +): Delegator { + let fetcher: Fetcher; + let targetRootValue: Record = rootValue; + if (subschemaConfig != null) { + if (subschemaConfig.dispatcher != null) { + const dynamicLinkOrFetcher = subschemaConfig.dispatcher(context); + fetcher = + typeof dynamicLinkOrFetcher === 'function' + ? dynamicLinkOrFetcher + : linkToFetcher(dynamicLinkOrFetcher); + } else if (subschemaConfig.link != null) { + fetcher = linkToFetcher(subschemaConfig.link); + } else if (subschemaConfig.fetcher != null) { + fetcher = subschemaConfig.fetcher; + } + + if (!fetcher && !rootValue && subschemaConfig.rootValue != null) { + targetRootValue = subschemaConfig.rootValue; + } + } + + if (fetcher != null) { + return ({ document, context: graphqlContext, variables }) => + fetcher({ + query: document, + variables, + context: { graphqlContext }, + }); + } + + return ({ document, context: graphqlContext, variables }) => + execute({ + schema, + document, + rootValue: targetRootValue, + contextValue: graphqlContext, + variableValues: variables, + }); +} + +function createSubscriber( + schema: GraphQLSchema, + rootValue: Record, + context: Record, + subschemaConfig?: SubschemaConfig, +): Delegator { + let link: ApolloLink; + let targetRootValue: Record = rootValue; + + if (subschemaConfig != null) { + if (subschemaConfig.dispatcher != null) { + link = subschemaConfig.dispatcher(context) as ApolloLink; + } else if (subschemaConfig.link != null) { + link = subschemaConfig.link; + } + + if (!link && !rootValue && subschemaConfig.rootValue != null) { + targetRootValue = subschemaConfig.rootValue; + } + } + + if (link != null) { + return ({ document, context: graphqlContext, variables }) => { + const operation = { + query: document, + variables, + context: { graphqlContext }, + }; + const observable = executeLink(link, operation); + return observableToAsyncIterable(observable); + }; + } + + return ({ document, context: graphqlContext, variables }) => + subscribe({ + schema, + document, + rootValue: targetRootValue, + contextValue: graphqlContext, + variableValues: variables, + }); +} diff --git a/src/delegate/index.ts b/src/delegate/index.ts new file mode 100644 index 00000000000..9e3c80e66ff --- /dev/null +++ b/src/delegate/index.ts @@ -0,0 +1,9 @@ +import delegateToSchema, { delegateRequest } from './delegateToSchema'; +import { createRequestFromInfo, createRequest } from './createRequest'; + +export { + delegateToSchema, + createRequestFromInfo, + createRequest, + delegateRequest, +}; diff --git a/src/Logger.ts b/src/generate/Logger.ts similarity index 64% rename from src/Logger.ts rename to src/generate/Logger.ts index 3b8c420ff3a..15d4f6ead9d 100644 --- a/src/Logger.ts +++ b/src/generate/Logger.ts @@ -2,12 +2,12 @@ * A very simple class for logging errors */ -import { ILogger } from './Interfaces'; +import { ILogger } from '../Interfaces'; export class Logger implements ILogger { - public errors: Error[]; - public name: string; - private callback: Function; + public errors: Array; + public name: string | undefined; + private readonly callback: Function | undefined; constructor(name?: string, callback?: Function) { this.name = name; @@ -23,13 +23,13 @@ export class Logger implements ILogger { } } - public printOneError(e: Error) { - return e.stack; + public printOneError(e: Error): string { + return e.stack ? e.stack : ''; } public printAllErrors() { return this.errors.reduce( - (agg, e) => `${agg}\n${this.printOneError(e)}`, + (agg: string, e: Error) => `${agg}\n${this.printOneError(e)}`, '', ); } diff --git a/src/generate/addResolveFunctionsToSchema.ts b/src/generate/addResolveFunctionsToSchema.ts deleted file mode 100644 index 5e9741017af..00000000000 --- a/src/generate/addResolveFunctionsToSchema.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { SchemaError } from '.'; - -import { - GraphQLField, - GraphQLEnumType, - GraphQLScalarType, - GraphQLType, - GraphQLSchema, - GraphQLObjectType, - GraphQLInterfaceType, - GraphQLFieldMap, -} from 'graphql'; - -import { - IResolvers, - IResolverValidationOptions, - IAddResolveFunctionsToSchemaOptions, -} from '../Interfaces'; -import { applySchemaTransforms } from '../transforms/transforms'; -import { checkForResolveTypeResolver, extendResolversFromInterfaces } from '.'; -import ConvertEnumValues from '../transforms/ConvertEnumValues'; - -function addResolveFunctionsToSchema( - options: IAddResolveFunctionsToSchemaOptions | GraphQLSchema, - legacyInputResolvers?: IResolvers, - legacyInputValidationOptions?: IResolverValidationOptions, -) { - if (options instanceof GraphQLSchema) { - console.warn( - 'The addResolveFunctionsToSchema function takes named options now; see IAddResolveFunctionsToSchemaOptions', - ); - options = { - schema: options, - resolvers: legacyInputResolvers, - resolverValidationOptions: legacyInputValidationOptions, - }; - } - - const { - schema, - resolvers: inputResolvers, - resolverValidationOptions = {}, - inheritResolversFromInterfaces = false, - } = options; - - const { - allowResolversNotInSchema = false, - requireResolversForResolveType, - } = resolverValidationOptions; - - const resolvers = inheritResolversFromInterfaces - ? extendResolversFromInterfaces(schema, inputResolvers) - : inputResolvers; - - // Used to map the external value of an enum to its internal value, when - // that internal value is provided by a resolver. - const enumValueMap = Object.create(null); - - Object.keys(resolvers).forEach(typeName => { - const resolverValue = resolvers[typeName]; - const resolverType = typeof resolverValue; - - if (resolverType !== 'object' && resolverType !== 'function') { - throw new SchemaError( - `"${typeName}" defined in resolvers, but has invalid value "${resolverValue}". A resolver's value ` + - `must be of type object or function.`, - ); - } - - const type = schema.getType(typeName); - - if (!type && typeName !== '__schema') { - if (allowResolversNotInSchema) { - return; - } - - throw new SchemaError( - `"${typeName}" defined in resolvers, but not in schema`, - ); - } - - Object.keys(resolverValue).forEach(fieldName => { - if (fieldName.startsWith('__')) { - // this is for isTypeOf and resolveType and all the other stuff. - type[fieldName.substring(2)] = resolverValue[fieldName]; - return; - } - - if (type instanceof GraphQLScalarType) { - type[fieldName] = resolverValue[fieldName]; - return; - } - - if (type instanceof GraphQLEnumType) { - if (!type.getValue(fieldName)) { - if (allowResolversNotInSchema) { - return; - } - throw new SchemaError( - `${typeName}.${fieldName} was defined in resolvers, but enum is not in schema`, - ); - } - - // We've encountered an enum resolver that is being used to provide an - // internal enum value. - // Reference: https://www.apollographql.com/docs/graphql-tools/scalars.html#internal-values - // - // We're storing a map of the current enums external facing value to - // its resolver provided internal value. This map is used to transform - // the current schema to a new schema that includes enums with the new - // internal value. - enumValueMap[type.name] = enumValueMap[type.name] || {}; - enumValueMap[type.name][fieldName] = resolverValue[fieldName]; - return; - } - - // object type - const fields = getFieldsForType(type); - if (!fields) { - if (allowResolversNotInSchema) { - return; - } - - throw new SchemaError( - `${typeName} was defined in resolvers, but it's not an object`, - ); - } - - if (!fields[fieldName]) { - if (allowResolversNotInSchema) { - return; - } - - throw new SchemaError( - `${typeName}.${fieldName} defined in resolvers, but not in schema`, - ); - } - const field = fields[fieldName]; - const fieldResolve = resolverValue[fieldName]; - if (typeof fieldResolve === 'function') { - // for convenience. Allows shorter syntax in resolver definition file - setFieldProperties(field, { resolve: fieldResolve }); - } else { - if (typeof fieldResolve !== 'object') { - throw new SchemaError( - `Resolver ${typeName}.${fieldName} must be object or function`, - ); - } - setFieldProperties(field, fieldResolve); - } - }); - }); - - checkForResolveTypeResolver(schema, requireResolversForResolveType); - - // If there are any enum resolver functions (that are used to return - // internal enum values), create a new schema that includes enums with the - // new internal facing values. - const updatedSchema = applySchemaTransforms(schema, [ - new ConvertEnumValues(enumValueMap), - ]); - - return updatedSchema; -} - -function getFieldsForType(type: GraphQLType): GraphQLFieldMap { - if ( - type instanceof GraphQLObjectType || - type instanceof GraphQLInterfaceType - ) { - return type.getFields(); - } else { - return undefined; - } -} - -function setFieldProperties( - field: GraphQLField, - propertiesObj: Object, -) { - Object.keys(propertiesObj).forEach(propertyName => { - field[propertyName] = propertiesObj[propertyName]; - }); -} - -export default addResolveFunctionsToSchema; diff --git a/src/generate/addResolversToSchema.ts b/src/generate/addResolversToSchema.ts new file mode 100644 index 00000000000..b61a516c947 --- /dev/null +++ b/src/generate/addResolversToSchema.ts @@ -0,0 +1,213 @@ +import { + GraphQLField, + GraphQLEnumType, + GraphQLSchema, + isSchema, + isScalarType, + isEnumType, + isUnionType, + isInterfaceType, + isObjectType, +} from 'graphql'; + +import { + IResolvers, + IResolverValidationOptions, + IAddResolversToSchemaOptions, +} from '../Interfaces'; +import { + parseInputValue, + serializeInputValue, + healSchema, + forEachField, + forEachDefaultValue, +} from '../utils/index'; +import { toConfig } from '../polyfills/index'; + +import SchemaError from './SchemaError'; +import checkForResolveTypeResolver from './checkForResolveTypeResolver'; +import extendResolversFromInterfaces from './extendResolversFromInterfaces'; + +function addResolversToSchema( + schemaOrOptions: GraphQLSchema | IAddResolversToSchemaOptions, + legacyInputResolvers?: IResolvers, + legacyInputValidationOptions?: IResolverValidationOptions, +): GraphQLSchema { + const options: IAddResolversToSchemaOptions = isSchema(schemaOrOptions) + ? { + schema: schemaOrOptions, + resolvers: legacyInputResolvers, + resolverValidationOptions: legacyInputValidationOptions, + } + : schemaOrOptions; + + const { + schema, + resolvers: inputResolvers, + defaultFieldResolver, + resolverValidationOptions = {}, + inheritResolversFromInterfaces = false, + } = options; + + const { + allowResolversNotInSchema = false, + requireResolversForResolveType, + } = resolverValidationOptions; + + const resolvers = inheritResolversFromInterfaces + ? extendResolversFromInterfaces(schema, inputResolvers) + : inputResolvers; + + const typeMap = schema.getTypeMap(); + + Object.keys(resolvers).forEach((typeName) => { + const resolverValue = resolvers[typeName]; + const resolverType = typeof resolverValue; + + if (resolverType !== 'object' && resolverType !== 'function') { + throw new SchemaError( + `"${typeName}" defined in resolvers, but has invalid value "${ + resolverValue as string + }". A resolver's value must be of type object or function.`, + ); + } + + const type = schema.getType(typeName); + + if (!type && typeName !== '__schema') { + if (allowResolversNotInSchema) { + return; + } + + throw new SchemaError( + `"${typeName}" defined in resolvers, but not in schema`, + ); + } + + if (isScalarType(type)) { + // Support -- without recommending -- overriding default scalar types + Object.keys(resolverValue).forEach((fieldName) => { + if (fieldName.startsWith('__')) { + type[fieldName.substring(2)] = resolverValue[fieldName]; + } else { + type[fieldName] = resolverValue[fieldName]; + } + }); + } else if (isEnumType(type)) { + // We've encountered an enum resolver that is being used to provide an + // internal enum value. + // Reference: https://www.apollographql.com/docs/graphql-tools/scalars.html#internal-values + Object.keys(resolverValue).forEach((fieldName) => { + if (!type.getValue(fieldName)) { + if (allowResolversNotInSchema) { + return; + } + throw new SchemaError( + `${typeName}.${fieldName} was defined in resolvers, but enum is not in schema`, + ); + } + }); + + const config = toConfig(type); + + const values = type.getValues(); + const newValues = {}; + values.forEach((value) => { + const newValue = Object.keys(resolverValue).includes(value.name) + ? resolverValue[value.name] + : value.name; + newValues[value.name] = { + value: newValue, + deprecationReason: value.deprecationReason, + description: value.description, + astNode: value.astNode, + }; + }); + + // healSchema called later to update all fields to new type + typeMap[typeName] = new GraphQLEnumType({ + ...config, + values: newValues, + }); + } else if (isUnionType(type)) { + Object.keys(resolverValue).forEach((fieldName) => { + if (fieldName.startsWith('__')) { + // this is for isTypeOf and resolveType and all the other stuff. + type[fieldName.substring(2)] = resolverValue[fieldName]; + return; + } + if (allowResolversNotInSchema) { + return; + } + + throw new SchemaError( + `${typeName} was defined in resolvers, but it's not an object`, + ); + }); + } else if (isObjectType(type) || isInterfaceType(type)) { + Object.keys(resolverValue).forEach((fieldName) => { + if (fieldName.startsWith('__')) { + // this is for isTypeOf and resolveType and all the other stuff. + type[fieldName.substring(2)] = resolverValue[fieldName]; + return; + } + + const fields = type.getFields(); + const field = fields[fieldName]; + + if (field == null) { + if (allowResolversNotInSchema) { + return; + } + + throw new SchemaError( + `${typeName}.${fieldName} defined in resolvers, but not in schema`, + ); + } + + const fieldResolve = resolverValue[fieldName]; + if (typeof fieldResolve === 'function') { + // for convenience. Allows shorter syntax in resolver definition file + field.resolve = fieldResolve; + } else { + if (typeof fieldResolve !== 'object') { + throw new SchemaError( + `Resolver ${typeName}.${fieldName} must be object or function`, + ); + } + setFieldProperties(field, fieldResolve); + } + }); + } + }); + + checkForResolveTypeResolver(schema, requireResolversForResolveType); + + // serialize all default values prior to healing fields with new scalar/enum types. + forEachDefaultValue(schema, serializeInputValue); + // schema may have new scalar/enum types that require healing + healSchema(schema); + // reparse all default values with new parsing functions. + forEachDefaultValue(schema, parseInputValue); + + if (defaultFieldResolver != null) { + forEachField(schema, (field) => { + if (!field.resolve) { + field.resolve = defaultFieldResolver; + } + }); + } + + return schema; +} + +function setFieldProperties( + field: GraphQLField, + propertiesObj: Record, +) { + Object.keys(propertiesObj).forEach((propertyName) => { + field[propertyName] = propertiesObj[propertyName]; + }); +} + +export default addResolversToSchema; diff --git a/src/generate/addSchemaLevelResolveFunction.ts b/src/generate/addSchemaLevelResolver.ts similarity index 56% rename from src/generate/addSchemaLevelResolveFunction.ts rename to src/generate/addSchemaLevelResolver.ts index 048c7e0770e..b77bb5033e4 100644 --- a/src/generate/addSchemaLevelResolveFunction.ts +++ b/src/generate/addSchemaLevelResolver.ts @@ -4,9 +4,9 @@ import { GraphQLFieldResolver, } from 'graphql'; -// wraps all resolve functions of query, mutation or subscription fields -// with the provided function to simulate a root schema level resolve funciton -function addSchemaLevelResolveFunction( +// wraps all resolvers of query, mutation or subscription fields +// with the provided function to simulate a root schema level resolver +function addSchemaLevelResolver( schema: GraphQLSchema, fn: GraphQLFieldResolver, ): void { @@ -15,24 +15,29 @@ function addSchemaLevelResolveFunction( schema.getQueryType(), schema.getMutationType(), schema.getSubscriptionType(), - ].filter(x => !!x); - rootTypes.forEach(type => { - // XXX this should run at most once per request to simulate a true root resolver - // for graphql-js this is an approximation that works with queries but not mutations - const rootResolveFn = runAtMostOncePerRequest(fn); - const fields = type.getFields(); - Object.keys(fields).forEach(fieldName => { - // XXX if the type is a subscription, a same query AST will be ran multiple times so we - // deactivate here the runOnce if it's a subscription. This may not be optimal though... - if (type === schema.getSubscriptionType()) { - fields[fieldName].resolve = wrapResolver(fields[fieldName].resolve, fn); - } else { - fields[fieldName].resolve = wrapResolver( - fields[fieldName].resolve, - rootResolveFn, - ); - } - }); + ].filter((x) => Boolean(x)); + rootTypes.forEach((type) => { + if (type != null) { + // XXX this should run at most once per request to simulate a true root resolver + // for graphql-js this is an approximation that works with queries but not mutations + const rootResolveFn = runAtMostOncePerRequest(fn); + const fields = type.getFields(); + Object.keys(fields).forEach((fieldName) => { + // XXX if the type is a subscription, a same query AST will be ran multiple times so we + // deactivate here the runOnce if it's a subscription. This may not be optimal though... + if (type === schema.getSubscriptionType()) { + fields[fieldName].resolve = wrapResolver( + fields[fieldName].resolve, + fn, + ); + } else { + fields[fieldName].resolve = wrapResolver( + fields[fieldName].resolve, + rootResolveFn, + ); + } + }); + } }); } @@ -41,14 +46,13 @@ function wrapResolver( innerResolver: GraphQLFieldResolver | undefined, outerResolver: GraphQLFieldResolver, ): GraphQLFieldResolver { - return (obj, args, ctx, info) => { - return Promise.resolve(outerResolver(obj, args, ctx, info)).then(root => { - if (innerResolver) { + return (obj, args, ctx, info) => + Promise.resolve(outerResolver(obj, args, ctx, info)).then((root) => { + if (innerResolver != null) { return innerResolver(root, args, ctx, info); } return defaultFieldResolver(root, args, ctx, info); }); - }; } // XXX this function only works for resolvers @@ -74,4 +78,4 @@ function runAtMostOncePerRequest( }; } -export default addSchemaLevelResolveFunction; +export default addSchemaLevelResolver; diff --git a/src/generate/assertResolveFunctionsPresent.ts b/src/generate/assertResolversPresent.ts similarity index 61% rename from src/generate/assertResolveFunctionsPresent.ts rename to src/generate/assertResolversPresent.ts index 013a2187790..3d579260a9d 100644 --- a/src/generate/assertResolveFunctionsPresent.ts +++ b/src/generate/assertResolversPresent.ts @@ -2,16 +2,18 @@ import { GraphQLSchema, GraphQLField, getNamedType, - GraphQLScalarType, + isScalarType, } from 'graphql'; + import { IResolverValidationOptions } from '../Interfaces'; +import { forEachField } from '../utils/index'; -import { forEachField, SchemaError } from '.'; +import SchemaError from './SchemaError'; -function assertResolveFunctionsPresent( +function assertResolversPresent( schema: GraphQLSchema, resolverValidationOptions: IResolverValidationOptions = {}, -) { +): void { const { requireResolversForArgs = false, requireResolversForNonScalar = false, @@ -30,35 +32,35 @@ function assertResolveFunctionsPresent( } forEachField(schema, (field, typeName, fieldName) => { - // requires a resolve function for *every* field. + // requires a resolver for *every* field. if (requireResolversForAllFields) { - expectResolveFunction(field, typeName, fieldName); + expectResolver(field, typeName, fieldName); } - // requires a resolve function on every field that has arguments + // requires a resolver on every field that has arguments if (requireResolversForArgs && field.args.length > 0) { - expectResolveFunction(field, typeName, fieldName); + expectResolver(field, typeName, fieldName); } - // requires a resolve function on every field that returns a non-scalar type + // requires a resolver on every field that returns a non-scalar type if ( requireResolversForNonScalar && - !(getNamedType(field.type) instanceof GraphQLScalarType) + !isScalarType(getNamedType(field.type)) ) { - expectResolveFunction(field, typeName, fieldName); + expectResolver(field, typeName, fieldName); } }); } -function expectResolveFunction( +function expectResolver( field: GraphQLField, typeName: string, fieldName: string, ) { if (!field.resolve) { + // eslint-disable-next-line no-console console.warn( - // tslint:disable-next-line: max-line-length - `Resolve function missing for "${typeName}.${fieldName}". To disable this warning check https://github.com/apollostack/graphql-tools/issues/131`, + `Resolver missing for "${typeName}.${fieldName}". To disable this warning check https://github.com/apollostack/graphql-tools/issues/131`, ); return; } @@ -69,4 +71,4 @@ function expectResolveFunction( } } -export default assertResolveFunctionsPresent; +export default assertResolversPresent; diff --git a/src/generate/attachConnectorsToContext.ts b/src/generate/attachConnectorsToContext.ts index a6274cff3c6..668f1b6c8e8 100644 --- a/src/generate/attachConnectorsToContext.ts +++ b/src/generate/attachConnectorsToContext.ts @@ -1,10 +1,9 @@ -import { GraphQLSchema, GraphQLFieldResolver } from 'graphql'; - import { deprecated } from 'deprecated-decorator'; +import { GraphQLSchema, GraphQLFieldResolver, isSchema } from 'graphql'; import { IConnectors, IConnector, IConnectorCls } from '../Interfaces'; -import { addSchemaLevelResolveFunction } from '.'; +import addSchemaLevelResolver from './addSchemaLevelResolver'; // takes a GraphQL-JS schema and an object of connectors, then attaches // the connectors to the context by wrapping each query or mutation resolve @@ -15,8 +14,8 @@ const attachConnectorsToContext = deprecated( version: '0.7.0', url: 'https://github.com/apollostack/graphql-tools/issues/140', }, - function(schema: GraphQLSchema, connectors: IConnectors): void { - if (!schema || !(schema instanceof GraphQLSchema)) { + (schema: GraphQLSchema, connectors: IConnectors): void => { + if (!schema || !isSchema(schema)) { throw new Error( 'schema must be an instance of GraphQLSchema. ' + 'This error could be caused by installing more than one version of GraphQL-JS', @@ -42,9 +41,9 @@ const attachConnectorsToContext = deprecated( } schema['_apolloConnectorsAttached'] = true; const attachconnectorFn: GraphQLFieldResolver = ( - root: any, - args: { [key: string]: any }, - ctx: any, + root, + _args, + ctx, ) => { if (typeof ctx !== 'object') { // if in any way possible, we should throw an error when the attachconnectors @@ -57,17 +56,17 @@ const attachConnectorsToContext = deprecated( if (typeof ctx.connectors === 'undefined') { ctx.connectors = {}; } - Object.keys(connectors).forEach(connectorName => { - let connector: IConnector = connectors[connectorName]; - if (!!connector.prototype) { - ctx.connectors[connectorName] = new (connector)(ctx); + Object.keys(connectors).forEach((connectorName) => { + const connector: IConnector = connectors[connectorName]; + if (connector.prototype != null) { + ctx.connectors[connectorName] = new (connector as IConnectorCls)(ctx); } else { - throw new Error(`Connector must be a function or an class`); + throw new Error('Connector must be a function or an class'); } }); return root; }; - addSchemaLevelResolveFunction(schema, attachconnectorFn); + addSchemaLevelResolver(schema, attachconnectorFn); }, ); diff --git a/src/generate/attachDirectiveResolvers.ts b/src/generate/attachDirectiveResolvers.ts index 3b6a3a50bad..fbe6ae73c15 100644 --- a/src/generate/attachDirectiveResolvers.ts +++ b/src/generate/attachDirectiveResolvers.ts @@ -1,10 +1,11 @@ import { GraphQLSchema, GraphQLField, defaultFieldResolver } from 'graphql'; + import { IDirectiveResolvers } from '../Interfaces'; -import { SchemaDirectiveVisitor } from '../schemaVisitor'; +import { SchemaDirectiveVisitor } from '../utils/SchemaDirectiveVisitor'; function attachDirectiveResolvers( schema: GraphQLSchema, - directiveResolvers: IDirectiveResolvers, + directiveResolvers: IDirectiveResolvers, ) { if (typeof directiveResolvers !== 'object') { throw new Error( @@ -20,16 +21,24 @@ function attachDirectiveResolvers( const schemaDirectives = Object.create(null); - Object.keys(directiveResolvers).forEach(directiveName => { + Object.keys(directiveResolvers).forEach((directiveName) => { schemaDirectives[directiveName] = class extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const resolver = directiveResolvers[directiveName]; - const originalResolver = field.resolve || defaultFieldResolver; + const originalResolver = + field.resolve != null ? field.resolve : defaultFieldResolver; const directiveArgs = this.args; - field.resolve = (...args: any[]) => { + field.resolve = (...args) => { const [source /* original args */, , context, info] = args; return resolver( - async () => originalResolver.apply(field, args), + () => + new Promise((resolve, reject) => { + const result = originalResolver.apply(field, args); + if (result instanceof Error) { + reject(result); + } + resolve(result); + }), source, directiveArgs, context, diff --git a/src/generate/buildSchemaFromTypeDefinitions.ts b/src/generate/buildSchemaFromTypeDefinitions.ts index a122a993365..4493a002f49 100644 --- a/src/generate/buildSchemaFromTypeDefinitions.ts +++ b/src/generate/buildSchemaFromTypeDefinitions.ts @@ -4,15 +4,17 @@ import { buildASTSchema, GraphQLSchema, DocumentNode, + ASTNode, } from 'graphql'; + import { ITypeDefinitions, GraphQLParseOptions } from '../Interfaces'; import { extractExtensionDefinitions, - concatenateTypeDefs, - SchemaError, -} from '.'; -import filterExtensionDefinitions from './filterExtensionDefinitions'; + filterExtensionDefinitions, +} from './extensionDefinitions'; +import concatenateTypeDefs from './concatenateTypeDefs'; +import SchemaError from './SchemaError'; function buildSchemaFromTypeDefinitions( typeDefinitions: ITypeDefinitions, @@ -38,19 +40,14 @@ function buildSchemaFromTypeDefinitions( astDocument = parse(myDefinitions, parseOptions); } - const backcompatOptions = { commentDescriptions: true }; const typesAst = filterExtensionDefinitions(astDocument); - // TODO fix types https://github.com/apollographql/graphql-tools/issues/542 - let schema: GraphQLSchema = (buildASTSchema as any)( - typesAst, - backcompatOptions, - ); + const backcompatOptions = { commentDescriptions: true }; + let schema: GraphQLSchema = buildASTSchema(typesAst, backcompatOptions); const extensionsAst = extractExtensionDefinitions(astDocument); if (extensionsAst.definitions.length > 0) { - // TODO fix types https://github.com/apollographql/graphql-tools/issues/542 - schema = (extendSchema as any)(schema, extensionsAst, backcompatOptions); + schema = extendSchema(schema, extensionsAst, backcompatOptions); } return schema; @@ -59,7 +56,7 @@ function buildSchemaFromTypeDefinitions( function isDocumentNode( typeDefinitions: ITypeDefinitions, ): typeDefinitions is DocumentNode { - return (typeDefinitions).kind !== undefined; + return (typeDefinitions as ASTNode).kind !== undefined; } export default buildSchemaFromTypeDefinitions; diff --git a/src/generate/chainResolvers.ts b/src/generate/chainResolvers.ts index 88d8d44def6..dd5cdfc30c2 100644 --- a/src/generate/chainResolvers.ts +++ b/src/generate/chainResolvers.ts @@ -1,13 +1,23 @@ -import { defaultFieldResolver, GraphQLResolveInfo, GraphQLFieldResolver } from 'graphql'; +import { + defaultFieldResolver, + GraphQLResolveInfo, + GraphQLFieldResolver, +} from 'graphql'; -export function chainResolvers(resolvers: GraphQLFieldResolver[]) { - return (root: any, args: { [argName: string]: any }, ctx: any, info: GraphQLResolveInfo) => { - return resolvers.reduce((prev, curResolver) => { - if (curResolver) { +export function chainResolvers( + resolvers: Array>, +) { + return ( + root: any, + args: { [argName: string]: any }, + ctx: any, + info: GraphQLResolveInfo, + ) => + resolvers.reduce((prev, curResolver) => { + if (curResolver != null) { return curResolver(prev, args, ctx, info); } return defaultFieldResolver(prev, args, ctx, info); }, root); - }; } diff --git a/src/generate/checkForResolveTypeResolver.ts b/src/generate/checkForResolveTypeResolver.ts index 139cbde349d..cf6f15758b3 100644 --- a/src/generate/checkForResolveTypeResolver.ts +++ b/src/generate/checkForResolveTypeResolver.ts @@ -1,6 +1,11 @@ -import { GraphQLInterfaceType, GraphQLUnionType, GraphQLSchema } from 'graphql'; +import { + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLSchema, + isAbstractType, +} from 'graphql'; -import { SchemaError } from '.'; +import SchemaError from './SchemaError'; // If we have any union or interface types throw if no there is no resolveType or isTypeOf resolvers function checkForResolveTypeResolver( @@ -8,31 +13,18 @@ function checkForResolveTypeResolver( requireResolversForResolveType?: boolean, ) { Object.keys(schema.getTypeMap()) - .map(typeName => schema.getType(typeName)) + .map((typeName) => schema.getType(typeName)) .forEach((type: GraphQLUnionType | GraphQLInterfaceType) => { - if ( - !( - type instanceof GraphQLUnionType || - type instanceof GraphQLInterfaceType - ) - ) { + if (!isAbstractType(type)) { return; } if (!type.resolveType) { - if (requireResolversForResolveType === false) { + if (!requireResolversForResolveType) { return; } - if (requireResolversForResolveType === true) { - throw new SchemaError( - `Type "${type.name}" is missing a "resolveType" resolver`, - ); - } - // tslint:disable-next-line:max-line-length - console.warn( - `Type "${ - type.name - }" is missing a "__resolveType" resolver. Pass false into ` + - `"resolverValidationOptions.requireResolversForResolveType" to disable this warning.`, + throw new SchemaError( + `Type "${type.name}" is missing a "__resolveType" resolver. Pass false into ` + + '"resolverValidationOptions.requireResolversForResolveType" to disable this error.', ); } }); diff --git a/src/generate/concatenateTypeDefs.ts b/src/generate/concatenateTypeDefs.ts index c72784fc976..31863b5f738 100644 --- a/src/generate/concatenateTypeDefs.ts +++ b/src/generate/concatenateTypeDefs.ts @@ -1,18 +1,15 @@ -import { print, DocumentNode, ASTNode } from 'graphql'; +import { print, ASTNode } from 'graphql'; + import { ITypedef } from '../Interfaces'; -import { SchemaError } from '.'; +import SchemaError from './SchemaError'; function concatenateTypeDefs( - typeDefinitionsAry: ITypedef[], + typeDefinitionsAry: Array, calledFunctionRefs = [] as any, ): string { - let resolvedTypeDefinitions: string[] = []; + let resolvedTypeDefinitions: Array = []; typeDefinitionsAry.forEach((typeDef: ITypedef) => { - if ((typeDef).kind !== undefined) { - typeDef = print(typeDef as ASTNode); - } - if (typeof typeDef === 'function') { if (calledFunctionRefs.indexOf(typeDef) === -1) { calledFunctionRefs.push(typeDef); @@ -22,6 +19,8 @@ function concatenateTypeDefs( } } else if (typeof typeDef === 'string') { resolvedTypeDefinitions.push(typeDef.trim()); + } else if ((typeDef as ASTNode).kind !== undefined) { + resolvedTypeDefinitions.push(print(typeDef).trim()); } else { const type = typeof typeDef; throw new SchemaError( @@ -29,15 +28,17 @@ function concatenateTypeDefs( ); } }); - return uniq(resolvedTypeDefinitions.map(x => x.trim())).join('\n'); + return uniq(resolvedTypeDefinitions.map((x) => x.trim())).join('\n'); } function uniq(array: Array): Array { - return array.reduce((accumulator, currentValue) => { - return accumulator.indexOf(currentValue) === -1 - ? [...accumulator, currentValue] - : accumulator; - }, []); + return array.reduce( + (accumulator, currentValue) => + accumulator.indexOf(currentValue) === -1 + ? [...accumulator, currentValue] + : accumulator, + [], + ); } export default concatenateTypeDefs; diff --git a/src/generate/decorateWithLogger.ts b/src/generate/decorateWithLogger.ts index eedf45264fe..7f48e0f8f34 100644 --- a/src/generate/decorateWithLogger.ts +++ b/src/generate/decorateWithLogger.ts @@ -1,4 +1,5 @@ import { defaultFieldResolver, GraphQLFieldResolver } from 'graphql'; + import { ILogger } from '../Interfaces'; /* @@ -7,13 +8,11 @@ import { ILogger } from '../Interfaces'; * hint: an optional hint to add to the error's message */ function decorateWithLogger( - fn: GraphQLFieldResolver | undefined, + fn: GraphQLFieldResolver, logger: ILogger, hint: string, ): GraphQLFieldResolver { - if (typeof fn === 'undefined') { - fn = defaultFieldResolver; - } + const resolver = fn != null ? fn : defaultFieldResolver; const logError = (e: Error) => { // TODO: clone the error properly @@ -29,8 +28,8 @@ function decorateWithLogger( return (root, args, ctx, info) => { try { - const result = fn(root, args, ctx, info); - // If the resolve function returns a Promise log any Promise rejects. + const result = resolver(root, args, ctx, info); + // If the resolver returns a Promise log any Promise rejects. if ( result && typeof result.then === 'function' && diff --git a/src/generate/extendResolversFromInterfaces.ts b/src/generate/extendResolversFromInterfaces.ts index aee6d0efb5c..20665eb4318 100644 --- a/src/generate/extendResolversFromInterfaces.ts +++ b/src/generate/extendResolversFromInterfaces.ts @@ -1,6 +1,12 @@ -import { GraphQLObjectType, GraphQLSchema } from 'graphql'; +import { + GraphQLObjectType, + GraphQLSchema, + isObjectType, + isInterfaceType, +} from 'graphql'; import { IResolvers } from '../Interfaces'; +import { graphqlVersion } from '../utils/index'; function extendResolversFromInterfaces( schema: GraphQLSchema, @@ -12,22 +18,23 @@ function extendResolversFromInterfaces( }); const extendedResolvers: IResolvers = {}; - typeNames.forEach(typeName => { + typeNames.forEach((typeName) => { const typeResolvers = resolvers[typeName]; const type = schema.getType(typeName); - if (type instanceof GraphQLObjectType) { - const interfaceResolvers = type + if ( + isObjectType(type) || + (graphqlVersion() >= 15 && isInterfaceType(type)) + ) { + const interfaceResolvers = (type as GraphQLObjectType) .getInterfaces() - .map(iFace => resolvers[iFace.name]); + .map((iFace) => resolvers[iFace.name]); extendedResolvers[typeName] = Object.assign( {}, ...interfaceResolvers, typeResolvers, ); - } else { - if (typeResolvers) { - extendedResolvers[typeName] = typeResolvers; - } + } else if (typeResolvers != null) { + extendedResolvers[typeName] = typeResolvers; } }); diff --git a/src/generate/extensionDefinitions.ts b/src/generate/extensionDefinitions.ts new file mode 100644 index 00000000000..5adce4a7288 --- /dev/null +++ b/src/generate/extensionDefinitions.ts @@ -0,0 +1,39 @@ +import { DocumentNode, DefinitionNode, Kind } from 'graphql'; + +import { graphqlVersion } from '../utils/index'; + +export function extractExtensionDefinitions(ast: DocumentNode) { + const extensionDefs = ast.definitions.filter( + (def: DefinitionNode) => + def.kind === Kind.OBJECT_TYPE_EXTENSION || + (graphqlVersion() >= 13 && def.kind === Kind.INTERFACE_TYPE_EXTENSION) || + def.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION || + def.kind === Kind.UNION_TYPE_EXTENSION || + def.kind === Kind.ENUM_TYPE_EXTENSION || + def.kind === Kind.SCALAR_TYPE_EXTENSION || + def.kind === Kind.SCHEMA_EXTENSION, + ); + + return { + ...ast, + definitions: extensionDefs, + }; +} + +export function filterExtensionDefinitions(ast: DocumentNode) { + const extensionDefs = ast.definitions.filter( + (def: DefinitionNode) => + def.kind !== Kind.OBJECT_TYPE_EXTENSION && + def.kind !== Kind.INTERFACE_TYPE_EXTENSION && + def.kind !== Kind.INPUT_OBJECT_TYPE_EXTENSION && + def.kind !== Kind.UNION_TYPE_EXTENSION && + def.kind !== Kind.ENUM_TYPE_EXTENSION && + def.kind !== Kind.SCALAR_TYPE_EXTENSION && + def.kind !== Kind.SCHEMA_EXTENSION, + ); + + return { + ...ast, + definitions: extensionDefs, + }; +} diff --git a/src/generate/extractExtensionDefinitions.ts b/src/generate/extractExtensionDefinitions.ts deleted file mode 100644 index 69b8d072a5d..00000000000 --- a/src/generate/extractExtensionDefinitions.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { DocumentNode, DefinitionNode } from 'graphql'; - -const newExtensionDefinitionKind = 'ObjectTypeExtension'; -const interfaceExtensionDefinitionKind = 'InterfaceTypeExtension'; -const inputObjectExtensionDefinitionKind = 'InputObjectTypeExtension'; -const unionExtensionDefinitionKind = 'UnionTypeExtension'; -const enumExtensionDefinitionKind = 'EnumTypeExtension'; - -export default function extractExtensionDefinitions(ast: DocumentNode) { - const extensionDefs = ast.definitions.filter( - (def: DefinitionNode) => - (def.kind as any) === newExtensionDefinitionKind || - (def.kind as any) === interfaceExtensionDefinitionKind || - (def.kind as any) === inputObjectExtensionDefinitionKind || - (def.kind as any) === unionExtensionDefinitionKind || - (def.kind as any) === enumExtensionDefinitionKind, - ); - - return Object.assign({}, ast, { - definitions: extensionDefs, - }); -} diff --git a/src/generate/filterExtensionDefinitions.ts b/src/generate/filterExtensionDefinitions.ts deleted file mode 100644 index e53a43a0640..00000000000 --- a/src/generate/filterExtensionDefinitions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { DocumentNode, DefinitionNode, Kind } from 'graphql'; - -export default function filterExtensionDefinitions(ast: DocumentNode) { - const extensionDefs = ast.definitions.filter( - (def: DefinitionNode) => - def.kind !== Kind.OBJECT_TYPE_EXTENSION && - def.kind !== Kind.INTERFACE_TYPE_EXTENSION && - def.kind !== Kind.INPUT_OBJECT_TYPE_EXTENSION && - def.kind !== Kind.UNION_TYPE_EXTENSION && - def.kind !== Kind.ENUM_TYPE_EXTENSION && - def.kind !== Kind.SCALAR_TYPE_EXTENSION && - def.kind !== Kind.SCHEMA_EXTENSION, - ); - - return { - ...ast, - definitions: extensionDefs, - }; -} diff --git a/src/generate/forEachField.ts b/src/generate/forEachField.ts deleted file mode 100644 index f48da53a4b7..00000000000 --- a/src/generate/forEachField.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getNamedType, GraphQLObjectType, GraphQLSchema } from 'graphql'; -import { IFieldIteratorFn } from '../Interfaces'; - -function forEachField(schema: GraphQLSchema, fn: IFieldIteratorFn): void { - const typeMap = schema.getTypeMap(); - Object.keys(typeMap).forEach(typeName => { - const type = typeMap[typeName]; - - // TODO: maybe have an option to include these? - if ( - !getNamedType(type).name.startsWith('__') && - type instanceof GraphQLObjectType - ) { - const fields = type.getFields(); - Object.keys(fields).forEach(fieldName => { - const field = fields[fieldName]; - fn(field, typeName, fieldName); - }); - } - }); -} - -export default forEachField; diff --git a/src/generate/index.ts b/src/generate/index.ts index 1cabbca287d..e32f284566b 100644 --- a/src/generate/index.ts +++ b/src/generate/index.ts @@ -1,6 +1,16 @@ -export { default as addResolveFunctionsToSchema } from './addResolveFunctionsToSchema'; -export { default as addSchemaLevelResolveFunction } from './addSchemaLevelResolveFunction'; -export { default as assertResolveFunctionsPresent } from './assertResolveFunctionsPresent'; +import { GraphQLSchema, GraphQLFieldResolver } from 'graphql'; + +import { + IAddResolversToSchemaOptions, + IResolvers, + IResolverValidationOptions, +} from '../Interfaces'; + +import addResolversToSchema from './addResolversToSchema'; +import addSchemaLevelResolver from './addSchemaLevelResolver'; +import assertResolversPresent from './assertResolversPresent'; + +export { addResolversToSchema, addSchemaLevelResolver, assertResolversPresent }; export { default as attachDirectiveResolvers } from './attachDirectiveResolvers'; export { default as attachConnectorsToContext } from './attachConnectorsToContext'; export { default as buildSchemaFromTypeDefinitions } from './buildSchemaFromTypeDefinitions'; @@ -9,6 +19,38 @@ export { default as checkForResolveTypeResolver } from './checkForResolveTypeRes export { default as concatenateTypeDefs } from './concatenateTypeDefs'; export { default as decorateWithLogger } from './decorateWithLogger'; export { default as extendResolversFromInterfaces } from './extendResolversFromInterfaces'; -export { default as extractExtensionDefinitions } from './extractExtensionDefinitions'; -export { default as forEachField } from './forEachField'; +export { + extractExtensionDefinitions, + filterExtensionDefinitions, +} from './extensionDefinitions'; export { default as SchemaError } from './SchemaError'; +export * from './makeExecutableSchema'; + +// These functions are preserved for backwards compatibility. +// They are not simply rexported with new (old) names so as to allow +// typedoc to annotate them. +export function addResolveFunctionsToSchema( + schemaOrOptions: GraphQLSchema | IAddResolversToSchemaOptions, + legacyInputResolvers?: IResolvers, + legacyInputValidationOptions?: IResolverValidationOptions, +): GraphQLSchema { + return addResolversToSchema( + schemaOrOptions, + legacyInputResolvers, + legacyInputValidationOptions, + ); +} + +export function addSchemaLevelResolveFunction( + schema: GraphQLSchema, + fn: GraphQLFieldResolver, +): void { + addSchemaLevelResolver(schema, fn); +} + +export function assertResolveFunctionsPresent( + schema: GraphQLSchema, + resolverValidationOptions: IResolverValidationOptions = {}, +): void { + assertResolversPresent(schema, resolverValidationOptions); +} diff --git a/src/makeExecutableSchema.ts b/src/generate/makeExecutableSchema.ts similarity index 56% rename from src/makeExecutableSchema.ts rename to src/generate/makeExecutableSchema.ts index ab9f3debed3..e8188e37795 100644 --- a/src/makeExecutableSchema.ts +++ b/src/generate/makeExecutableSchema.ts @@ -1,21 +1,24 @@ -import { defaultFieldResolver, GraphQLSchema, GraphQLFieldResolver } from 'graphql'; - -import { IExecutableSchemaDefinition, ILogger } from './Interfaces'; - -import { SchemaDirectiveVisitor } from './schemaVisitor'; -import mergeDeep from './mergeDeep'; +import { + defaultFieldResolver, + GraphQLSchema, + GraphQLFieldResolver, +} from 'graphql'; +import { IExecutableSchemaDefinition, ILogger } from '../Interfaces'; import { - attachDirectiveResolvers, - assertResolveFunctionsPresent, - addResolveFunctionsToSchema, - attachConnectorsToContext, - addSchemaLevelResolveFunction, - buildSchemaFromTypeDefinitions, - decorateWithLogger, + SchemaDirectiveVisitor, forEachField, - SchemaError -} from './generate'; + mergeDeep, +} from '../utils/index'; + +import attachDirectiveResolvers from './attachDirectiveResolvers'; +import assertResolversPresent from './assertResolversPresent'; +import addResolversToSchema from './addResolversToSchema'; +import attachConnectorsToContext from './attachConnectorsToContext'; +import addSchemaLevelResolver from './addSchemaLevelResolver'; +import buildSchemaFromTypeDefinitions from './buildSchemaFromTypeDefinitions'; +import decorateWithLogger from './decorateWithLogger'; +import SchemaError from './SchemaError'; export function makeExecutableSchema({ typeDefs, @@ -24,67 +27,70 @@ export function makeExecutableSchema({ logger, allowUndefinedInResolve = true, resolverValidationOptions = {}, - directiveResolvers = null, - schemaDirectives = null, + directiveResolvers, + schemaDirectives, parseOptions = {}, - inheritResolversFromInterfaces = false + inheritResolversFromInterfaces = false, }: IExecutableSchemaDefinition) { // Validate and clean up arguments if (typeof resolverValidationOptions !== 'object') { - throw new SchemaError('Expected `resolverValidationOptions` to be an object'); + throw new SchemaError( + 'Expected `resolverValidationOptions` to be an object', + ); } if (!typeDefs) { throw new SchemaError('Must provide typeDefs'); } - if (!resolvers) { - throw new SchemaError('Must provide resolvers'); - } - // We allow passing in an array of resolver maps, in which case we merge them const resolverMap = Array.isArray(resolvers) - ? resolvers.filter(resolverObj => typeof resolverObj === 'object').reduce(mergeDeep, {}) + ? resolvers + .filter((resolverObj) => typeof resolverObj === 'object') + .reduce(mergeDeep, {}) : resolvers; // Arguments are now validated and cleaned up - let schema = buildSchemaFromTypeDefinitions(typeDefs, parseOptions); + const schema = buildSchemaFromTypeDefinitions(typeDefs, parseOptions); - schema = addResolveFunctionsToSchema({ + addResolversToSchema({ schema, resolvers: resolverMap, resolverValidationOptions, - inheritResolversFromInterfaces + inheritResolversFromInterfaces, }); - assertResolveFunctionsPresent(schema, resolverValidationOptions); + assertResolversPresent(schema, resolverValidationOptions); if (!allowUndefinedInResolve) { addCatchUndefinedToSchema(schema); } - if (logger) { + if (logger != null) { addErrorLoggingToSchema(schema, logger); } if (typeof resolvers['__schema'] === 'function') { // TODO a bit of a hack now, better rewrite generateSchema to attach it there. // not doing that now, because I'd have to rewrite a lot of tests. - addSchemaLevelResolveFunction(schema, resolvers['__schema'] as GraphQLFieldResolver); + addSchemaLevelResolver( + schema, + resolvers['__schema'] as GraphQLFieldResolver, + ); } - if (connectors) { + if (connectors != null) { // connectors are optional, at least for now. That means you can just import them in the resolve // function if you want. attachConnectorsToContext(schema, connectors); } - if (directiveResolvers) { + if (directiveResolvers != null) { attachDirectiveResolvers(schema, directiveResolvers); } - if (schemaDirectives) { + if (schemaDirectives != null) { SchemaDirectiveVisitor.visitSchemaDirectives(schema, schemaDirectives); } @@ -93,15 +99,13 @@ export function makeExecutableSchema({ function decorateToCatchUndefined( fn: GraphQLFieldResolver, - hint: string + hint: string, ): GraphQLFieldResolver { - if (typeof fn === 'undefined') { - fn = defaultFieldResolver; - } + const resolve = fn == null ? defaultFieldResolver : fn; return (root, args, ctx, info) => { - const result = fn(root, args, ctx, info); + const result = resolve(root, args, ctx, info); if (typeof result === 'undefined') { - throw new Error(`Resolve function for "${hint}" returned undefined`); + throw new Error(`Resolver for "${hint}" returned undefined`); } return result; }; @@ -114,7 +118,10 @@ export function addCatchUndefinedToSchema(schema: GraphQLSchema): void { }); } -export function addErrorLoggingToSchema(schema: GraphQLSchema, logger: ILogger): void { +export function addErrorLoggingToSchema( + schema: GraphQLSchema, + logger?: ILogger, +): void { if (!logger) { throw new Error('Must provide a logger'); } @@ -126,5 +133,3 @@ export function addErrorLoggingToSchema(schema: GraphQLSchema, logger: ILogger): field.resolve = decorateWithLogger(field.resolve, logger, errorHint); }); } - -export * from './generate'; diff --git a/src/index.ts b/src/index.ts index a8bf617019c..55dd06de2c1 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,10 @@ export * from './Interfaces'; -export * from './makeExecutableSchema'; +export * from './delegate'; +export * from './generate'; +export * from './links'; export * from './mock'; -export * from './stitching'; -export * from './transforms'; -export { SchemaDirectiveVisitor } from './schemaVisitor'; +export * from './polyfills'; +export * from './scalars'; +export * from './stitch'; +export * from './wrap'; +export * from './utils'; diff --git a/src/links/createServerHttpLink.ts b/src/links/createServerHttpLink.ts new file mode 100644 index 00000000000..f1272a4758a --- /dev/null +++ b/src/links/createServerHttpLink.ts @@ -0,0 +1,404 @@ +/* eslint-disable import/no-nodejs-modules */ + +import { Readable } from 'stream'; + +import { ApolloLink, Observable, RequestHandler, fromError } from 'apollo-link'; +import { + serializeFetchParameter, + selectURI, + parseAndCheckHttpResponse, + selectHttpOptionsAndBody, + createSignalIfSupported, + fallbackHttpConfig, + Body, + HttpOptions, + UriFunction, +} from 'apollo-link-http-common'; +import { DefinitionNode } from 'graphql'; +import { + extractFiles, + isExtractableFile as defaultIsExtractableFile, +} from 'extract-files'; +import KnownLengthFormData, { AppendOptions } from 'form-data'; +import fetch from 'node-fetch'; + +const hasOwn = Object.prototype.hasOwnProperty; + +class FormData extends KnownLengthFormData { + private hasUnknowableLength: boolean; + + constructor(options?: any) { + super(options); + this.hasUnknowableLength = false; + } + + public append( + key: string, + value: any, + optionsOrFilename: AppendOptions | string = {}, + ): void { + // allow filename as single option + const options: AppendOptions = + typeof optionsOrFilename === 'string' + ? { filename: optionsOrFilename } + : optionsOrFilename; + + // empty or either doesn't have path or not an http response + if ( + !options.knownLength && + !Buffer.isBuffer(value) && + typeof value !== 'string' && + !value.path && + !(value.readable && hasOwn.call(value, 'httpVersion')) + ) { + this.hasUnknowableLength = true; + } + + super.append(key, value, options); + } + + public getLength( + callback: (err: Error | null, length: number) => void, + ): void { + if (this.hasUnknowableLength) { + return null; + } + + return super.getLength(callback); + } + + public getLengthSync(): number { + if (this.hasUnknowableLength) { + return null; + } + + // eslint-disable-next-line no-sync + return super.getLengthSync(); + } +} + +export type Function = UriFunction; +export type Options = HttpOptions & { + /** + * If set to true, use the HTTP GET method for query operations. Mutations + * will still use the method specified in fetchOptions.method (which defaults + * to POST). + */ + useGETForQueries?: boolean; + serializer?: (method: string) => any; + appendFile?: (form: FormData, index: string, file: File) => void; +}; +// For backwards compatibility. +export { HttpOptions as FetchOptions }; + +interface File { + createReadStream?: () => Readable; + filename?: string; + mimetype?: string; + name?: string; +} + +export const createServerHttpLink = (linkOptions: Options = {}) => { + const { + uri = '/graphql', + fetch: customFetch = (fetch as unknown) as WindowOrWorkerGlobalScope['fetch'], + serializer: customSerializer = defaultSerializer, + appendFile: customAppendFile = defaultAppendFile, + includeExtensions, + useGETForQueries, + ...requestOptions + } = linkOptions; + + const linkConfig = { + http: { includeExtensions }, + options: requestOptions.fetchOptions, + credentials: requestOptions.credentials, + headers: requestOptions.headers, + }; + + return new ApolloLink((operation) => { + let chosenURI = selectURI(operation, uri); + + const context = operation.getContext(); + + // `apollographql-client-*` headers are automatically set if a + // `clientAwareness` object is found in the context. These headers are + // set first, followed by the rest of the headers pulled from + // `context.headers`. If desired, `apollographql-client-*` headers set by + // the `clientAwareness` object can be overridden by + // `apollographql-client-*` headers set in `context.headers`. + const clientAwarenessHeaders = {}; + if (context.clientAwareness) { + const { name, version } = context.clientAwareness; + if (name) { + clientAwarenessHeaders['apollographql-client-name'] = name; + } + if (version) { + clientAwarenessHeaders['apollographql-client-version'] = version; + } + } + + const contextHeaders = { ...clientAwarenessHeaders, ...context.headers }; + + const contextConfig = { + http: context.http, + options: context.fetchOptions, + credentials: context.credentials, + headers: contextHeaders, + }; + + // uses fallback, link, and then context to build options + const { options, body } = selectHttpOptionsAndBody( + operation, + fallbackHttpConfig, + linkConfig, + contextConfig, + ); + + let controller: AbortController; + if (!(options as any).signal) { + const { controller: _controller, signal } = createSignalIfSupported(); + controller = _controller; + if ((controller as unknown) as boolean) { + (options as any).signal = signal; + } + } + + // If requested, set method to GET if there are no mutations. + const definitionIsMutation = (d: DefinitionNode) => + d.kind === 'OperationDefinition' && d.operation === 'mutation'; + + if ( + useGETForQueries && + !operation.query.definitions.some(definitionIsMutation) + ) { + options.method = 'GET'; + } + + if (options.method === 'GET') { + const { newURI, parseError } = rewriteURIForGET(chosenURI, body); + if (parseError) { + return fromError(parseError); + } + chosenURI = newURI; + } + + return new Observable((observer) => { + getFinalPromise(body) + .then((resolvedBody) => { + if (options.method !== 'GET') { + options.body = customSerializer(resolvedBody, customAppendFile); + if (options.body instanceof FormData) { + // Automatically set by fetch when the body is a FormData instance. + delete options.headers['content-type']; + } + } + return options; + }) + .then((newOptions) => customFetch(chosenURI, newOptions)) + .then((response) => { + operation.setContext({ response }); + return response; + }) + .then(parseAndCheckHttpResponse(operation)) + .then((result) => { + // we have data and can send it to back up the link chain + observer.next(result); + observer.complete(); + return result; + }) + .catch((err) => { + // fetch was cancelled so it's already been cleaned up in the unsubscribe + if (err.name === 'AbortError') { + return; + } + // if it is a network error, BUT there is graphql result info + // fire the next observer before calling error + // this gives apollo-client (and react-apollo) the `graphqlErrors` and `networErrors` + // to pass to UI + // this should only happen if we *also* have data as part of the response key per + // the spec + if (err.result && err.result.errors && err.result.data) { + // if we don't call next, the UI can only show networkError because AC didn't + // get any graphqlErrors + // this is graphql execution result info (i.e errors and possibly data) + // this is because there is no formal spec how errors should translate to + // http status codes. So an auth error (401) could have both data + // from a public field, errors from a private field, and a status of 401 + // { + // user { // this will have errors + // firstName + // } + // products { // this is public so will have data + // cost + // } + // } + // + // the result of above *could* look like this: + // { + // data: { products: [{ cost: "$10" }] }, + // errors: [{ + // message: 'your session has timed out', + // path: [] + // }] + // } + // status code of above would be a 401 + // in the UI you want to show data where you can, errors as data where you can + // and use correct http status codes + observer.next(err.result); + } + observer.error(err); + }); + + return () => { + // XXX support canceling this request + // https://developers.google.com/web/updates/2017/09/abortable-fetch + if ((controller as unknown) as boolean) { + controller.abort(); + } + }; + }); + }); +}; + +// For GET operations, returns the given URI rewritten with parameters, or a +// parse error. +function rewriteURIForGET(chosenURI: string, body: Body) { + // Implement the standard HTTP GET serialization, plus 'extensions'. Note + // the extra level of JSON serialization! + const queryParams: Array = []; + const addQueryParam = (key: string, value: string) => { + queryParams.push(`${key}=${encodeURIComponent(value)}`); + }; + + if ('query' in body) { + addQueryParam('query', body.query); + } + if (body.operationName) { + addQueryParam('operationName', body.operationName); + } + if (body.variables != null) { + let serializedVariables; + try { + serializedVariables = serializeFetchParameter( + body.variables, + 'Variables map', + ); + } catch (parseError) { + return { parseError }; + } + addQueryParam('variables', serializedVariables); + } + if (body.extensions != null) { + let serializedExtensions; + try { + serializedExtensions = serializeFetchParameter( + body.extensions, + 'Extensions map', + ); + } catch (parseError) { + return { parseError }; + } + addQueryParam('extensions', serializedExtensions); + } + + // Reconstruct the URI with added query params. + // XXX This assumes that the URI is well-formed and that it doesn't + // already contain any of these query params. We could instead use the + // URL API and take a polyfill (whatwg-url@6) for older browsers that + // don't support URLSearchParams. Note that some browsers (and + // versions of whatwg-url) support URL but not URLSearchParams! + let fragment = ''; + let preFragment = chosenURI; + const fragmentStart = chosenURI.indexOf('#'); + if (fragmentStart !== -1) { + fragment = chosenURI.substr(fragmentStart); + preFragment = chosenURI.substr(0, fragmentStart); + } + const queryParamsPrefix = preFragment.indexOf('?') === -1 ? '?' : '&'; + const newURI = + preFragment + queryParamsPrefix + queryParams.join('&') + fragment; + return { newURI }; +} + +function getFinalPromise(object: any): Promise { + return Promise.resolve(object).then((resolvedObject) => { + if (resolvedObject == null) { + return resolvedObject; + } + + if (Array.isArray(resolvedObject)) { + return Promise.all(resolvedObject.map((o) => getFinalPromise(o))); + } else if (typeof resolvedObject === 'object') { + const keys = Object.keys(resolvedObject); + return Promise.all( + keys.map((key) => getFinalPromise(resolvedObject[key])), + ).then((awaitedValues) => { + for (let i = 0; i < keys.length; i++) { + resolvedObject[keys[i]] = awaitedValues[i]; + } + return resolvedObject; + }); + } + + return resolvedObject; + }); +} + +function defaultSerializer( + body: any, + appendFile: (form: FormData, index: string, file: File) => void, +): any { + const { clone, files } = extractFiles( + body, + undefined, + (value: any) => defaultIsExtractableFile(value) || value?.createReadStream, + ); + + const payload = serializeFetchParameter(clone, 'Payload'); + + if (!files.size) { + return payload; + } + + // GraphQL multipart request spec: + // https://github.com/jaydenseric/graphql-multipart-request-spec + + const form = new FormData(); + + form.append('operations', payload); + + const map = {}; + let i = 0; + + files.forEach((paths: Array) => { + map[++i] = paths; + }); + + form.append('map', JSON.stringify(map)); + + i = 0; + files.forEach((_paths: Array, file: File) => { + appendFile(form, (++i).toString(), file); + }); + + return form; +} + +function defaultAppendFile(form: FormData, index: string, file: File) { + if (file.createReadStream != null) { + form.append(index, file.createReadStream(), { + filename: file.filename, + contentType: file.mimetype, + }); + } else { + form.append(index, file, file.name); + } +} + +export class ServerHttpLink extends ApolloLink { + public requester: RequestHandler; + constructor(opts?: HttpOptions) { + super(createServerHttpLink(opts).request); + } +} diff --git a/src/links/index.ts b/src/links/index.ts new file mode 100644 index 00000000000..a34c83329b9 --- /dev/null +++ b/src/links/index.ts @@ -0,0 +1,3 @@ +import { createServerHttpLink } from './createServerHttpLink'; + +export { createServerHttpLink }; diff --git a/src/mergeDeep.ts b/src/mergeDeep.ts deleted file mode 100644 index 599ab656773..00000000000 --- a/src/mergeDeep.ts +++ /dev/null @@ -1,21 +0,0 @@ -export default function mergeDeep(target: any, source: any): any { - let output = Object.assign({}, target); - if (isObject(target) && isObject(source)) { - Object.keys(source).forEach(key => { - if (isObject(source[key])) { - if (!(key in target)) { - Object.assign(output, { [key]: source[key] }); - } else { - output[key] = mergeDeep(target[key], source[key]); - } - } else { - Object.assign(output, { [key]: source[key] }); - } - }); - } - return output; -} - -function isObject(item: any): boolean { - return item && typeof item === 'object' && !Array.isArray(item); -} diff --git a/src/mock.ts b/src/mock/index.ts similarity index 75% rename from src/mock.ts rename to src/mock/index.ts index 9a53a2beb2b..e87e47a4c82 100644 --- a/src/mock.ts +++ b/src/mock/index.ts @@ -2,9 +2,6 @@ import { graphql, GraphQLSchema, GraphQLObjectType, - GraphQLEnumType, - GraphQLUnionType, - GraphQLInterfaceType, GraphQLList, GraphQLType, GraphQLField, @@ -13,14 +10,19 @@ import { getNamedType, GraphQLNamedType, GraphQLFieldResolver, - GraphQLNonNull, GraphQLNullableType, + isSchema, + isObjectType, + isUnionType, + isInterfaceType, + isListType, + isEnumType, + isAbstractType, } from 'graphql'; -import * as uuid from 'uuid'; -import { - forEachField, - buildSchemaFromTypeDefinitions, -} from './makeExecutableSchema'; +import { v4 as uuid } from 'uuid'; + +import { buildSchemaFromTypeDefinitions } from '../generate/index'; +import { forEachField } from '../utils/index'; import { IMocks, @@ -29,23 +31,25 @@ import { IMockFn, IMockTypeFn, ITypeDefinitions, -} from './Interfaces'; +} from '../Interfaces'; -// This function wraps addMockFunctionsToSchema for more convenience +/** + * This function wraps addMocksToSchema for more convenience + */ function mockServer( schema: GraphQLSchema | ITypeDefinitions, mocks: IMocks, preserveResolvers: boolean = false, ): IMockServer { let mySchema: GraphQLSchema; - if (!(schema instanceof GraphQLSchema)) { + if (!isSchema(schema)) { // TODO: provide useful error messages here if this fails mySchema = buildSchemaFromTypeDefinitions(schema); } else { mySchema = schema; } - addMockFunctionsToSchema({ schema: mySchema, mocks, preserveResolvers }); + addMocksToSchema({ schema: mySchema, mocks, preserveResolvers }); return { query: (query, vars) => graphql(mySchema, query, {}, {}, vars) }; } @@ -55,12 +59,12 @@ defaultMockMap.set('Int', () => Math.round(Math.random() * 200) - 100); defaultMockMap.set('Float', () => Math.random() * 200 - 100); defaultMockMap.set('String', () => 'Hello World'); defaultMockMap.set('Boolean', () => Math.random() > 0.5); -defaultMockMap.set('ID', () => uuid.v4()); +defaultMockMap.set('ID', () => uuid()); // TODO allow providing a seed such that lengths of list could be deterministic // this could be done by using casual to get a random list length if the casual // object is global. -function addMockFunctionsToSchema({ +function addMocksToSchema({ schema, mocks = {}, preserveResolvers = false, @@ -68,7 +72,7 @@ function addMockFunctionsToSchema({ if (!schema) { throw new Error('Must provide schema to mock'); } - if (!(schema instanceof GraphQLSchema)) { + if (!isSchema(schema)) { throw new Error('Value at "schema" must be of type GraphQLSchema'); } if (!isObject(mocks)) { @@ -77,7 +81,7 @@ function addMockFunctionsToSchema({ // use Map internally, because that API is nicer. const mockFunctionMap: Map = new Map(); - Object.keys(mocks).forEach(typeName => { + Object.keys(mocks).forEach((typeName) => { mockFunctionMap.set(typeName, mocks[typeName]); }); @@ -87,9 +91,9 @@ function addMockFunctionsToSchema({ } }); - const mockType = function( + const mockType = function ( type: GraphQLType, - typeName?: string, + _typeName?: string, fieldName?: string, ): GraphQLFieldResolver { // order of precendence for mocking: @@ -109,7 +113,7 @@ function addMockFunctionsToSchema({ const fieldType = getNullableType(type) as GraphQLNullableType; const namedFieldType = getNamedType(fieldType); - if (root && typeof root[fieldName] !== 'undefined') { + if (fieldName && root && typeof root[fieldName] !== 'undefined') { let result: any; // if we're here, the field is already defined @@ -132,53 +136,38 @@ function addMockFunctionsToSchema({ // Now we merge the result with the default mock for this type. // This allows overriding defaults while writing very little code. if (mockFunctionMap.has(namedFieldType.name)) { + const mock = mockFunctionMap.get(namedFieldType.name); + result = mergeMocks( - mockFunctionMap - .get(namedFieldType.name) - .bind(null, root, args, context, info), + mock.bind(null, root, args, context, info), result, ); } return result; } - if ( - fieldType instanceof GraphQLList || - fieldType instanceof GraphQLNonNull - ) { + if (isListType(fieldType)) { return [ mockType(fieldType.ofType)(root, args, context, info), mockType(fieldType.ofType)(root, args, context, info), ]; } - if ( - mockFunctionMap.has(fieldType.name) && - !( - fieldType instanceof GraphQLUnionType || - fieldType instanceof GraphQLInterfaceType - ) - ) { + if (mockFunctionMap.has(fieldType.name) && !isAbstractType(fieldType)) { // the object passed doesn't have this field, so we apply the default mock - return mockFunctionMap.get(fieldType.name)(root, args, context, info); + const mock = mockFunctionMap.get(fieldType.name); + return mock(root, args, context, info); } - if (fieldType instanceof GraphQLObjectType) { + if (isObjectType(fieldType)) { // objects don't return actual data, we only need to mock scalars! return {}; } // if a mock function is provided for unionType or interfaceType, execute it to resolve the concrete type // otherwise randomly pick a type from all implementation types - if ( - fieldType instanceof GraphQLUnionType || - fieldType instanceof GraphQLInterfaceType - ) { + if (isAbstractType(fieldType)) { let implementationType; if (mockFunctionMap.has(fieldType.name)) { - const interfaceMockObj = mockFunctionMap.get(fieldType.name)( - root, - args, - context, - info, - ); + const mock = mockFunctionMap.get(fieldType.name); + const interfaceMockObj = mock(root, args, context, info); if (!interfaceMockObj || !interfaceMockObj.__typename) { return Error(`Please return a __typename in "${fieldType.name}"`); } @@ -187,18 +176,19 @@ function addMockFunctionsToSchema({ const possibleTypes = schema.getPossibleTypes(fieldType); implementationType = getRandomElement(possibleTypes); } - return Object.assign( - { __typename: implementationType }, - mockType(implementationType)(root, args, context, info), - ); + return { + __typename: implementationType, + ...mockType(implementationType)(root, args, context, info), + }; } - if (fieldType instanceof GraphQLEnumType) { + if (isEnumType(fieldType)) { return getRandomElement(fieldType.getValues()).value; } if (defaultMockMap.has(fieldType.name)) { - return defaultMockMap.get(fieldType.name)(root, args, context, info); + const defaultMock = defaultMockMap.get(fieldType.name); + return defaultMock(root, args, context, info); } // if we get to here, we don't have a value, and we don't have a mock for this type, @@ -212,34 +202,44 @@ function addMockFunctionsToSchema({ schema, (field: GraphQLField, typeName: string, fieldName: string) => { assignResolveType(field.type, preserveResolvers); - let mockResolver: GraphQLFieldResolver; + let mockResolver: GraphQLFieldResolver = mockType( + field.type, + typeName, + fieldName, + ); // we have to handle the root mutation and root query types differently, // because no resolver is called at the root. - /* istanbul ignore next: Must provide schema DefinitionNode with query type or a type named Query. */ - const isOnQueryType: boolean = schema.getQueryType() && schema.getQueryType().name === typeName - const isOnMutationType: boolean = schema.getMutationType() && schema.getMutationType().name === typeName + const queryType = schema.getQueryType(); + const isOnQueryType = queryType != null && queryType.name === typeName; + + const mutationType = schema.getMutationType(); + const isOnMutationType = + mutationType != null && mutationType.name === typeName; if (isOnQueryType || isOnMutationType) { if (mockFunctionMap.has(typeName)) { const rootMock = mockFunctionMap.get(typeName); // XXX: BUG in here, need to provide proper signature for rootMock. - if (typeof rootMock(undefined, {}, {}, {} as any)[fieldName] === 'function') { + if ( + typeof rootMock(undefined, {}, {}, {} as any)[fieldName] === + 'function' + ) { mockResolver = ( root: any, args: { [key: string]: any }, context: any, info: GraphQLResolveInfo, ) => { - const updatedRoot = root || {}; // TODO: should we clone instead? + const updatedRoot = root ?? {}; // TODO: should we clone instead? updatedRoot[fieldName] = rootMock(root, args, context, info)[ fieldName ]; // XXX this is a bit of a hack to still use mockType, which // lets you mock lists etc. as well // otherwise we could just set field.resolve to rootMock()[fieldName] - // it's like pretending there was a resolve function that ran before - // the root resolve function. + // it's like pretending there was a resolver that ran before + // the root resolver. return mockType(field.type, typeName, fieldName)( updatedRoot, args, @@ -250,23 +250,20 @@ function addMockFunctionsToSchema({ } } } - if (!mockResolver) { - mockResolver = mockType(field.type, typeName, fieldName); - } if (!preserveResolvers || !field.resolve) { field.resolve = mockResolver; } else { const oldResolver = field.resolve; field.resolve = ( - rootObject?: any, - args?: { [key: string]: any }, - context?: any, - info?: GraphQLResolveInfo, + rootObject: any, + args: { [key: string]: any }, + context: any, + info: GraphQLResolveInfo, ) => Promise.all([ mockResolver(rootObject, args, context, info), oldResolver(rootObject, args, context, info), - ]).then(values => { + ]).then((values) => { const [mockedValue, resolvedValue] = values; // In case we couldn't mock @@ -307,26 +304,33 @@ function getRandomElement(ary: ReadonlyArray) { return ary[sample]; } -function mergeObjects(a: Object, b: Object) { +function mergeObjects(a: Record, b: Record) { return Object.assign(a, b); } -function copyOwnPropsIfNotPresent(target: Object, source: Object) { - Object.getOwnPropertyNames(source).forEach(prop => { +function copyOwnPropsIfNotPresent( + target: Record, + source: Record, +) { + Object.getOwnPropertyNames(source).forEach((prop) => { if (!Object.getOwnPropertyDescriptor(target, prop)) { + const propertyDescriptor = Object.getOwnPropertyDescriptor(source, prop); Object.defineProperty( target, prop, - Object.getOwnPropertyDescriptor(source, prop), + propertyDescriptor == null ? {} : propertyDescriptor, ); } }); } -function copyOwnProps(target: Object, ...sources: Object[]) { - sources.forEach(source => { +function copyOwnProps( + target: Record, + ...sources: Array> +) { + sources.forEach((source) => { let chain = source; - while (chain) { + while (chain != null) { copyOwnPropsIfNotPresent(target, chain); chain = Object.getPrototypeOf(chain); } @@ -349,13 +353,8 @@ function mergeMocks(genericMockFunction: () => any, customMock: any): any { } function getResolveType(namedFieldType: GraphQLNamedType) { - if ( - namedFieldType instanceof GraphQLInterfaceType || - namedFieldType instanceof GraphQLUnionType - ) { + if (isAbstractType(namedFieldType)) { return namedFieldType.resolveType; - } else { - return undefined; } } @@ -364,33 +363,28 @@ function assignResolveType(type: GraphQLType, preserveResolvers: boolean) { const namedFieldType = getNamedType(fieldType); const oldResolveType = getResolveType(namedFieldType); - if (preserveResolvers && oldResolveType && oldResolveType.length) { + if (preserveResolvers && oldResolveType != null && oldResolveType.length) { return; } - if ( - namedFieldType instanceof GraphQLUnionType || - namedFieldType instanceof GraphQLInterfaceType - ) { + if (isInterfaceType(namedFieldType) || isUnionType(namedFieldType)) { // the default `resolveType` always returns null. We add a fallback // resolution that works with how unions and interface are mocked namedFieldType.resolveType = ( data: any, - context: any, + _context: any, info: GraphQLResolveInfo, - ) => { - return info.schema.getType(data.__typename) as GraphQLObjectType; - }; + ) => info.schema.getType(data.__typename) as GraphQLObjectType; } } class MockList { - private len: number | number[]; - private wrappedFunction: GraphQLFieldResolver; + private readonly len: number | Array; + private readonly wrappedFunction: GraphQLFieldResolver | undefined; // wrappedFunction can return another MockList or a value constructor( - len: number | number[], + len: number | Array, wrappedFunction?: GraphQLFieldResolver, ) { this.len = len; @@ -412,7 +406,7 @@ class MockList { fieldType: GraphQLList, mockTypeFunc: IMockTypeFn, ) { - let arr: any[]; + let arr: Array; if (Array.isArray(this.len)) { arr = new Array(this.randint(this.len[0], this.len[1])); } else { @@ -449,4 +443,14 @@ class MockList { } } -export { addMockFunctionsToSchema, MockList, mockServer }; +// retain addMockFunctionsToSchema for backwards compatibility + +function addMockFunctionsToSchema({ + schema, + mocks = {}, + preserveResolvers = false, +}: IMockOptions): void { + addMocksToSchema({ schema, mocks, preserveResolvers }); +} + +export { addMocksToSchema, addMockFunctionsToSchema, MockList, mockServer }; diff --git a/src/polyfills/buildSchema.ts b/src/polyfills/buildSchema.ts new file mode 100644 index 00000000000..ae8c3c05c0f --- /dev/null +++ b/src/polyfills/buildSchema.ts @@ -0,0 +1,9 @@ +import { Source, buildASTSchema, parse, BuildSchemaOptions } from 'graphql'; + +// polyfill for graphql prior to v13 which do not pass options to buildASTSchema +export function buildSchema( + ast: string | Source, + buildSchemaOptions: BuildSchemaOptions, +) { + return buildASTSchema(parse(ast), buildSchemaOptions); +} diff --git a/src/polyfills/extendSchema.ts b/src/polyfills/extendSchema.ts new file mode 100644 index 00000000000..89a67aae6cc --- /dev/null +++ b/src/polyfills/extendSchema.ts @@ -0,0 +1,36 @@ +import { + DocumentNode, + GraphQLSchema, + extendSchema as graphqlExtendSchema, +} from 'graphql'; + +import { getResolversFromSchema } from '../utils/index'; +import { IResolverOptions } from '../Interfaces'; + +// polyfill for graphql < v14.2 which does not support subscriptions +export function extendSchema( + schema: GraphQLSchema, + extension: DocumentNode, + options: any, +): GraphQLSchema { + const subscriptionType = schema.getSubscriptionType(); + if (subscriptionType == null) { + return graphqlExtendSchema(schema, extension, options); + } + + const resolvers = getResolversFromSchema(schema); + + const subscriptionTypeName = subscriptionType.name; + const subscriptionResolvers = resolvers[ + subscriptionTypeName + ] as IResolverOptions; + + const extendedSchema = graphqlExtendSchema(schema, extension, options); + + const fields = extendedSchema.getSubscriptionType().getFields(); + Object.keys(subscriptionResolvers).forEach((fieldName) => { + fields[fieldName].subscribe = subscriptionResolvers[fieldName].subscribe; + }); + + return extendedSchema; +} diff --git a/src/polyfills/index.ts b/src/polyfills/index.ts new file mode 100644 index 00000000000..054341ba434 --- /dev/null +++ b/src/polyfills/index.ts @@ -0,0 +1,24 @@ +export { isSpecifiedScalarType } from './isSpecifiedScalarType'; + +export { buildSchema } from './buildSchema'; + +export { extendSchema } from './extendSchema'; + +export { + toConfig, + schemaToConfig, + typeToConfig, + objectTypeToConfig, + interfaceTypeToConfig, + unionTypeToConfig, + enumTypeToConfig, + scalarTypeToConfig, + inputObjectTypeToConfig, + directiveToConfig, + inputFieldMapToConfig, + inputFieldToConfig, + fieldMapToConfig, + fieldToConfig, + argumentMapToConfig, + argumentToConfig, +} from './toConfig'; diff --git a/src/isSpecifiedScalarType.ts b/src/polyfills/isSpecifiedScalarType.ts similarity index 85% rename from src/isSpecifiedScalarType.ts rename to src/polyfills/isSpecifiedScalarType.ts index 0fec7d93544..9305fcb53ae 100644 --- a/src/isSpecifiedScalarType.ts +++ b/src/polyfills/isSpecifiedScalarType.ts @@ -9,6 +9,7 @@ import { } from 'graphql'; // FIXME: Replace with https://github.com/graphql/graphql-js/blob/master/src/type/scalars.js#L139 +// Blocked by https://github.com/graphql/graphql-js/issues/2153 export const specifiedScalarTypes: Array = [ GraphQLString, @@ -18,7 +19,7 @@ export const specifiedScalarTypes: Array = [ GraphQLID, ]; -export default function isSpecifiedScalarType(type: any): boolean { +export function isSpecifiedScalarType(type: any): boolean { return ( isNamedType(type) && // Would prefer to use specifiedScalarTypes.some(), however %checks needs diff --git a/src/polyfills/toConfig.ts b/src/polyfills/toConfig.ts new file mode 100644 index 00000000000..bdb83b01fc2 --- /dev/null +++ b/src/polyfills/toConfig.ts @@ -0,0 +1,441 @@ +// graphql = []; + + const types = schema.getTypeMap(); + Object.keys(types).forEach((typeName) => { + newTypes.push(types[typeName]); + }); + + const schemaConfig = { + query: schema.getQueryType(), + mutation: schema.getMutationType(), + subscription: schema.getSubscriptionType(), + types: newTypes, + directives: schema.getDirectives().slice(), + extensions: schema.extensions, + astNode: schema.astNode, + extensionASTNodes: + schema.extensionASTNodes != null ? schema.extensionASTNodes : [], + assumeValid: + (schema as { __validationErrors?: boolean }).__validationErrors !== + undefined, + }; + + if (graphqlVersion() >= 15) { + (schemaConfig as { + description?: string; + }).description = (schema as { + description?: string; + }).description; + } + + return schemaConfig; +} + +export function toConfig(graphqlObject: GraphQLSchema): GraphQLSchemaConfig; +export function toConfig( + graphqlObject: GraphQLObjectTypeConfig & { + interfaces: Array; + fields: GraphQLFieldConfigMap; + }, +): GraphQLObjectTypeConfig; +export function toConfig( + graphqlObject: GraphQLInterfaceType, +): GraphQLInterfaceTypeConfig & { + fields: GraphQLFieldConfigMap; +}; +export function toConfig( + graphqlObject: GraphQLUnionType, +): GraphQLUnionTypeConfig & { + types: Array; +}; +export function toConfig(graphqlObject: GraphQLEnumType): GraphQLEnumTypeConfig; +export function toConfig( + graphqlObject: GraphQLScalarType, +): GraphQLScalarTypeConfig; +export function toConfig( + graphqlObject: GraphQLInputObjectType, +): GraphQLInputObjectTypeConfig & { + fields: GraphQLInputFieldConfigMap; +}; +export function toConfig( + graphqlObject: GraphQLDirective, +): GraphQLDirectiveConfig; +export function toConfig( + graphqlObject: GraphQLInputField, +): GraphQLInputFieldConfig; +export function toConfig( + graphqlObject: GraphQLField, +): GraphQLFieldConfig; +export function toConfig(graphqlObject: any): any; +export function toConfig(graphqlObject: any) { + if (isSchema(graphqlObject)) { + return schemaToConfig(graphqlObject); + } else if (isDirective(graphqlObject)) { + return directiveToConfig(graphqlObject); + } else if (isNamedType(graphqlObject)) { + return typeToConfig(graphqlObject); + } + + // Input and output fields do not have predicates defined, but using duck typing, + // type is defined for input and output fields + if (graphqlObject.type != null) { + if ( + graphqlObject.args != null || + graphqlObject.resolve != null || + graphqlObject.subscribe != null + ) { + return fieldToConfig(graphqlObject); + } else if (graphqlObject.defaultValue !== undefined) { + return inputFieldToConfig(graphqlObject); + } + + // Not all input and output fields can be checked by above in older versions + // of graphql, but almost all properties on the field and config are identical. + // In particular, just name and isDeprecated should be removed. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { name, isDeprecated, ...rest } = graphqlObject; + return { + ...rest, + }; + } + + throw new Error(`Unknown graphql object ${graphqlObject as string}`); +} + +export function typeToConfig( + type: GraphQLObjectType, +): GraphQLObjectTypeConfig; +export function typeToConfig( + type: GraphQLInterfaceType, +): GraphQLInterfaceTypeConfig; +export function typeToConfig( + type: GraphQLUnionType, +): GraphQLUnionTypeConfig; +export function typeToConfig(type: GraphQLEnumType): GraphQLEnumTypeConfig; +export function typeToConfig( + type: GraphQLScalarType, +): GraphQLScalarTypeConfig; +export function typeToConfig( + type: GraphQLInputObjectType, +): GraphQLInputObjectTypeConfig; +export function typeToConfig(type: any): any; +export function typeToConfig(type: any) { + if (isObjectType(type)) { + return objectTypeToConfig(type); + } else if (isInterfaceType(type)) { + return interfaceTypeToConfig(type); + } else if (isUnionType(type)) { + return unionTypeToConfig(type); + } else if (isEnumType(type)) { + return enumTypeToConfig(type); + } else if (isScalarType(type)) { + return scalarTypeToConfig(type); + } else if (isInputObjectType(type)) { + return inputObjectTypeToConfig(type); + } + + throw new Error(`Unknown type ${type as string}`); +} + +export function objectTypeToConfig( + type: GraphQLObjectType, +): GraphQLObjectTypeConfig { + if (type.toConfig != null) { + return type.toConfig(); + } + + const typeConfig = { + name: type.name, + description: type.description, + interfaces: type.getInterfaces(), + fields: fieldMapToConfig(type.getFields()), + isTypeOf: type.isTypeOf, + extensions: type.extensions, + astNode: type.astNode, + extensionASTNodes: + type.extensionASTNodes != null ? type.extensionASTNodes : [], + }; + + return typeConfig; +} + +export function interfaceTypeToConfig( + type: GraphQLInterfaceType, +): GraphQLInterfaceTypeConfig { + if (type.toConfig != null) { + return type.toConfig(); + } + + const typeConfig = { + name: type.name, + description: type.description, + fields: fieldMapToConfig(type.getFields()), + resolveType: type.resolveType, + extensions: type.extensions, + astNode: type.astNode, + extensionASTNodes: + type.extensionASTNodes != null ? type.extensionASTNodes : [], + }; + + if (graphqlVersion() >= 15) { + ((typeConfig as unknown) as GraphQLObjectTypeConfig< + any, + any + >).interfaces = ((type as unknown) as GraphQLObjectType).getInterfaces(); + } + + return typeConfig; +} + +export function unionTypeToConfig( + type: GraphQLUnionType, +): GraphQLUnionTypeConfig { + if (type.toConfig != null) { + return type.toConfig(); + } + + const typeConfig = { + name: type.name, + description: type.description, + types: type.getTypes(), + resolveType: type.resolveType, + extensions: type.extensions, + astNode: type.astNode, + extensionASTNodes: + type.extensionASTNodes != null ? type.extensionASTNodes : [], + }; + + return typeConfig; +} + +export function enumTypeToConfig(type: GraphQLEnumType): GraphQLEnumTypeConfig { + if (type.toConfig != null) { + return type.toConfig(); + } + + const newValues = {}; + + type.getValues().forEach((value) => { + newValues[value.name] = { + description: value.description, + value: value.value, + deprecationReason: value.deprecationReason, + extensions: value.extensions, + astNode: value.astNode, + }; + }); + + const typeConfig = { + name: type.name, + description: type.description, + values: newValues, + extensions: type.extensions, + astNode: type.astNode, + extensionASTNodes: + type.extensionASTNodes != null ? type.extensionASTNodes : [], + }; + + return typeConfig; +} + +const hasOwn = Object.prototype.hasOwnProperty; + +export function scalarTypeToConfig( + type: GraphQLScalarType, +): GraphQLScalarTypeConfig { + if (type.toConfig != null) { + return type.toConfig(); + } + + const typeConfig = { + name: type.name, + description: type.description, + serialize: + graphqlVersion() >= 14 || hasOwn.call(type, 'serialize') + ? type.serialize + : ((type as unknown) as { + _scalarConfig: GraphQLScalarTypeConfig; + })._scalarConfig.serialize, + parseValue: + graphqlVersion() >= 14 || hasOwn.call(type, 'parseValue') + ? type.parseValue + : ((type as unknown) as { + _scalarConfig: GraphQLScalarTypeConfig; + })._scalarConfig.parseValue, + parseLiteral: + graphqlVersion() >= 14 || hasOwn.call(type, 'parseLiteral') + ? type.parseLiteral + : ((type as unknown) as { + _scalarConfig: GraphQLScalarTypeConfig; + })._scalarConfig.parseLiteral, + extensions: type.extensions, + astNode: type.astNode, + extensionASTNodes: + type.extensionASTNodes != null ? type.extensionASTNodes : [], + }; + + return typeConfig; +} + +export function inputObjectTypeToConfig( + type: GraphQLInputObjectType, +): GraphQLInputObjectTypeConfig { + if (type.toConfig != null) { + return type.toConfig(); + } + + const typeConfig = { + name: type.name, + description: type.description, + fields: inputFieldMapToConfig(type.getFields()), + extensions: type.extensions, + astNode: type.astNode, + extensionASTNodes: + type.extensionASTNodes != null ? type.extensionASTNodes : [], + }; + + return typeConfig; +} + +export function inputFieldMapToConfig( + fields: GraphQLInputFieldMap, +): GraphQLInputFieldConfigMap { + const newFields = {}; + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + newFields[fieldName] = toConfig(field); + }); + + return newFields; +} + +export function inputFieldToConfig( + field: GraphQLInputField, +): GraphQLInputFieldConfig { + return { + description: field.description, + type: field.type, + defaultValue: field.defaultValue, + extensions: field.extensions, + astNode: field.astNode, + }; +} + +export function directiveToConfig( + directive: GraphQLDirective, +): GraphQLDirectiveConfig { + if (directive.toConfig != null) { + return directive.toConfig(); + } + + const directiveConfig = { + name: directive.name, + description: directive.description, + locations: directive.locations, + args: argumentMapToConfig(directive.args), + isRepeatable: ((directive as unknown) as { isRepeatable: boolean }) + .isRepeatable, + extensions: directive.extensions, + astNode: directive.astNode, + }; + + return directiveConfig; +} + +export function fieldMapToConfig( + fields: GraphQLFieldMap, +): GraphQLFieldConfigMap { + const newFields = {}; + + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + newFields[fieldName] = toConfig(field); + }); + + return newFields; +} + +export function fieldToConfig( + field: GraphQLField, +): GraphQLFieldConfig { + return { + description: field.description, + type: field.type, + args: argumentMapToConfig(field.args), + resolve: field.resolve, + subscribe: field.subscribe, + deprecationReason: field.deprecationReason, + extensions: field.extensions, + astNode: field.astNode, + }; +} + +export function argumentMapToConfig( + args: ReadonlyArray, +): GraphQLFieldConfigArgumentMap { + const newArguments = {}; + args.forEach((arg) => { + newArguments[arg.name] = argumentToConfig(arg); + }); + + return newArguments; +} + +export function argumentToConfig(arg: GraphQLArgument): GraphQLArgumentConfig { + return { + description: arg.description, + type: arg.type, + defaultValue: arg.defaultValue, + extensions: arg.extensions, + astNode: arg.astNode, + }; +} diff --git a/src/scalars/GraphQLUpload.ts b/src/scalars/GraphQLUpload.ts new file mode 100644 index 00000000000..a903244057b --- /dev/null +++ b/src/scalars/GraphQLUpload.ts @@ -0,0 +1,23 @@ +import { GraphQLScalarType, GraphQLError } from 'graphql'; + +const GraphQLUpload = new GraphQLScalarType({ + name: 'Upload', + description: 'The `Upload` scalar type represents a file upload.', + parseValue: (value) => { + if (value != null && value.promise instanceof Promise) { + // graphql-upload v10 + return value.promise; + } else if (value instanceof Promise) { + // graphql-upload v9 + return value; + } + throw new GraphQLError('Upload value invalid.'); + }, + // serialization requires to support schema stitching + serialize: (value) => value, + parseLiteral: (ast) => { + throw new GraphQLError('Upload literal unsupported.', ast); + }, +}); + +export { GraphQLUpload }; diff --git a/src/scalars/index.ts b/src/scalars/index.ts new file mode 100644 index 00000000000..593c28f6641 --- /dev/null +++ b/src/scalars/index.ts @@ -0,0 +1,3 @@ +import { GraphQLUpload } from './GraphQLUpload'; + +export { GraphQLUpload }; diff --git a/src/schemaVisitor.ts b/src/schemaVisitor.ts deleted file mode 100644 index 5769e132b0c..00000000000 --- a/src/schemaVisitor.ts +++ /dev/null @@ -1,766 +0,0 @@ -import { - GraphQLArgument, - GraphQLDirective, - GraphQLEnumType, - GraphQLEnumValue, - GraphQLField, - GraphQLInputField, - GraphQLInputObjectType, - GraphQLInterfaceType, - GraphQLNamedType, - GraphQLObjectType, - GraphQLScalarType, - GraphQLSchema, - GraphQLUnionType, - Kind, - ValueNode, - DirectiveLocationEnum, - GraphQLType, - GraphQLList, - GraphQLNonNull, - isNamedType, -} from 'graphql'; - -import { - getArgumentValues, -} from 'graphql/execution/values'; - -export type VisitableSchemaType = - GraphQLSchema - | GraphQLObjectType - | GraphQLInterfaceType - | GraphQLInputObjectType - | GraphQLNamedType - | GraphQLScalarType - | GraphQLField - | GraphQLArgument - | GraphQLUnionType - | GraphQLEnumType - | GraphQLEnumValue - | GraphQLInputField; - -const hasOwn = Object.prototype.hasOwnProperty; - -// Abstract base class of any visitor implementation, defining the available -// visitor methods along with their parameter types, and providing a static -// helper function for determining whether a subclass implements a given -// visitor method, as opposed to inheriting one of the stubs defined here. -export abstract class SchemaVisitor { - // All SchemaVisitor instances are created while visiting a specific - // GraphQLSchema object, so this property holds a reference to that object, - // in case a visitor method needs to refer to this.schema. - public schema: GraphQLSchema; - - // Determine if this SchemaVisitor (sub)class implements a particular - // visitor method. - public static implementsVisitorMethod(methodName: string) { - if (! methodName.startsWith('visit')) { - return false; - } - - const method = this.prototype[methodName]; - if (typeof method !== 'function') { - return false; - } - - if (this === SchemaVisitor) { - // The SchemaVisitor class implements every visitor method. - return true; - } - - const stub = SchemaVisitor.prototype[methodName]; - if (method === stub) { - // If this.prototype[methodName] was just inherited from SchemaVisitor, - // then this class does not really implement the method. - return false; - } - - return true; - } - - // Concrete subclasses of SchemaVisitor should override one or more of these - // visitor methods, in order to express their interest in handling certain - // schema types/locations. Each method may return null to remove the given - // type from the schema, a non-null value of the same type to update the - // type in the schema, or nothing to leave the type as it was. - - /* tslint:disable:no-empty */ - public visitSchema(schema: GraphQLSchema): void {} - public visitScalar(scalar: GraphQLScalarType): GraphQLScalarType | void | null {} - public visitObject(object: GraphQLObjectType): GraphQLObjectType | void | null {} - public visitFieldDefinition(field: GraphQLField, details: { - objectType: GraphQLObjectType | GraphQLInterfaceType, - }): GraphQLField | void | null {} - public visitArgumentDefinition(argument: GraphQLArgument, details: { - field: GraphQLField, - objectType: GraphQLObjectType | GraphQLInterfaceType, - }): GraphQLArgument | void | null {} - public visitInterface(iface: GraphQLInterfaceType): GraphQLInterfaceType | void | null {} - public visitUnion(union: GraphQLUnionType): GraphQLUnionType | void | null {} - public visitEnum(type: GraphQLEnumType): GraphQLEnumType | void | null {} - public visitEnumValue(value: GraphQLEnumValue, details: { - enumType: GraphQLEnumType, - }): GraphQLEnumValue | void | null {} - public visitInputObject(object: GraphQLInputObjectType): GraphQLInputObjectType | void | null {} - public visitInputFieldDefinition(field: GraphQLInputField, details: { - objectType: GraphQLInputObjectType, - }): GraphQLInputField | void | null {} - /* tslint:enable:no-empty */ -} - -// Generic function for visiting GraphQLSchema objects. -export function visitSchema( - schema: GraphQLSchema, - // To accommodate as many different visitor patterns as possible, the - // visitSchema function does not simply accept a single instance of the - // SchemaVisitor class, but instead accepts a function that takes the - // current VisitableSchemaType object and the name of a visitor method and - // returns an array of SchemaVisitor instances that implement the visitor - // method and have an interest in handling the given VisitableSchemaType - // object. In the simplest case, this function can always return an array - // containing a single visitor object, without even looking at the type or - // methodName parameters. In other cases, this function might sometimes - // return an empty array to indicate there are no visitors that should be - // applied to the given VisitableSchemaType object. For an example of a - // visitor pattern that benefits from this abstraction, see the - // SchemaDirectiveVisitor class below. - visitorSelector: ( - type: VisitableSchemaType, - methodName: string, - ) => SchemaVisitor[], -): GraphQLSchema { - // Helper function that calls visitorSelector and applies the resulting - // visitors to the given type, with arguments [type, ...args]. - function callMethod( - methodName: string, - type: T, - ...args: any[] - ): T { - visitorSelector(type, methodName).every(visitor => { - const newType = visitor[methodName](type, ...args); - - if (typeof newType === 'undefined') { - // Keep going without modifying type. - return true; - } - - if (methodName === 'visitSchema' || - type instanceof GraphQLSchema) { - throw new Error(`Method ${methodName} cannot replace schema with ${newType}`); - } - - if (newType === null) { - // Stop the loop and return null form callMethod, which will cause - // the type to be removed from the schema. - type = null; - return false; - } - - // Update type to the new type returned by the visitor method, so that - // later directives will see the new type, and callMethod will return - // the final type. - type = newType; - return true; - }); - - // If there were no directives for this type object, or if all visitor - // methods returned nothing, type will be returned unmodified. - return type; - } - - // Recursive helper function that calls any appropriate visitor methods for - // each object in the schema, then traverses the object's children (if any). - function visit(type: T): T { - if (type instanceof GraphQLSchema) { - // Unlike the other types, the root GraphQLSchema object cannot be - // replaced by visitor methods, because that would make life very hard - // for SchemaVisitor subclasses that rely on the original schema object. - callMethod('visitSchema', type); - - updateEachKey(type.getTypeMap(), (namedType, typeName) => { - if (! typeName.startsWith('__')) { - // Call visit recursively to let it determine which concrete - // subclass of GraphQLNamedType we found in the type map. Because - // we're using updateEachKey, the result of visit(namedType) may - // cause the type to be removed or replaced. - return visit(namedType); - } - }); - - return type; - } - - if (type instanceof GraphQLObjectType) { - // Note that callMethod('visitObject', type) may not actually call any - // methods, if there are no @directive annotations associated with this - // type, or if this SchemaDirectiveVisitor subclass does not override - // the visitObject method. - const newObject = callMethod('visitObject', type); - if (newObject) { - visitFields(newObject); - } - return newObject; - } - - if (type instanceof GraphQLInterfaceType) { - const newInterface = callMethod('visitInterface', type); - if (newInterface) { - visitFields(newInterface); - } - return newInterface; - } - - if (type instanceof GraphQLInputObjectType) { - const newInputObject = callMethod('visitInputObject', type); - - if (newInputObject) { - updateEachKey(newInputObject.getFields(), field => { - // Since we call a different method for input object fields, we - // can't reuse the visitFields function here. - return callMethod('visitInputFieldDefinition', field, { - objectType: newInputObject, - }); - }); - } - - return newInputObject; - } - - if (type instanceof GraphQLScalarType) { - return callMethod('visitScalar', type); - } - - if (type instanceof GraphQLUnionType) { - return callMethod('visitUnion', type); - } - - if (type instanceof GraphQLEnumType) { - const newEnum = callMethod('visitEnum', type); - - if (newEnum) { - updateEachKey(newEnum.getValues(), value => { - return callMethod('visitEnumValue', value, { - enumType: newEnum, - }); - }); - } - - return newEnum; - } - - throw new Error(`Unexpected schema type: ${type}`); - } - - function visitFields(type: GraphQLObjectType | GraphQLInterfaceType) { - updateEachKey(type.getFields(), field => { - // It would be nice if we could call visit(field) recursively here, but - // GraphQLField is merely a type, not a value that can be detected using - // an instanceof check, so we have to visit the fields in this lexical - // context, so that TypeScript can validate the call to - // visitFieldDefinition. - const newField = callMethod('visitFieldDefinition', field, { - // While any field visitor needs a reference to the field object, some - // field visitors may also need to know the enclosing (parent) type, - // perhaps to determine if the parent is a GraphQLObjectType or a - // GraphQLInterfaceType. To obtain a reference to the parent, a - // visitor method can have a second parameter, which will be an object - // with an .objectType property referring to the parent. - objectType: type, - }); - - if (newField && newField.args) { - updateEachKey(newField.args, arg => { - return callMethod('visitArgumentDefinition', arg, { - // Like visitFieldDefinition, visitArgumentDefinition takes a - // second parameter that provides additional context, namely the - // parent .field and grandparent .objectType. Remember that the - // current GraphQLSchema is always available via this.schema. - field: newField, - objectType: type, - }); - }); - } - - return newField; - }); - } - - visit(schema); - - // Return the original schema for convenience, even though it cannot have - // been replaced or removed by the code above. - return schema; -} - -type NamedTypeMap = { - [key: string]: GraphQLNamedType; -}; - -// Update any references to named schema types that disagree with the named -// types found in schema.getTypeMap(). -export function healSchema(schema: GraphQLSchema) { - heal(schema); - return schema; - - function heal(type: VisitableSchemaType) { - if (type instanceof GraphQLSchema) { - const originalTypeMap: NamedTypeMap = type.getTypeMap(); - const actualNamedTypeMap: NamedTypeMap = Object.create(null); - - // If any of the .name properties of the GraphQLNamedType objects in - // schema.getTypeMap() have changed, the keys of the type map need to - // be updated accordingly. - - each(originalTypeMap, (namedType, typeName) => { - if (typeName.startsWith('__')) { - return; - } - - const actualName = namedType.name; - if (actualName.startsWith('__')) { - return; - } - - if (hasOwn.call(actualNamedTypeMap, actualName)) { - throw new Error(`Duplicate schema type name ${actualName}`); - } - - actualNamedTypeMap[actualName] = namedType; - - // Note: we are deliberately leaving namedType in the schema by its - // original name (which might be different from actualName), so that - // references by that name can be healed. - }); - - // Now add back every named type by its actual name. - each(actualNamedTypeMap, (namedType, typeName) => { - originalTypeMap[typeName] = namedType; - }); - - // Directive declaration argument types can refer to named types. - each(type.getDirectives(), (decl: GraphQLDirective) => { - if (decl.args) { - each(decl.args, arg => { - arg.type = healType(arg.type); - }); - } - }); - - each(originalTypeMap, (namedType, typeName) => { - if (! typeName.startsWith('__')) { - heal(namedType); - } - }); - - updateEachKey(originalTypeMap, (namedType, typeName) => { - // Dangling references to renamed types should remain in the schema - // during healing, but must be removed now, so that the following - // invariant holds for all names: schema.getType(name).name === name - if (! typeName.startsWith('__') && - ! hasOwn.call(actualNamedTypeMap, typeName)) { - return null; - } - }); - - } else if (type instanceof GraphQLObjectType) { - healFields(type); - each(type.getInterfaces(), iface => heal(iface)); - - } else if (type instanceof GraphQLInterfaceType) { - healFields(type); - - } else if (type instanceof GraphQLInputObjectType) { - each(type.getFields(), field => { - field.type = healType(field.type); - }); - - } else if (type instanceof GraphQLScalarType) { - // Nothing to do. - - } else if (type instanceof GraphQLUnionType) { - updateEachKey(type.getTypes(), t => healType(t)); - - } else if (type instanceof GraphQLEnumType) { - // Nothing to do. - - } else { - throw new Error(`Unexpected schema type: ${type}`); - } - } - - function healFields(type: GraphQLObjectType | GraphQLInterfaceType) { - each(type.getFields(), field => { - field.type = healType(field.type); - if (field.args) { - each(field.args, arg => { - arg.type = healType(arg.type); - }); - } - }); - } - - function healType(type: T): T { - // Unwrap the two known wrapper types - if (type instanceof GraphQLList) { - type = new GraphQLList(healType(type.ofType)) as T; - } else if (type instanceof GraphQLNonNull) { - type = new GraphQLNonNull(healType(type.ofType)) as T; - } else if (isNamedType(type)) { - // If a type annotation on a field or an argument or a union member is - // any `GraphQLNamedType` with a `name`, then it must end up identical - // to `schema.getType(name)`, since `schema.getTypeMap()` is the source - // of truth for all named schema types. - const namedType = type as GraphQLNamedType; - const officialType = schema.getType(namedType.name); - if (officialType && namedType !== officialType) { - return officialType as T; - } - } - return type; - } -} - -// This class represents a reusable implementation of a @directive that may -// appear in a GraphQL schema written in Schema Definition Language. -// -// By overriding one or more visit{Object,Union,...} methods, a subclass -// registers interest in certain schema types, such as GraphQLObjectType, -// GraphQLUnionType, etc. When SchemaDirectiveVisitor.visitSchemaDirectives is -// called with a GraphQLSchema object and a map of visitor subclasses, the -// overidden methods of those subclasses allow the visitors to obtain -// references to any type objects that have @directives attached to them, -// enabling visitors to inspect or modify the schema as appropriate. -// -// For example, if a directive called @rest(url: "...") appears after a field -// definition, a SchemaDirectiveVisitor subclass could provide meaning to that -// directive by overriding the visitFieldDefinition method (which receives a -// GraphQLField parameter), and then the body of that visitor method could -// manipulate the field's resolver function to fetch data from a REST endpoint -// described by the url argument passed to the @rest directive: -// -// const typeDefs = ` -// type Query { -// people: [Person] @rest(url: "/api/v1/people") -// }`; -// -// const schema = makeExecutableSchema({ typeDefs }); -// -// SchemaDirectiveVisitor.visitSchemaDirectives(schema, { -// rest: class extends SchemaDirectiveVisitor { -// public visitFieldDefinition(field: GraphQLField) { -// const { url } = this.args; -// field.resolve = () => fetch(url); -// } -// } -// }); -// -// The subclass in this example is defined as an anonymous class expression, -// for brevity. A truly reusable SchemaDirectiveVisitor would most likely be -// defined in a library using a named class declaration, and then exported for -// consumption by other modules and packages. -// -// See below for a complete list of overridable visitor methods, their -// parameter types, and more details about the properties exposed by instances -// of the SchemaDirectiveVisitor class. - -export class SchemaDirectiveVisitor extends SchemaVisitor { - // The name of the directive this visitor is allowed to visit (that is, the - // identifier that appears after the @ character in the schema). Note that - // this property is per-instance rather than static because subclasses of - // SchemaDirectiveVisitor can be instantiated multiple times to visit - // directives of different names. In other words, SchemaDirectiveVisitor - // implementations are effectively anonymous, and it's up to the caller of - // SchemaDirectiveVisitor.visitSchemaDirectives to assign names to them. - public name: string; - - // A map from parameter names to argument values, as obtained from a - // specific occurrence of a @directive(arg1: value1, arg2: value2, ...) in - // the schema. Visitor methods may refer to this object via this.args. - public args: { [name: string]: any }; - - // A reference to the type object that this visitor was created to visit. - public visitedType: VisitableSchemaType; - - // A shared object that will be available to all visitor instances via - // this.context. Callers of visitSchemaDirectives can provide their own - // object, or just use the default empty object. - public context: { [key: string]: any }; - - // Override this method to return a custom GraphQLDirective (or modify one - // already present in the schema) to enforce argument types, provide default - // argument values, or specify schema locations where this @directive may - // appear. By default, any declaration found in the schema will be returned. - public static getDirectiveDeclaration( - directiveName: string, - schema: GraphQLSchema, - ): GraphQLDirective { - return schema.getDirective(directiveName); - } - - // Call SchemaDirectiveVisitor.visitSchemaDirectives to visit every - // @directive in the schema and create an appropriate SchemaDirectiveVisitor - // instance to visit the object decorated by the @directive. - public static visitSchemaDirectives( - schema: GraphQLSchema, - directiveVisitors: { - // The keys of this object correspond to directive names as they appear - // in the schema, and the values should be subclasses (not instances!) - // of the SchemaDirectiveVisitor class. This distinction is important - // because a new SchemaDirectiveVisitor instance will be created each - // time a matching directive is found in the schema AST, with arguments - // and other metadata specific to that occurrence. To help prevent the - // mistake of passing instances, the SchemaDirectiveVisitor constructor - // method is marked as protected. - [directiveName: string]: typeof SchemaDirectiveVisitor - }, - // Optional context object that will be available to all visitor instances - // via this.context. Defaults to an empty null-prototype object. - context: { - [key: string]: any - } = Object.create(null), - ): { - // The visitSchemaDirectives method returns a map from directive names to - // lists of SchemaDirectiveVisitors created while visiting the schema. - [directiveName: string]: SchemaDirectiveVisitor[], - } { - // If the schema declares any directives for public consumption, record - // them here so that we can properly coerce arguments when/if we encounter - // an occurrence of the directive while walking the schema below. - const declaredDirectives = - this.getDeclaredDirectives(schema, directiveVisitors); - - // Map from directive names to lists of SchemaDirectiveVisitor instances - // created while visiting the schema. - const createdVisitors: { - [directiveName: string]: SchemaDirectiveVisitor[] - } = Object.create(null); - Object.keys(directiveVisitors).forEach(directiveName => { - createdVisitors[directiveName] = []; - }); - - function visitorSelector( - type: VisitableSchemaType, - methodName: string, - ): SchemaDirectiveVisitor[] { - const visitors: SchemaDirectiveVisitor[] = []; - const directiveNodes = type.astNode && type.astNode.directives; - if (! directiveNodes) { - return visitors; - } - - directiveNodes.forEach(directiveNode => { - const directiveName = directiveNode.name.value; - if (! hasOwn.call(directiveVisitors, directiveName)) { - return; - } - - const visitorClass = directiveVisitors[directiveName]; - - // Avoid creating visitor objects if visitorClass does not override - // the visitor method named by methodName. - if (! visitorClass.implementsVisitorMethod(methodName)) { - return; - } - - const decl = declaredDirectives[directiveName]; - let args: { [key: string]: any }; - - if (decl) { - // If this directive was explicitly declared, use the declared - // argument types (and any default values) to check, coerce, and/or - // supply default values for the given arguments. - args = getArgumentValues(decl, directiveNode); - } else { - // If this directive was not explicitly declared, just convert the - // argument nodes to their corresponding JavaScript values. - args = Object.create(null); - directiveNode.arguments.forEach(arg => { - args[arg.name.value] = valueFromASTUntyped(arg.value); - }); - } - - // As foretold in comments near the top of the visitSchemaDirectives - // method, this is where instances of the SchemaDirectiveVisitor class - // get created and assigned names. While subclasses could override the - // constructor method, the constructor is marked as protected, so - // these are the only arguments that will ever be passed. - visitors.push(new visitorClass({ - name: directiveName, - args, - visitedType: type, - schema, - context, - })); - }); - - if (visitors.length > 0) { - visitors.forEach(visitor => { - createdVisitors[visitor.name].push(visitor); - }); - } - - return visitors; - } - - visitSchema(schema, visitorSelector); - - // Automatically update any references to named schema types replaced - // during the traversal, so implementors don't have to worry about that. - healSchema(schema); - - return createdVisitors; - } - - protected static getDeclaredDirectives( - schema: GraphQLSchema, - directiveVisitors: { - [directiveName: string]: typeof SchemaDirectiveVisitor - }, - ) { - const declaredDirectives: { - [directiveName: string]: GraphQLDirective, - } = Object.create(null); - - each(schema.getDirectives(), (decl: GraphQLDirective) => { - declaredDirectives[decl.name] = decl; - }); - - // If the visitor subclass overrides getDirectiveDeclaration, and it - // returns a non-null GraphQLDirective, use that instead of any directive - // declared in the schema itself. Reasoning: if a SchemaDirectiveVisitor - // goes to the trouble of implementing getDirectiveDeclaration, it should - // be able to rely on that implementation. - each(directiveVisitors, (visitorClass, directiveName) => { - const decl = visitorClass.getDirectiveDeclaration(directiveName, schema); - if (decl) { - declaredDirectives[directiveName] = decl; - } - }); - - each(declaredDirectives, (decl, name) => { - if (! hasOwn.call(directiveVisitors, name)) { - // SchemaDirectiveVisitors.visitSchemaDirectives might be called - // multiple times with partial directiveVisitors maps, so it's not - // necessarily an error for directiveVisitors to be missing an - // implementation of a directive that was declared in the schema. - return; - } - const visitorClass = directiveVisitors[name]; - - each(decl.locations, loc => { - const visitorMethodName = directiveLocationToVisitorMethodName(loc); - if (SchemaVisitor.implementsVisitorMethod(visitorMethodName) && - ! visitorClass.implementsVisitorMethod(visitorMethodName)) { - // While visitor subclasses may implement extra visitor methods, - // it's definitely a mistake if the GraphQLDirective declares itself - // applicable to certain schema locations, and the visitor subclass - // does not implement all the corresponding methods. - throw new Error( - `SchemaDirectiveVisitor for @${name} must implement ${visitorMethodName} method` - ); - } - }); - }); - - return declaredDirectives; - } - - // Mark the constructor protected to enforce passing SchemaDirectiveVisitor - // subclasses (not instances) to visitSchemaDirectives. - protected constructor(config: { - name: string - args: { [name: string]: any } - visitedType: VisitableSchemaType - schema: GraphQLSchema - context: { [key: string]: any } - }) { - super(); - this.name = config.name; - this.args = config.args; - this.visitedType = config.visitedType; - this.schema = config.schema; - this.context = config.context; - } -} - -// Convert a string like "FIELD_DEFINITION" to "visitFieldDefinition". -function directiveLocationToVisitorMethodName(loc: DirectiveLocationEnum) { - return 'visit' + loc.replace(/([^_]*)_?/g, (wholeMatch, part) => { - return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); - }); -} - -type IndexedObject = { [key: string]: V } | ReadonlyArray; - -function each( - arrayOrObject: IndexedObject, - callback: (value: V, key: string) => void, -) { - Object.keys(arrayOrObject).forEach(key => { - callback(arrayOrObject[key], key); - }); -} - -// A more powerful version of each that has the ability to replace or remove -// array or object keys. -function updateEachKey( - arrayOrObject: IndexedObject, - // The callback can return nothing to leave the key untouched, null to remove - // the key from the array or object, or a non-null V to replace the value. - callback: (value: V, key: string) => V | void, -) { - let deletedCount = 0; - - Object.keys(arrayOrObject).forEach(key => { - const result = callback(arrayOrObject[key], key); - - if (typeof result === 'undefined') { - return; - } - - if (result === null) { - delete arrayOrObject[key]; - deletedCount++; - return; - } - - arrayOrObject[key] = result; - }); - - if (deletedCount > 0 && Array.isArray(arrayOrObject)) { - // Remove any holes from the array due to deleted elements. - arrayOrObject.splice(0).forEach(elem => { - arrayOrObject.push(elem); - }); - } -} - -// Similar to the graphql-js function of the same name, slightly simplified: -// https://github.com/graphql/graphql-js/blob/master/src/utilities/valueFromASTUntyped.js -function valueFromASTUntyped( - valueNode: ValueNode, -): any { - switch (valueNode.kind) { - case Kind.NULL: - return null; - case Kind.INT: - return parseInt(valueNode.value, 10); - case Kind.FLOAT: - return parseFloat(valueNode.value); - case Kind.STRING: - case Kind.ENUM: - case Kind.BOOLEAN: - return valueNode.value; - case Kind.LIST: - return valueNode.values.map(valueFromASTUntyped); - case Kind.OBJECT: - const obj = Object.create(null); - valueNode.fields.forEach(field => { - obj[field.name.value] = valueFromASTUntyped(field.value); - }); - return obj; - /* istanbul ignore next */ - default: - throw new Error('Unexpected value kind: ' + valueNode.kind); - } -} diff --git a/src/stitch/createMergedResolver.ts b/src/stitch/createMergedResolver.ts new file mode 100644 index 00000000000..940a5d130b3 --- /dev/null +++ b/src/stitch/createMergedResolver.ts @@ -0,0 +1,54 @@ +import { IFieldResolver } from '../Interfaces'; + +import { unwrapResult, dehoistResult } from './proxiedResult'; +import defaultMergedResolver from './defaultMergedResolver'; + +export function createMergedResolver({ + fromPath, + dehoist, + delimeter = '__gqltf__', +}: { + fromPath?: Array; + dehoist?: boolean; + delimeter?: string; +}): IFieldResolver { + const parentErrorResolver: IFieldResolver = ( + parent, + args, + context, + info, + ) => + parent instanceof Error + ? parent + : defaultMergedResolver(parent, args, context, info); + + const unwrappingResolver: IFieldResolver = + fromPath != null + ? (parent, args, context, info) => + parentErrorResolver( + unwrapResult(parent, info, fromPath), + args, + context, + info, + ) + : parentErrorResolver; + + const dehoistingResolver: IFieldResolver = dehoist + ? (parent, args, context, info) => + unwrappingResolver( + dehoistResult(parent, delimeter), + args, + context, + info, + ) + : unwrappingResolver; + + const noParentResolver: IFieldResolver = ( + parent, + args, + context, + info, + ) => (parent ? dehoistingResolver(parent, args, context, info) : {}); + + return noParentResolver; +} diff --git a/src/stitch/defaultMergedResolver.ts b/src/stitch/defaultMergedResolver.ts new file mode 100644 index 00000000000..5a55cd40491 --- /dev/null +++ b/src/stitch/defaultMergedResolver.ts @@ -0,0 +1,38 @@ +import { defaultFieldResolver } from 'graphql'; + +import { IGraphQLToolsResolveInfo } from '../Interfaces'; +import { handleResult } from '../delegate/checkResultAndHandleErrors'; + +import { getErrors, getSubschema } from './proxiedResult'; +import { getResponseKeyFromInfo } from './getResponseKeyFromInfo'; + +/** + * Resolver that knows how to: + * a) handle aliases for proxied schemas + * b) handle errors from proxied schemas + * c) handle external to internal enum coversion + */ +export default function defaultMergedResolver( + parent: Record, + args: Record, + context: Record, + info: IGraphQLToolsResolveInfo, +) { + if (!parent) { + return null; + } + + const responseKey = getResponseKeyFromInfo(info); + const errors = getErrors(parent, responseKey); + + // check to see if parent is not a proxied result, i.e. if parent resolver was manually overwritten + // See https://github.com/apollographql/graphql-tools/issues/967 + if (!errors) { + return defaultFieldResolver(parent, args, context, info); + } + + const result = parent[responseKey]; + const subschema = getSubschema(parent, responseKey); + + return handleResult(result, errors, subschema, context, info); +} diff --git a/src/stitch/errors.ts b/src/stitch/errors.ts new file mode 100644 index 00000000000..3076d7b0487 --- /dev/null +++ b/src/stitch/errors.ts @@ -0,0 +1,97 @@ +import { GraphQLError, ASTNode } from 'graphql'; + +export function relocatedError( + originalError: Error | GraphQLError, + nodes: ReadonlyArray, + path: ReadonlyArray, +): GraphQLError { + if (Array.isArray((originalError as GraphQLError).path)) { + return new GraphQLError( + (originalError as GraphQLError).message, + (originalError as GraphQLError).nodes, + (originalError as GraphQLError).source, + (originalError as GraphQLError).positions, + path != null ? path : (originalError as GraphQLError).path, + (originalError as GraphQLError).originalError, + (originalError as GraphQLError).extensions, + ); + } + + if (originalError == null) { + return new GraphQLError( + undefined, + nodes, + undefined, + undefined, + path, + originalError, + ); + } + + return new GraphQLError( + originalError.message, + (originalError as GraphQLError).nodes != null + ? (originalError as GraphQLError).nodes + : nodes, + (originalError as GraphQLError).source, + (originalError as GraphQLError).positions, + path, + originalError, + ); +} + +export function slicedError(originalError: GraphQLError) { + return relocatedError( + originalError, + originalError.nodes, + originalError.path != null ? originalError.path.slice(1) : undefined, + ); +} + +export function getErrorsByPathSegment( + errors: ReadonlyArray, +): Record> { + const record = Object.create(null); + errors.forEach((error) => { + if (!error.path || error.path.length < 2) { + return; + } + + const pathSegment = error.path[1]; + + const current = record[pathSegment] != null ? record[pathSegment] : []; + current.push(slicedError(error)); + record[pathSegment] = current; + }); + + return record; +} + +class CombinedError extends Error { + public errors: ReadonlyArray; + constructor(message: string, errors: ReadonlyArray) { + super(message); + this.errors = errors; + } +} + +export function combineErrors( + errors: ReadonlyArray, +): GraphQLError | CombinedError { + if (errors.length === 1) { + return new GraphQLError( + errors[0].message, + errors[0].nodes, + errors[0].source, + errors[0].positions, + errors[0].path, + errors[0].originalError, + errors[0].extensions, + ); + } + + return new CombinedError( + errors.map((error) => error.message).join('\n'), + errors, + ); +} diff --git a/src/stitching/getResponseKeyFromInfo.ts b/src/stitch/getResponseKeyFromInfo.ts similarity index 75% rename from src/stitching/getResponseKeyFromInfo.ts rename to src/stitch/getResponseKeyFromInfo.ts index d342a4ca56b..822cc2f7501 100644 --- a/src/stitching/getResponseKeyFromInfo.ts +++ b/src/stitch/getResponseKeyFromInfo.ts @@ -6,5 +6,7 @@ import { GraphQLResolveInfo } from 'graphql'; * @param info The info argument to the resolver. */ export function getResponseKeyFromInfo(info: GraphQLResolveInfo) { - return info.fieldNodes[0].alias ? info.fieldNodes[0].alias.value : info.fieldName; + return info.fieldNodes[0].alias != null + ? info.fieldNodes[0].alias.value + : info.fieldName; } diff --git a/src/stitch/index.ts b/src/stitch/index.ts new file mode 100644 index 00000000000..2da2ce29c2e --- /dev/null +++ b/src/stitch/index.ts @@ -0,0 +1,14 @@ +import introspectSchema from './introspectSchema'; +import mergeSchemas from './mergeSchemas'; +import defaultMergedResolver from './defaultMergedResolver'; +import { createMergedResolver } from './createMergedResolver'; +import { dehoistResult, unwrapResult } from './proxiedResult'; + +export { + introspectSchema, + mergeSchemas, + defaultMergedResolver, + createMergedResolver, + dehoistResult, + unwrapResult, +}; diff --git a/src/stitch/introspectSchema.ts b/src/stitch/introspectSchema.ts new file mode 100644 index 00000000000..2bb3da4450d --- /dev/null +++ b/src/stitch/introspectSchema.ts @@ -0,0 +1,53 @@ +import { ApolloLink } from 'apollo-link'; +import { + GraphQLSchema, + DocumentNode, + getIntrospectionQuery, + buildClientSchema, + parse, +} from 'graphql'; + +import { Fetcher } from '../Interfaces'; + +import { combineErrors } from './errors'; +import linkToFetcher from './linkToFetcher'; + +const parsedIntrospectionQuery: DocumentNode = parse(getIntrospectionQuery()); + +export default function introspectSchema( + linkOrFetcher: ApolloLink | Fetcher, + linkContext?: { [key: string]: any }, +): Promise { + const fetcher = + linkOrFetcher instanceof ApolloLink + ? linkToFetcher(linkOrFetcher) + : linkOrFetcher; + + return fetcher({ + query: parsedIntrospectionQuery, + context: linkContext, + }).then((introspectionResult) => { + if ( + (Array.isArray(introspectionResult.errors) && + introspectionResult.errors.length) || + !introspectionResult.data.__schema + ) { + if (Array.isArray(introspectionResult.errors)) { + const combinedError: Error = combineErrors(introspectionResult.errors); + throw combinedError; + } else { + throw new Error( + 'Could not obtain introspection result, received: ' + + JSON.stringify(introspectionResult), + ); + } + } else { + const schema = buildClientSchema( + introspectionResult.data as { + __schema: any; + }, + ); + return schema; + } + }); +} diff --git a/src/stitch/linkToFetcher.ts b/src/stitch/linkToFetcher.ts new file mode 100644 index 00000000000..e64e9ff19cf --- /dev/null +++ b/src/stitch/linkToFetcher.ts @@ -0,0 +1,10 @@ +import { ApolloLink, toPromise, execute, ExecutionResult } from 'apollo-link'; + +import { Fetcher, IFetcherOperation } from '../Interfaces'; + +export { execute } from 'apollo-link'; + +export default function linkToFetcher(link: ApolloLink): Fetcher { + return (fetcherOperation: IFetcherOperation): Promise => + toPromise(execute(link, fetcherOperation)); +} diff --git a/src/stitch/makeMergedType.ts b/src/stitch/makeMergedType.ts new file mode 100644 index 00000000000..7fd70acfc1b --- /dev/null +++ b/src/stitch/makeMergedType.ts @@ -0,0 +1,18 @@ +import { GraphQLType, isAbstractType, isObjectType } from 'graphql'; + +import defaultMergedResolver from './defaultMergedResolver'; +import resolveFromParentTypename from './resolveFromParentTypename'; + +export function makeMergedType(type: GraphQLType): void { + if (isObjectType(type)) { + type.isTypeOf = undefined; + + const fieldMap = type.getFields(); + Object.keys(fieldMap).forEach((fieldName) => { + fieldMap[fieldName].resolve = defaultMergedResolver; + fieldMap[fieldName].subscribe = null; + }); + } else if (isAbstractType(type)) { + type.resolveType = (parent) => resolveFromParentTypename(parent); + } +} diff --git a/src/stitching/mapAsyncIterator.ts b/src/stitch/mapAsyncIterator.ts similarity index 95% rename from src/stitching/mapAsyncIterator.ts rename to src/stitch/mapAsyncIterator.ts index 1aeaa1cb41b..34ecee64a91 100644 --- a/src/stitching/mapAsyncIterator.ts +++ b/src/stitch/mapAsyncIterator.ts @@ -34,7 +34,7 @@ export default function mapAsyncIterator( asyncMapValue(error, reject).then(iteratorResult, abruptClose); } - return ({ + return { next() { return iterator.next().then(mapResult, mapReject); }, @@ -52,14 +52,14 @@ export default function mapAsyncIterator( [$$asyncIterator]() { return this; }, - } as any); + } as any; } function asyncMapValue( value: T, callback: (value: T) => Promise | U, ): Promise { - return new Promise(resolve => resolve(callback(value))); + return new Promise((resolve) => resolve(callback(value))); } function iteratorResult(value: T): IteratorResult { diff --git a/src/stitch/mergeFields.ts b/src/stitch/mergeFields.ts new file mode 100644 index 00000000000..1f3a616c6d4 --- /dev/null +++ b/src/stitch/mergeFields.ts @@ -0,0 +1,169 @@ +import { FieldNode, SelectionNode, Kind } from 'graphql'; + +import { + SubschemaConfig, + IGraphQLToolsResolveInfo, + MergedTypeInfo, +} from '../Interfaces'; + +import { mergeProxiedResults } from './proxiedResult'; + +function buildDelegationPlan( + mergedTypeInfo: MergedTypeInfo, + originalSelections: Array, + sourceSubschemas: Array, + targetSubschemas: Array, +): { + delegationMap: Map>; + unproxiableSelections: Array; + proxiableSubschemas: Array; + nonProxiableSubschemas: Array; +} { + // 1. calculate if possible to delegate to given subschema + // TODO: change logic so that required selection set can be spread across multiple subschemas? + + const proxiableSubschemas: Array = []; + const nonProxiableSubschemas: Array = []; + + targetSubschemas.forEach((t) => { + if ( + sourceSubschemas.some((s) => { + const selectionSet = mergedTypeInfo.selectionSets.get(t); + return mergedTypeInfo.containsSelectionSet.get(s).get(selectionSet); + }) + ) { + proxiableSubschemas.push(t); + } else { + nonProxiableSubschemas.push(t); + } + }); + + const { uniqueFields, nonUniqueFields } = mergedTypeInfo; + const unproxiableSelections: Array = []; + + // 2. for each selection: + + const delegationMap: Map> = new Map(); + originalSelections.forEach((selection) => { + // 2a. use uniqueFields map to assign fields to subschema if one of possible subschemas + + const uniqueSubschema: SubschemaConfig = uniqueFields[selection.name.value]; + if (uniqueSubschema != null) { + if (proxiableSubschemas.includes(uniqueSubschema)) { + const existingSubschema = delegationMap.get(uniqueSubschema); + if (existingSubschema != null) { + existingSubschema.push(selection); + } else { + delegationMap.set(uniqueSubschema, [selection]); + } + } else { + unproxiableSelections.push(selection); + } + } else { + // 2b. use nonUniqueFields to assign to a possible subschema, + // preferring one of the subschemas already targets of delegation + + let nonUniqueSubschemas: Array = + nonUniqueFields[selection.name.value]; + nonUniqueSubschemas = nonUniqueSubschemas.filter((s) => + proxiableSubschemas.includes(s), + ); + if (nonUniqueSubschemas != null) { + const subschemas: Array = Array.from( + delegationMap.keys(), + ); + const existingSubschema = nonUniqueSubschemas.find((s) => + subschemas.includes(s), + ); + if (existingSubschema != null) { + delegationMap.get(existingSubschema).push(selection); + } else { + delegationMap.set(nonUniqueSubschemas[0], [selection]); + } + } else { + unproxiableSelections.push(selection); + } + } + }); + + return { + delegationMap, + unproxiableSelections, + proxiableSubschemas, + nonProxiableSubschemas, + }; +} + +export function mergeFields( + mergedTypeInfo: MergedTypeInfo, + typeName: string, + object: any, + originalSelections: Array, + sourceSubschemas: Array, + targetSubschemas: Array, + context: Record, + info: IGraphQLToolsResolveInfo, +): any { + if (!originalSelections.length) { + return object; + } + + const { + delegationMap, + unproxiableSelections, + proxiableSubschemas, + nonProxiableSubschemas, + } = buildDelegationPlan( + mergedTypeInfo, + originalSelections, + sourceSubschemas, + targetSubschemas, + ); + + if (!delegationMap.size) { + return object; + } + + const maybePromises: Promise | any = []; + delegationMap.forEach( + (selections: Array, s: SubschemaConfig) => { + const maybePromise = s.merge[typeName].resolve(object, context, info, s, { + kind: Kind.SELECTION_SET, + selections, + }); + maybePromises.push(maybePromise); + }, + ); + + let containsPromises = false; + for (const maybePromise of maybePromises) { + if (maybePromise instanceof Promise) { + containsPromises = true; + break; + } + } + + return containsPromises + ? Promise.all(maybePromises).then((results) => + mergeFields( + mergedTypeInfo, + typeName, + mergeProxiedResults(object, ...results), + unproxiableSelections, + sourceSubschemas.concat(proxiableSubschemas), + nonProxiableSubschemas, + context, + info, + ), + ) + : mergeFields( + mergedTypeInfo, + typeName, + mergeProxiedResults(object, ...maybePromises), + unproxiableSelections, + sourceSubschemas.concat(proxiableSubschemas), + nonProxiableSubschemas, + context, + info, + ); +} diff --git a/src/stitch/mergeInfo.ts b/src/stitch/mergeInfo.ts new file mode 100644 index 00000000000..dd83913ecab --- /dev/null +++ b/src/stitch/mergeInfo.ts @@ -0,0 +1,331 @@ +import { + GraphQLNamedType, + GraphQLObjectType, + GraphQLSchema, + Kind, + SelectionNode, + SelectionSetNode, + isObjectType, + isScalarType, +} from 'graphql'; +import { TypeMap } from 'graphql/type/schema'; + +import { + IDelegateToSchemaOptions, + MergeInfo, + IResolversParameter, + isSubschemaConfig, + SubschemaConfig, + IGraphQLToolsResolveInfo, + MergedTypeInfo, + Transform, +} from '../Interfaces'; +import { ExpandAbstractTypes, AddReplacementFragments } from '../wrap/index'; +import { + parseFragmentToInlineFragment, + concatInlineFragments, + typeContainsSelectionSet, + parseSelectionSet, +} from '../utils/index'; + +import delegateToSchema from '../delegate/delegateToSchema'; + +type MergeTypeCandidate = { + type: GraphQLNamedType; + schema?: GraphQLSchema; + subschema?: GraphQLSchema | SubschemaConfig; + transformedSubschema?: GraphQLSchema; +}; + +export function createMergeInfo( + allSchemas: Array, + typeCandidates: { [name: string]: Array }, + mergeTypes?: + | boolean + | Array + | (( + typeName: string, + mergeTypeCandidates: Array, + ) => boolean), +): MergeInfo { + return { + delegate( + operation: 'query' | 'mutation' | 'subscription', + fieldName: string, + args: { [key: string]: any }, + context: { [key: string]: any }, + info: IGraphQLToolsResolveInfo, + transforms: Array = [], + ) { + const schema = guessSchemaByRootField(allSchemas, operation, fieldName); + const expandTransforms = new ExpandAbstractTypes(info.schema, schema); + const fragmentTransform = new AddReplacementFragments( + schema, + info.mergeInfo.replacementFragments, + ); + return delegateToSchema({ + schema, + operation, + fieldName, + args, + context, + info, + transforms: [...transforms, expandTransforms, fragmentTransform], + }); + }, + + delegateToSchema(options: IDelegateToSchemaOptions) { + return delegateToSchema({ + ...options, + transforms: options.transforms, + }); + }, + fragments: [], + replacementSelectionSets: undefined, + replacementFragments: undefined, + mergedTypes: createMergedTypes(typeCandidates, mergeTypes), + }; +} + +function createMergedTypes( + typeCandidates: { [name: string]: Array }, + mergeTypes?: + | boolean + | Array + | (( + typeName: string, + mergeTypeCandidates: Array, + ) => boolean), +): Record { + const mergedTypes: Record = {}; + + Object.keys(typeCandidates).forEach((typeName) => { + if (isObjectType(typeCandidates[typeName][0].type)) { + const mergedTypeCandidates = typeCandidates[typeName].filter( + (typeCandidate) => + typeCandidate.subschema != null && + isSubschemaConfig(typeCandidate.subschema) && + typeCandidate.subschema.merge != null && + typeCandidate.subschema.merge[typeName] != null, + ); + + if ( + mergeTypes === true || + (typeof mergeTypes === 'function' && + mergeTypes(typeName, typeCandidates[typeName])) || + (Array.isArray(mergeTypes) && mergeTypes.includes(typeName)) || + mergedTypeCandidates.length + ) { + const subschemas: Array = []; + + let requiredSelections: Array = [ + parseSelectionSet('{ __typename }').selections[0], + ]; + const fields = Object.create({}); + const typeMaps: Map = new Map(); + const selectionSets: Map = new Map(); + + mergedTypeCandidates.forEach((typeCandidate) => { + const subschemaConfig = typeCandidate.subschema as SubschemaConfig; + const transformedSubschema = typeCandidate.transformedSubschema; + typeMaps.set(subschemaConfig, transformedSubschema.getTypeMap()); + const type = transformedSubschema.getType( + typeName, + ) as GraphQLObjectType; + const fieldMap = type.getFields(); + Object.keys(fieldMap).forEach((fieldName) => { + if (fields[fieldName] == null) { + fields[fieldName] = []; + } + fields[fieldName].push(subschemaConfig); + }); + + const mergedTypeConfig = subschemaConfig.merge[typeName]; + + if (mergedTypeConfig.selectionSet) { + const selectionSet = parseSelectionSet( + mergedTypeConfig.selectionSet, + ); + requiredSelections = requiredSelections.concat( + selectionSet.selections, + ); + selectionSets.set(subschemaConfig, selectionSet); + } + + if (!mergedTypeConfig.resolve) { + mergedTypeConfig.resolve = ( + originalResult, + context, + info, + subschema, + selectionSet, + ) => + delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: mergedTypeConfig.fieldName, + args: mergedTypeConfig.args(originalResult), + selectionSet, + context, + info, + skipTypeMerging: true, + }); + } + + subschemas.push(subschemaConfig); + }); + + mergedTypes[typeName] = { + subschemas, + typeMaps, + selectionSets, + containsSelectionSet: new Map(), + uniqueFields: Object.create({}), + nonUniqueFields: Object.create({}), + }; + + subschemas.forEach((subschema) => { + const type = typeMaps.get(subschema)[typeName] as GraphQLObjectType; + const subschemaMap = new Map(); + subschemas + .filter((s) => s !== subschema) + .forEach((s) => { + const selectionSet = selectionSets.get(s); + if ( + selectionSet != null && + typeContainsSelectionSet(type, selectionSet) + ) { + subschemaMap.set(selectionSet, true); + } + }); + mergedTypes[typeName].containsSelectionSet.set( + subschema, + subschemaMap, + ); + }); + + Object.keys(fields).forEach((fieldName) => { + const supportedBySubschemas = fields[fieldName]; + if (supportedBySubschemas.length === 1) { + mergedTypes[typeName].uniqueFields[fieldName] = + supportedBySubschemas[0]; + } else { + mergedTypes[typeName].nonUniqueFields[ + fieldName + ] = supportedBySubschemas; + } + }); + + mergedTypes[typeName].selectionSet = { + kind: Kind.SELECTION_SET, + selections: requiredSelections, + }; + } + } + }); + + return mergedTypes; +} + +export function completeMergeInfo( + mergeInfo: MergeInfo, + resolvers: IResolversParameter, +): MergeInfo { + const replacementSelectionSets = Object.create(null); + + Object.keys(resolvers).forEach((typeName) => { + const type = resolvers[typeName]; + if (isScalarType(type)) { + return; + } + Object.keys(type).forEach((fieldName) => { + const field = type[fieldName]; + if (field.selectionSet) { + const selectionSet = parseSelectionSet(field.selectionSet); + if (replacementSelectionSets[typeName] == null) { + replacementSelectionSets[typeName] = {}; + } + if (replacementSelectionSets[typeName][fieldName] == null) { + replacementSelectionSets[typeName][fieldName] = { + kind: Kind.SELECTION_SET, + selections: [], + }; + } + replacementSelectionSets[typeName][ + fieldName + ].selections = replacementSelectionSets[typeName][ + fieldName + ].selections.concat(selectionSet.selections); + } + if (field.fragment) { + mergeInfo.fragments.push({ + field: fieldName, + fragment: field.fragment, + }); + } + }); + }); + + const mapping = {}; + mergeInfo.fragments.forEach(({ field, fragment }) => { + const parsedFragment = parseFragmentToInlineFragment(fragment); + const actualTypeName = parsedFragment.typeCondition.name.value; + if (mapping[actualTypeName] == null) { + mapping[actualTypeName] = {}; + } + if (mapping[actualTypeName][field] == null) { + mapping[actualTypeName][field] = []; + } + mapping[actualTypeName][field].push(parsedFragment); + }); + + const replacementFragments = Object.create(null); + Object.keys(mapping).forEach((typeName) => { + Object.keys(mapping[typeName]).forEach((field) => { + if (replacementFragments[typeName] == null) { + replacementFragments[typeName] = {}; + } + replacementFragments[typeName][field] = concatInlineFragments( + typeName, + mapping[typeName][field], + ); + }); + }); + + mergeInfo.replacementSelectionSets = replacementSelectionSets; + mergeInfo.replacementFragments = replacementFragments; + + return mergeInfo; +} + +function operationToRootType( + operation: 'query' | 'mutation' | 'subscription', + schema: GraphQLSchema, +): GraphQLObjectType { + if (operation === 'subscription') { + return schema.getSubscriptionType(); + } else if (operation === 'mutation') { + return schema.getMutationType(); + } + + return schema.getQueryType(); +} + +function guessSchemaByRootField( + schemas: Array, + operation: 'query' | 'mutation' | 'subscription', + fieldName: string, +): GraphQLSchema { + for (const schema of schemas) { + const rootObject = operationToRootType(operation, schema); + if (rootObject != null) { + const fields = rootObject.getFields(); + if (fields[fieldName] != null) { + return schema; + } + } + } + throw new Error( + `Could not find subschema with field \`${operation}.${fieldName}\``, + ); +} diff --git a/src/stitch/mergeSchemas.ts b/src/stitch/mergeSchemas.ts new file mode 100644 index 00000000000..b8c6bd39457 --- /dev/null +++ b/src/stitch/mergeSchemas.ts @@ -0,0 +1,406 @@ +import { + DocumentNode, + GraphQLNamedType, + GraphQLObjectType, + GraphQLSchema, + getNamedType, + isNamedType, + parse, + Kind, + GraphQLDirective, + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLEnumType, + ASTNode, + isSchema, + isDirective, + isScalarType, + isObjectType, + isInterfaceType, + isUnionType, + isEnumType, +} from 'graphql'; + +import { + OnTypeConflict, + IResolversParameter, + isSubschemaConfig, + SchemaLikeObject, + IResolvers, + SubschemaConfig, +} from '../Interfaces'; +import { + extractExtensionDefinitions, + addResolversToSchema, +} from '../generate/index'; +import { wrapSchema } from '../wrap/index'; +import { + SchemaDirectiveVisitor, + cloneDirective, + healTypes, + forEachField, + mergeDeep, + graphqlVersion, +} from '../utils/index'; +import { toConfig, extendSchema } from '../polyfills/index'; + +import typeFromAST from './typeFromAST'; +import { createMergeInfo, completeMergeInfo } from './mergeInfo'; + +type MergeTypeCandidate = { + type: GraphQLNamedType; + schema?: GraphQLSchema; + subschema?: GraphQLSchema | SubschemaConfig; + transformedSubschema?: GraphQLSchema; +}; + +type CandidateSelector = ( + candidates: Array, +) => MergeTypeCandidate; + +export default function mergeSchemas({ + subschemas = [], + types = [], + typeDefs, + schemas: schemaLikeObjects = [], + onTypeConflict, + resolvers = {}, + schemaDirectives, + inheritResolversFromInterfaces, + mergeTypes = false, + mergeDirectives, + queryTypeName = 'Query', + mutationTypeName = 'Mutation', + subscriptionTypeName = 'Subscription', +}: { + subschemas?: Array; + types?: Array; + typeDefs?: string | DocumentNode; + schemas?: Array; + onTypeConflict?: OnTypeConflict; + resolvers?: IResolversParameter; + schemaDirectives?: { [name: string]: typeof SchemaDirectiveVisitor }; + inheritResolversFromInterfaces?: boolean; + mergeTypes?: + | boolean + | Array + | (( + typeName: string, + mergeTypeCandidates: Array, + ) => boolean); + mergeDirectives?: boolean; + queryTypeName?: string; + mutationTypeName?: string; + subscriptionTypeName?: string; +}): GraphQLSchema { + const allSchemas: Array = []; + const typeCandidates: { [name: string]: Array } = {}; + const typeMap: { [name: string]: GraphQLNamedType } = {}; + const extensions: Array = []; + const directives: Array = []; + + let schemas: Array = [...subschemas]; + if (typeDefs) { + schemas.push(typeDefs); + } + if (types != null) { + schemas.push(types); + } + schemas = [...schemas, ...schemaLikeObjects]; + + schemas.forEach((schemaLikeObject) => { + if (isSchema(schemaLikeObject) || isSubschemaConfig(schemaLikeObject)) { + const schema = wrapSchema(schemaLikeObject); + + allSchemas.push(schema); + + const operationTypes = { + [queryTypeName]: schema.getQueryType(), + [mutationTypeName]: schema.getMutationType(), + [subscriptionTypeName]: schema.getSubscriptionType(), + }; + + Object.keys(operationTypes).forEach((typeName) => { + if (operationTypes[typeName] != null) { + addTypeCandidate(typeCandidates, typeName, { + schema, + type: operationTypes[typeName], + subschema: schemaLikeObject, + transformedSubschema: schema, + }); + } + }); + + if (mergeDirectives) { + const directiveInstances = schema.getDirectives(); + directiveInstances.forEach((directive) => { + directives.push(directive); + }); + } + + const originalTypeMap = schema.getTypeMap(); + Object.keys(originalTypeMap).forEach((typeName) => { + const type: GraphQLNamedType = originalTypeMap[typeName]; + if ( + isNamedType(type) && + getNamedType(type).name.slice(0, 2) !== '__' && + type !== operationTypes.Query && + type !== operationTypes.Mutation && + type !== operationTypes.Subscription + ) { + addTypeCandidate(typeCandidates, type.name, { + schema, + type, + subschema: schemaLikeObject, + transformedSubschema: schema, + }); + } + }); + } else if ( + typeof schemaLikeObject === 'string' || + (schemaLikeObject != null && + (schemaLikeObject as ASTNode).kind === Kind.DOCUMENT) + ) { + const parsedSchemaDocument = + typeof schemaLikeObject === 'string' + ? parse(schemaLikeObject) + : (schemaLikeObject as DocumentNode); + + parsedSchemaDocument.definitions.forEach((def) => { + const type = typeFromAST(def); + if (isDirective(type) && mergeDirectives) { + directives.push(type); + } else if (type != null && !isDirective(type)) { + addTypeCandidate(typeCandidates, type.name, { + type, + }); + } + }); + + const extensionsDocument = extractExtensionDefinitions( + parsedSchemaDocument, + ); + if (extensionsDocument.definitions.length > 0) { + extensions.push(extensionsDocument); + } + } else if (Array.isArray(schemaLikeObject)) { + schemaLikeObject.forEach((type) => { + addTypeCandidate(typeCandidates, type.name, { + type, + }); + }); + } else { + throw new Error('Invalid schema passed'); + } + }); + + let mergeInfo = createMergeInfo(allSchemas, typeCandidates, mergeTypes); + + let finalResolvers: IResolvers; + if (typeof resolvers === 'function') { + finalResolvers = resolvers(mergeInfo); + } else if (Array.isArray(resolvers)) { + finalResolvers = resolvers.reduce( + (left, right) => + mergeDeep(left, typeof right === 'function' ? right(mergeInfo) : right), + {}, + ); + if (Array.isArray(resolvers)) { + finalResolvers = resolvers.reduce(mergeDeep, {}); + } + } else { + finalResolvers = resolvers; + } + + if (finalResolvers == null) { + finalResolvers = {}; + } + + mergeInfo = completeMergeInfo(mergeInfo, finalResolvers); + + Object.keys(typeCandidates).forEach((typeName) => { + if ( + typeName === queryTypeName || + typeName === mutationTypeName || + typeName === subscriptionTypeName || + (mergeTypes === true && + !isScalarType(typeCandidates[typeName][0].type)) || + (typeof mergeTypes === 'function' && + mergeTypes(typeName, typeCandidates[typeName])) || + (Array.isArray(mergeTypes) && mergeTypes.includes(typeName)) || + mergeInfo.mergedTypes[typeName] != null + ) { + typeMap[typeName] = merge(typeName, typeCandidates[typeName]); + } else { + const candidateSelector = + onTypeConflict != null + ? onTypeConflictToCandidateSelector(onTypeConflict) + : (cands: Array) => cands[cands.length - 1]; + typeMap[typeName] = candidateSelector(typeCandidates[typeName]).type; + } + }); + + healTypes(typeMap, directives, { skipPruning: true }); + + let mergedSchema = new GraphQLSchema({ + query: typeMap[queryTypeName] as GraphQLObjectType, + mutation: typeMap[mutationTypeName] as GraphQLObjectType, + subscription: typeMap[subscriptionTypeName] as GraphQLObjectType, + types: Object.keys(typeMap).map((key) => typeMap[key]), + directives: directives.length + ? directives.map((directive) => cloneDirective(directive)) + : undefined, + }); + + extensions.forEach((extension) => { + mergedSchema = extendSchema(mergedSchema, extension, { + commentDescriptions: true, + }); + }); + + addResolversToSchema({ + schema: mergedSchema, + resolvers: finalResolvers, + inheritResolversFromInterfaces, + }); + + forEachField(mergedSchema, (field) => { + if (field.resolve != null) { + const fieldResolver = field.resolve; + field.resolve = (parent, args, context, info) => { + const newInfo = { ...info, mergeInfo }; + return fieldResolver(parent, args, context, newInfo); + }; + } + if (field.subscribe != null) { + const fieldResolver = field.subscribe; + field.subscribe = (parent, args, context, info) => { + const newInfo = { ...info, mergeInfo }; + return fieldResolver(parent, args, context, newInfo); + }; + } + }); + + if (schemaDirectives != null) { + SchemaDirectiveVisitor.visitSchemaDirectives( + mergedSchema, + schemaDirectives, + ); + } + + return mergedSchema; +} + +function addTypeCandidate( + typeCandidates: { [name: string]: Array }, + name: string, + typeCandidate: MergeTypeCandidate, +) { + if (!typeCandidates[name]) { + typeCandidates[name] = []; + } + typeCandidates[name].push(typeCandidate); +} + +function onTypeConflictToCandidateSelector( + onTypeConflict: OnTypeConflict, +): CandidateSelector { + return (cands) => + cands.reduce((prev, next) => { + const type = onTypeConflict(prev.type, next.type, { + left: { + schema: prev.schema, + }, + right: { + schema: next.schema, + }, + }); + if (prev.type === type) { + return prev; + } else if (next.type === type) { + return next; + } + return { + schemaName: 'unknown', + type, + }; + }); +} + +function merge( + typeName: string, + candidates: Array, +): GraphQLNamedType { + const initialCandidateType = candidates[0].type; + if ( + candidates.some( + (candidate) => + candidate.type.constructor !== initialCandidateType.constructor, + ) + ) { + throw new Error( + `Cannot merge different type categories into common type ${typeName}.`, + ); + } + if (isObjectType(initialCandidateType)) { + return new GraphQLObjectType({ + name: typeName, + fields: candidates.reduce( + (acc, candidate) => ({ + ...acc, + ...toConfig(candidate.type).fields, + }), + {}, + ), + interfaces: candidates.reduce((acc, candidate) => { + const interfaces = toConfig(candidate.type).interfaces; + return interfaces != null ? acc.concat(interfaces) : acc; + }, []), + }); + } else if (isInterfaceType(initialCandidateType)) { + const config = { + name: typeName, + fields: candidates.reduce( + (acc, candidate) => ({ + ...acc, + ...toConfig(candidate.type).fields, + }), + {}, + ), + interfaces: + graphqlVersion() >= 15 + ? candidates.reduce((acc, candidate) => { + const interfaces = toConfig(candidate.type).interfaces; + return interfaces != null ? acc.concat(interfaces) : acc; + }, []) + : undefined, + }; + return new GraphQLInterfaceType(config); + } else if (isUnionType(initialCandidateType)) { + return new GraphQLUnionType({ + name: typeName, + types: candidates.reduce( + (acc, candidate) => acc.concat(toConfig(candidate.type).types), + [], + ), + }); + } else if (isEnumType(initialCandidateType)) { + return new GraphQLEnumType({ + name: typeName, + values: candidates.reduce( + (acc, candidate) => ({ + ...acc, + ...toConfig(candidate.type).values, + }), + {}, + ), + }); + } else if (isScalarType(initialCandidateType)) { + throw new Error( + `Cannot merge type ${typeName}. Merging not supported for GraphQLScalarType.`, + ); + } else { + // not reachable. + throw new Error(`Type ${typeName} has unknown GraphQL type.`); + } +} diff --git a/src/stitching/observableToAsyncIterable.ts b/src/stitch/observableToAsyncIterable.ts similarity index 78% rename from src/stitching/observableToAsyncIterable.ts rename to src/stitch/observableToAsyncIterable.ts index 7b0cd203e02..f9098ae67a0 100644 --- a/src/stitching/observableToAsyncIterable.ts +++ b/src/stitch/observableToAsyncIterable.ts @@ -1,22 +1,23 @@ import { Observable } from 'apollo-link'; import { $$asyncIterator } from 'iterall'; + type Callback = (value?: any) => any; export function observableToAsyncIterable( - observable: Observable + observable: Observable, ): AsyncIterator & { [$$asyncIterator]: () => AsyncIterator; } { - const pullQueue: Callback[] = []; - const pushQueue: any[] = []; + const pullQueue: Array = []; + const pushQueue: Array = []; let listening = true; - const pushValue = ({ data }: any) => { + const pushValue = (value: any) => { if (pullQueue.length !== 0) { - pullQueue.shift()({ value: data, done: false }); + pullQueue.shift()({ value, done: false }); } else { - pushQueue.push({ value: data }); + pushQueue.push({ value }); } }; @@ -28,8 +29,8 @@ export function observableToAsyncIterable( } }; - const pullValue = () => { - return new Promise(resolve => { + const pullValue = () => + new Promise((resolve) => { if (pushQueue.length !== 0) { const element = pushQueue.shift(); // either {value: {errors: [...]}} or {value: ...} @@ -41,7 +42,6 @@ export function observableToAsyncIterable( pullQueue.push(resolve); } }); - }; const subscription = observable.subscribe({ next(value: any) { @@ -56,14 +56,14 @@ export function observableToAsyncIterable( if (listening) { listening = false; subscription.unsubscribe(); - pullQueue.forEach(resolve => resolve({ value: undefined, done: true })); + pullQueue.forEach((resolve) => resolve({ value: undefined, done: true })); pullQueue.length = 0; pushQueue.length = 0; } }; return { - async next() { + next() { return listening ? pullValue() : this.return(); }, return() { diff --git a/src/stitch/proxiedResult.ts b/src/stitch/proxiedResult.ts new file mode 100644 index 00000000000..a1972731a41 --- /dev/null +++ b/src/stitch/proxiedResult.ts @@ -0,0 +1,168 @@ +import { GraphQLError, GraphQLSchema, responsePathAsArray } from 'graphql'; + +import { SubschemaConfig, IGraphQLToolsResolveInfo } from '../Interfaces'; +import { mergeDeep } from '../utils/index'; +import { handleNull } from '../delegate/checkResultAndHandleErrors'; + +import { relocatedError } from './errors'; + +const hasSymbol = + (typeof global !== 'undefined' && 'Symbol' in global) || + // eslint-disable-next-line no-undef + (typeof window !== 'undefined' && 'Symbol' in window); + +export const OBJECT_SUBSCHEMA_SYMBOL = hasSymbol + ? Symbol('initialSubschema') + : '@@__initialSubschema'; +export const FIELD_SUBSCHEMA_MAP_SYMBOL = hasSymbol + ? Symbol('subschemaMap') + : '@@__subschemaMap'; +export const ERROR_SYMBOL = hasSymbol + ? Symbol('subschemaErrors') + : '@@__subschemaErrors'; + +export function isProxiedResult(result: any) { + return result != null ? result[ERROR_SYMBOL] : result; +} + +export function getSubschema( + result: any, + responseKey: string, +): GraphQLSchema | SubschemaConfig { + const subschema = + result[FIELD_SUBSCHEMA_MAP_SYMBOL] && + result[FIELD_SUBSCHEMA_MAP_SYMBOL][responseKey]; + return subschema ? subschema : result[OBJECT_SUBSCHEMA_SYMBOL]; +} + +export function setObjectSubschema( + result: any, + subschema: GraphQLSchema | SubschemaConfig, +) { + result[OBJECT_SUBSCHEMA_SYMBOL] = subschema; +} + +export function setErrors(result: any, errors: Array) { + result[ERROR_SYMBOL] = errors; +} + +export function getErrors( + result: any, + pathSegment: string, +): Array { + const errors = result != null ? result[ERROR_SYMBOL] : result; + + if (!Array.isArray(errors)) { + return null; + } + + const fieldErrors = []; + + for (const error of errors) { + if (!error.path || error.path[0] === pathSegment) { + fieldErrors.push(error); + } + } + + return fieldErrors; +} + +export function unwrapResult( + parent: any, + info: IGraphQLToolsResolveInfo, + path: Array, +): any { + let newParent: any = parent; + const pathLength = path.length; + for (let i = 0; i < pathLength; i++) { + const responseKey = path[i]; + const errors = getErrors(newParent, responseKey); + const subschema = getSubschema(newParent, responseKey); + + const object = newParent[responseKey]; + if (object == null) { + return handleNull( + info.fieldNodes, + responsePathAsArray(info.path), + errors, + ); + } + + setErrors( + object, + errors.map((error) => + relocatedError( + error, + error.nodes, + error.path != null ? error.path.slice(1) : undefined, + ), + ), + ); + setObjectSubschema(object, subschema); + + newParent = object; + } + + return newParent; +} + +export function dehoistResult( + parent: any, + delimeter: string = '__gqltf__', +): any { + const result = Object.create(null); + + Object.keys(parent).forEach((alias) => { + let obj = result; + + const fieldNames = alias.split(delimeter); + const fieldName = fieldNames.pop(); + fieldNames.forEach((key) => { + obj = obj[key] = obj[key] || Object.create(null); + }); + obj[fieldName] = parent[alias]; + }); + + result[ERROR_SYMBOL] = parent[ERROR_SYMBOL].map((error: GraphQLError) => { + if (error.path != null) { + const path = error.path.slice(); + const pathSegment = path.shift(); + const expandedPathSegment: Array< + string | number + > = (pathSegment as string).split(delimeter); + return relocatedError( + error, + error.nodes, + expandedPathSegment.concat(path), + ); + } + + return error; + }); + + result[OBJECT_SUBSCHEMA_SYMBOL] = parent[OBJECT_SUBSCHEMA_SYMBOL]; + + return result; +} + +export function mergeProxiedResults(target: any, ...sources: any): any { + const errors = target[ERROR_SYMBOL].concat( + sources.map((source: any) => source[ERROR_SYMBOL]), + ); + const fieldSubschemaMap = sources.reduce( + (acc: Record, source: any) => { + const subschema = source[OBJECT_SUBSCHEMA_SYMBOL]; + Object.keys(source).forEach((key) => { + acc[key] = subschema; + }); + return acc; + }, + {}, + ); + const result = mergeDeep(target, ...sources); + result[ERROR_SYMBOL] = errors; + result[FIELD_SUBSCHEMA_MAP_SYMBOL] = target[FIELD_SUBSCHEMA_MAP_SYMBOL] + ? mergeDeep(target[FIELD_SUBSCHEMA_MAP_SYMBOL], fieldSubschemaMap) + : fieldSubschemaMap; + return result; +} diff --git a/src/stitch/resolveFromParentTypename.ts b/src/stitch/resolveFromParentTypename.ts new file mode 100644 index 00000000000..bb3abdc52bf --- /dev/null +++ b/src/stitch/resolveFromParentTypename.ts @@ -0,0 +1,10 @@ +export default function resolveFromParentTypename(parent: any) { + const parentTypename: string = parent['__typename']; + if (!parentTypename) { + throw new Error( + 'Did not fetch typename for object, unable to resolve interface.', + ); + } + + return parentTypename; +} diff --git a/src/stitching/typeFromAST.ts b/src/stitch/typeFromAST.ts similarity index 51% rename from src/stitching/typeFromAST.ts rename to src/stitch/typeFromAST.ts index 69367beefb3..4a2e2e75f32 100644 --- a/src/stitching/typeFromAST.ts +++ b/src/stitch/typeFromAST.ts @@ -21,17 +21,19 @@ import { ScalarTypeDefinitionNode, TypeNode, UnionTypeDefinitionNode, - valueFromAST, - getDescription, - GraphQLString, GraphQLDirective, DirectiveDefinitionNode, DirectiveLocationEnum, DirectiveLocation, GraphQLFieldConfig, StringValueNode, + Location, + TokenKind, } from 'graphql'; -import resolveFromParentType from './resolveFromParentTypename'; + +import { createNamedStub, graphqlVersion } from '../utils/index'; + +import resolveFromParentTypename from './resolveFromParentTypename'; const backcompatOptions = { commentDescriptions: true }; @@ -64,37 +66,49 @@ export default function typeFromAST( } } -function makeObjectType( - node: ObjectTypeDefinitionNode, -): GraphQLObjectType { - return new GraphQLObjectType({ +function makeObjectType(node: ObjectTypeDefinitionNode): GraphQLObjectType { + const config = { name: node.name.value, fields: () => makeFields(node.fields), interfaces: () => node.interfaces.map( - iface => createNamedStub(iface.name.value, 'interface') as GraphQLInterfaceType, + (iface) => + createNamedStub( + iface.name.value, + 'interface', + ) as GraphQLInterfaceType, ), description: getDescription(node, backcompatOptions), - }); + }; + return new GraphQLObjectType(config); } function makeInterfaceType( node: InterfaceTypeDefinitionNode, ): GraphQLInterfaceType { - return new GraphQLInterfaceType({ + const config = { name: node.name.value, fields: () => makeFields(node.fields), + interfaces: + graphqlVersion() >= 15 + ? () => + ((node as unknown) as ObjectTypeDefinitionNode).interfaces.map( + (iface) => + createNamedStub( + iface.name.value, + 'interface', + ) as GraphQLInterfaceType, + ) + : undefined, description: getDescription(node, backcompatOptions), - resolveType: (parent, context, info) => - resolveFromParentType(parent, info.schema), - }); + resolveType: (parent: any) => resolveFromParentTypename(parent), + }; + return new GraphQLInterfaceType(config); } -function makeEnumType( - node: EnumTypeDefinitionNode, -): GraphQLEnumType { +function makeEnumType(node: EnumTypeDefinitionNode): GraphQLEnumType { const values = {}; - node.values.forEach(value => { + node.values.forEach((value) => { values[value.name.value] = { description: getDescription(value, backcompatOptions), }; @@ -106,24 +120,19 @@ function makeEnumType( }); } -function makeUnionType( - node: UnionTypeDefinitionNode, -): GraphQLUnionType { +function makeUnionType(node: UnionTypeDefinitionNode): GraphQLUnionType { return new GraphQLUnionType({ name: node.name.value, types: () => node.types.map( - type => resolveType(type, 'object') as GraphQLObjectType, + (type) => resolveType(type, 'object') as GraphQLObjectType, ), description: getDescription(node, backcompatOptions), - resolveType: (parent, context, info) => - resolveFromParentType(parent, info.schema), + resolveType: (parent) => resolveFromParentTypename(parent), }); } -function makeScalarType( - node: ScalarTypeDefinitionNode, -): GraphQLScalarType { +function makeScalarType(node: ScalarTypeDefinitionNode): GraphQLScalarType { return new GraphQLScalarType({ name: node.name.value, description: getDescription(node, backcompatOptions), @@ -153,19 +162,17 @@ function makeFields( const result: Record> = {}; nodes.forEach((node) => { const deprecatedDirective = node.directives.find( - (directive) => - directive && directive.name && directive.name.value === 'deprecated', + (directive) => directive.name.value === 'deprecated', ); - const deprecatedArgument = - deprecatedDirective && - deprecatedDirective.arguments && - deprecatedDirective.arguments.find( - (arg) => arg && arg.name && arg.name.value === 'reason', + + let deprecationReason; + + if (deprecatedDirective != null) { + const deprecatedArgument = deprecatedDirective.arguments.find( + (arg) => arg.name.value === 'reason', ); - const deprecationReason = - deprecatedArgument && - deprecatedArgument.value && - (deprecatedArgument.value as StringValueNode).value; + deprecationReason = (deprecatedArgument.value as StringValueNode).value; + } result[node.name.value] = { type: resolveType(node.type, 'object') as GraphQLObjectType, @@ -179,11 +186,11 @@ function makeFields( function makeValues(nodes: ReadonlyArray) { const result = {}; - nodes.forEach(node => { + nodes.forEach((node) => { const type = resolveType(node.type, 'input') as GraphQLInputType; result[node.name.value] = { type, - defaultValue: valueFromAST(node.defaultValue, type), + defaultValue: node.defaultValue, description: getDescription(node, backcompatOptions), }; }); @@ -204,40 +211,121 @@ function resolveType( } } -function createNamedStub( - name: string, - type: 'object' | 'interface' | 'input' -): GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType { - let constructor: any; - if (type === 'object') { - constructor = GraphQLObjectType; - } else if (type === 'interface') { - constructor = GraphQLInterfaceType; - } else { - constructor = GraphQLInputObjectType; - } - - return new constructor({ - name, - fields: { - __fake: { - type: GraphQLString, - }, - }, - }); -} - function makeDirective(node: DirectiveDefinitionNode): GraphQLDirective { const locations: Array = []; - node.locations.forEach(location => { - if (location.value in DirectiveLocation) { - locations.push(location.value); + node.locations.forEach((location) => { + if (location.value in DirectiveLocation) { + locations.push(location.value as DirectiveLocationEnum); } }); return new GraphQLDirective({ name: node.name.value, - description: node.description ? node.description.value : null, + description: node.description != null ? node.description.value : null, args: makeValues(node.arguments), locations, }); } + +// graphql < v13 does not export getDescription + +function getDescription( + node: { description?: StringValueNode; loc?: Location }, + options?: { commentDescriptions?: boolean }, +): string { + if (node.description != null) { + return node.description.value; + } + if (options.commentDescriptions) { + const rawValue = getLeadingCommentBlock(node); + if (rawValue !== undefined) { + return dedentBlockStringValue(`\n${rawValue as string}`); + } + } +} + +function getLeadingCommentBlock(node: { + description?: StringValueNode; + loc?: Location; +}): void | string { + const loc = node.loc; + if (!loc) { + return; + } + const comments = []; + let token = loc.startToken.prev; + while ( + token != null && + token.kind === TokenKind.COMMENT && + token.next != null && + token.prev != null && + token.line + 1 === token.next.line && + token.line !== token.prev.line + ) { + const value = String(token.value); + comments.push(value); + token = token.prev; + } + return comments.length > 0 ? comments.reverse().join('\n') : undefined; +} + +function dedentBlockStringValue(rawString: string): string { + // Expand a block string's raw value into independent lines. + const lines = rawString.split(/\r\n|[\n\r]/g); + + // Remove common indentation from all lines but first. + const commonIndent = getBlockStringIndentation(lines); + + if (commonIndent !== 0) { + for (let i = 1; i < lines.length; i++) { + lines[i] = lines[i].slice(commonIndent); + } + } + + // Remove leading and trailing blank lines. + while (lines.length > 0 && isBlank(lines[0])) { + lines.shift(); + } + while (lines.length > 0 && isBlank(lines[lines.length - 1])) { + lines.pop(); + } + + // Return a string of the lines joined with U+000A. + return lines.join('\n'); +} +/** + * @internal + */ +export function getBlockStringIndentation( + lines: ReadonlyArray, +): number { + let commonIndent = null; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const indent = leadingWhitespace(line); + if (indent === line.length) { + continue; // skip empty lines + } + + if (commonIndent === null || indent < commonIndent) { + commonIndent = indent; + if (commonIndent === 0) { + break; + } + } + } + + return commonIndent === null ? 0 : commonIndent; +} + +function leadingWhitespace(str: string) { + let i = 0; + while (i < str.length && (str[i] === ' ' || str[i] === '\t')) { + i++; + } + return i; +} + +function isBlank(str: string) { + return leadingWhitespace(str) === str.length; +} diff --git a/src/stitching/defaultMergedResolver.ts b/src/stitching/defaultMergedResolver.ts deleted file mode 100644 index cbf754a03a8..00000000000 --- a/src/stitching/defaultMergedResolver.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { GraphQLFieldResolver, responsePathAsArray } from 'graphql'; -import { locatedError } from 'graphql/error'; -import { getErrorsFromParent, annotateWithChildrenErrors } from './errors'; -import { getResponseKeyFromInfo } from './getResponseKeyFromInfo'; - -// Resolver that knows how to: -// a) handle aliases for proxied schemas -// b) handle errors from proxied schemas -const defaultMergedResolver: GraphQLFieldResolver = (parent, args, context, info) => { - if (!parent) { - return null; - } - - const responseKey = getResponseKeyFromInfo(info); - const errorResult = getErrorsFromParent(parent, responseKey); - - if (errorResult.kind === 'OWN') { - throw locatedError(new Error(errorResult.error.message), info.fieldNodes, responsePathAsArray(info.path)); - } - - let result = parent[responseKey]; - - if (result == null) { - result = parent[info.fieldName]; - } - - // subscription result mapping - if (!result && parent.data && parent.data[responseKey]) { - result = parent.data[responseKey]; - } - - if (errorResult.errors) { - result = annotateWithChildrenErrors(result, errorResult.errors); - } - return result; -}; - -export default defaultMergedResolver; diff --git a/src/stitching/delegateToSchema.ts b/src/stitching/delegateToSchema.ts deleted file mode 100644 index ff6bab772e9..00000000000 --- a/src/stitching/delegateToSchema.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - ArgumentNode, - DocumentNode, - FieldNode, - FragmentDefinitionNode, - Kind, - OperationDefinitionNode, - SelectionSetNode, - SelectionNode, - subscribe, - execute, - validate, - VariableDefinitionNode, - GraphQLSchema, - ExecutionResult, - NameNode, - isEnumType, -} from 'graphql'; - -import { Operation, Request, IDelegateToSchemaOptions } from '../Interfaces'; - -import { - applyRequestTransforms, - applyResultTransforms, -} from '../transforms/transforms'; - -import AddArgumentsAsVariables from '../transforms/AddArgumentsAsVariables'; -import FilterToSchema from '../transforms/FilterToSchema'; -import AddTypenameToAbstract from '../transforms/AddTypenameToAbstract'; -import CheckResultAndHandleErrors from '../transforms/CheckResultAndHandleErrors'; -import mapAsyncIterator from './mapAsyncIterator'; -import ExpandAbstractTypes from '../transforms/ExpandAbstractTypes'; -import ReplaceFieldWithFragment from '../transforms/ReplaceFieldWithFragment'; -import ConvertEnumResponse from '../transforms/ConvertEnumResponse'; - -export default function delegateToSchema( - options: IDelegateToSchemaOptions | GraphQLSchema, - ...args: any[] -): Promise { - if (options instanceof GraphQLSchema) { - throw new Error( - 'Passing positional arguments to delegateToSchema is a deprecated. ' + - 'Please pass named parameters instead.', - ); - } - return delegateToSchemaImplementation(options); -} - -async function delegateToSchemaImplementation( - options: IDelegateToSchemaOptions, -): Promise { - const { info, args = {} } = options; - const operation = options.operation || info.operation.operation; - const rawDocument: DocumentNode = createDocument( - options.fieldName, - operation, - info.fieldNodes, - Object.keys(info.fragments).map( - fragmentName => info.fragments[fragmentName], - ), - info.operation.variableDefinitions, - info.operation.name, - ); - - const rawRequest: Request = { - document: rawDocument, - variables: info.variableValues as Record, - }; - - let transforms = [ - ...(options.transforms || []), - new ExpandAbstractTypes(info.schema, options.schema), - ]; - - if (info.mergeInfo && info.mergeInfo.fragments) { - transforms.push( - new ReplaceFieldWithFragment(options.schema, info.mergeInfo.fragments), - ); - } - - transforms = transforms.concat([ - new AddArgumentsAsVariables(options.schema, args), - new FilterToSchema(options.schema), - new AddTypenameToAbstract(options.schema), - new CheckResultAndHandleErrors(info, options.fieldName), - ]); - - if (isEnumType(options.info.returnType)) { - transforms = transforms.concat( - new ConvertEnumResponse(options.info.returnType), - ); - } - - const processedRequest = applyRequestTransforms(rawRequest, transforms); - - if (!options.skipValidation) { - const errors = validate(options.schema, processedRequest.document); - if (errors.length > 0) { - throw errors; - } - } - - if (operation === 'query' || operation === 'mutation') { - return applyResultTransforms( - await execute( - options.schema, - processedRequest.document, - info.rootValue, - options.context, - processedRequest.variables, - ), - transforms, - ); - } - - if (operation === 'subscription') { - const executionResult = (await subscribe( - options.schema, - processedRequest.document, - info.rootValue, - options.context, - processedRequest.variables, - )) as AsyncIterator; - - // "subscribe" to the subscription result and map the result through the transforms - return mapAsyncIterator(executionResult, result => { - const transformedResult = applyResultTransforms(result, transforms); - const subscriptionKey = Object.keys(result.data)[0]; - - // for some reason the returned transformedResult needs to be nested inside the root subscription field - // does not work otherwise... - return { - [subscriptionKey]: transformedResult, - }; - }); - } -} - -function createDocument( - targetField: string, - targetOperation: Operation, - originalSelections: ReadonlyArray, - fragments: Array, - variables: ReadonlyArray, - operationName: NameNode, -): DocumentNode { - let selections: Array = []; - let args: Array = []; - - originalSelections.forEach((field: FieldNode) => { - const fieldSelections = field.selectionSet - ? field.selectionSet.selections - : []; - selections = selections.concat(fieldSelections); - args = args.concat(field.arguments || []); - }); - - let selectionSet = null; - if (selections.length > 0) { - selectionSet = { - kind: Kind.SELECTION_SET, - selections: selections, - }; - } - - const rootField: FieldNode = { - kind: Kind.FIELD, - alias: null, - arguments: args, - selectionSet, - name: { - kind: Kind.NAME, - value: targetField, - }, - }; - const rootSelectionSet: SelectionSetNode = { - kind: Kind.SELECTION_SET, - selections: [rootField], - }; - - const operationDefinition: OperationDefinitionNode = { - kind: Kind.OPERATION_DEFINITION, - operation: targetOperation, - variableDefinitions: variables, - selectionSet: rootSelectionSet, - name: operationName, - }; - - return { - kind: Kind.DOCUMENT, - definitions: [operationDefinition, ...fragments], - }; -} diff --git a/src/stitching/errors.ts b/src/stitching/errors.ts deleted file mode 100644 index 94ba6851a25..00000000000 --- a/src/stitching/errors.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - GraphQLResolveInfo, - responsePathAsArray, - ExecutionResult, - GraphQLFormattedError, - GraphQLError, -} from 'graphql'; -import { locatedError } from 'graphql/error'; -import { getResponseKeyFromInfo } from './getResponseKeyFromInfo'; - -export let ERROR_SYMBOL: any; -if ( - (typeof global !== 'undefined' && 'Symbol' in global) || - (typeof window !== 'undefined' && 'Symbol' in window) -) { - ERROR_SYMBOL = Symbol('subSchemaErrors'); -} else { - ERROR_SYMBOL = '@@__subSchemaErrors'; -} - -export function annotateWithChildrenErrors(object: any, childrenErrors: ReadonlyArray): any { - if (!childrenErrors || childrenErrors.length === 0) { - // Nothing to see here, move along - return object; - } - - if (Array.isArray(object)) { - const byIndex = {}; - - childrenErrors.forEach(error => { - if (!error.path) { - return; - } - const index = error.path[1]; - const current = byIndex[index] || []; - current.push({ - ...error, - path: error.path.slice(1) - }); - byIndex[index] = current; - }); - - return object.map((item, index) => annotateWithChildrenErrors(item, byIndex[index])); - } - - return { - ...object, - [ERROR_SYMBOL]: childrenErrors.map(error => ({ - ...error, - ...(error.path ? { path: error.path.slice(1) } : {}) - })) - }; -} - -export function getErrorsFromParent( - object: any, - fieldName: string -): - | { - kind: 'OWN'; - error: any; - } - | { - kind: 'CHILDREN'; - errors?: Array; - } { - const errors = (object && object[ERROR_SYMBOL]) || []; - const childrenErrors: Array = []; - - for (const error of errors) { - if (!error.path || (error.path.length === 1 && error.path[0] === fieldName)) { - return { - kind: 'OWN', - error - }; - } else if (error.path[0] === fieldName) { - childrenErrors.push(error); - } - } - - return { - kind: 'CHILDREN', - errors: childrenErrors - }; -} - -class CombinedError extends Error { - public errors: ReadonlyArray; - constructor(message: string, errors: ReadonlyArray) { - super(message); - this.errors = errors; - } -} - -export function checkResultAndHandleErrors( - result: ExecutionResult, - info: GraphQLResolveInfo, - responseKey?: string -): any { - if (!responseKey) { - responseKey = getResponseKeyFromInfo(info); - } - - if (result.errors && (!result.data || result.data[responseKey] == null)) { - // apollo-link-http & http-link-dataloader need the - // result property to be passed through for better error handling. - // If there is only one error, which contains a result property, pass the error through - const newError = - result.errors.length === 1 && hasResult(result.errors[0]) - ? result.errors[0] - : new CombinedError(concatErrors(result.errors), result.errors); - throw locatedError(newError, info.fieldNodes, responsePathAsArray(info.path)); - } - - let resultObject = result.data[responseKey]; - if (result.errors) { - resultObject = annotateWithChildrenErrors(resultObject, result.errors as ReadonlyArray); - } - return resultObject; -} - -function concatErrors(errors: ReadonlyArray) { - return errors.map(error => error.message).join('\n'); -} - -function hasResult(error: any) { - return error.result || error.extensions || (error.originalError && error.originalError.result); -} diff --git a/src/stitching/index.ts b/src/stitching/index.ts deleted file mode 100644 index 72724c6fb99..00000000000 --- a/src/stitching/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import makeRemoteExecutableSchema, { createResolver as defaultCreateRemoteResolver } from './makeRemoteExecutableSchema'; -import introspectSchema from './introspectSchema'; -import mergeSchemas from './mergeSchemas'; -import delegateToSchema from './delegateToSchema'; -import defaultMergedResolver from './defaultMergedResolver'; - -export { - makeRemoteExecutableSchema, - introspectSchema, - mergeSchemas, - // Those are currently undocumented and not part of official API, - // but exposed for the community use - delegateToSchema, - defaultMergedResolver, - defaultCreateRemoteResolver -}; diff --git a/src/stitching/introspectSchema.ts b/src/stitching/introspectSchema.ts deleted file mode 100644 index b4708bad958..00000000000 --- a/src/stitching/introspectSchema.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { GraphQLSchema, DocumentNode } from 'graphql'; -import { buildClientSchema, parse } from 'graphql'; -import { getIntrospectionQuery } from 'graphql/utilities'; -import { ApolloLink } from 'apollo-link'; -import { Fetcher } from './makeRemoteExecutableSchema'; -import linkToFetcher from './linkToFetcher'; - -const parsedIntrospectionQuery: DocumentNode = parse(getIntrospectionQuery()); - -export default async function introspectSchema( - fetcher: ApolloLink | Fetcher, - linkContext?: { [key: string]: any }, -): Promise { - // Convert link to fetcher - if ((fetcher as ApolloLink).request) { - fetcher = linkToFetcher(fetcher as ApolloLink); - } - - const introspectionResult = await (fetcher as Fetcher)({ - query: parsedIntrospectionQuery, - context: linkContext, - }); - - if ( - (introspectionResult.errors && introspectionResult.errors.length) || - !introspectionResult.data.__schema - ) { - throw introspectionResult.errors; - } else { - const schema = buildClientSchema(introspectionResult.data as { - __schema: any; - }); - return schema; - } -} diff --git a/src/stitching/linkToFetcher.ts b/src/stitching/linkToFetcher.ts deleted file mode 100644 index 9a6f75c9d8e..00000000000 --- a/src/stitching/linkToFetcher.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Fetcher, FetcherOperation } from './makeRemoteExecutableSchema'; - -import { - ApolloLink, // This import doesn't actually import code - only the types. - makePromise, - execute, - GraphQLRequest, -} from 'apollo-link'; - -export { execute } from 'apollo-link'; - -export default function linkToFetcher(link: ApolloLink): Fetcher { - return (fetcherOperation: FetcherOperation) => { - return makePromise(execute(link, fetcherOperation as GraphQLRequest)); - }; -} diff --git a/src/stitching/makeRemoteExecutableSchema.ts b/src/stitching/makeRemoteExecutableSchema.ts deleted file mode 100644 index 5ea5c7789a0..00000000000 --- a/src/stitching/makeRemoteExecutableSchema.ts +++ /dev/null @@ -1,218 +0,0 @@ -// This import doesn't actually import code - only the types. -// Don't use ApolloLink to actually construct a link here. -import { ApolloLink } from 'apollo-link'; - -import { - GraphQLObjectType, - GraphQLFieldResolver, - GraphQLSchema, - GraphQLInterfaceType, - GraphQLUnionType, - GraphQLID, - GraphQLString, - GraphQLFloat, - GraphQLBoolean, - GraphQLInt, - GraphQLScalarType, - ExecutionResult, - buildSchema, - printSchema, - Kind, - GraphQLResolveInfo, - DocumentNode, - BuildSchemaOptions -} from 'graphql'; -import linkToFetcher, { execute } from './linkToFetcher'; -import isEmptyObject from '../isEmptyObject'; -import { IResolvers, IResolverObject } from '../Interfaces'; -import { makeExecutableSchema } from '../makeExecutableSchema'; -import { recreateType } from './schemaRecreation'; -import resolveParentFromTypename from './resolveFromParentTypename'; -import defaultMergedResolver from './defaultMergedResolver'; -import { checkResultAndHandleErrors } from './errors'; -import { observableToAsyncIterable } from './observableToAsyncIterable'; - -export type ResolverFn = ( - rootValue?: any, - args?: any, - context?: any, - info?: GraphQLResolveInfo -) => AsyncIterator; - -export type Fetcher = (operation: FetcherOperation) => Promise; - -export type FetcherOperation = { - query: DocumentNode; - operationName?: string; - variables?: { [key: string]: any }; - context?: { [key: string]: any }; -}; - -/** - * This type has been copied inline from its source on `@types/graphql`: - * - * https://git.io/Jv8NX - * - * Previously, it was imported from `graphql/utilities/schemaPrinter`, however - * that module has been removed in `graphql@15`. Furthermore, the sole property - * on this type is due to be deprecated in `graphql@16`. - */ -interface PrintSchemaOptions { - /** - * Descriptions are defined as preceding string literals, however an older - * experimental version of the SDL supported preceding comments as - * descriptions. Set to true to enable this deprecated behavior. - * This option is provided to ease adoption and will be removed in v16. - * - * Default: false - */ - commentDescriptions?: boolean; -} - -export default function makeRemoteExecutableSchema({ - schema, - link, - fetcher, - createResolver: customCreateResolver = createResolver, - buildSchemaOptions, - printSchemaOptions = { commentDescriptions: true } -}: { - schema: GraphQLSchema | string; - link?: ApolloLink; - fetcher?: Fetcher; - createResolver?: (fetcher: Fetcher) => GraphQLFieldResolver; - buildSchemaOptions?: BuildSchemaOptions; - printSchemaOptions?: PrintSchemaOptions; -}): GraphQLSchema { - if (!fetcher && link) { - fetcher = linkToFetcher(link); - } - - let typeDefs: string; - - if (typeof schema === 'string') { - typeDefs = schema; - schema = buildSchema(typeDefs, buildSchemaOptions); - } else { - typeDefs = printSchema(schema, printSchemaOptions); - } - - // prepare query resolvers - const queryResolvers: IResolverObject = {}; - const queryType = schema.getQueryType(); - const queries = queryType.getFields(); - Object.keys(queries).forEach(key => { - queryResolvers[key] = customCreateResolver(fetcher); - }); - - // prepare mutation resolvers - const mutationResolvers: IResolverObject = {}; - const mutationType = schema.getMutationType(); - if (mutationType) { - const mutations = mutationType.getFields(); - Object.keys(mutations).forEach(key => { - mutationResolvers[key] = customCreateResolver(fetcher); - }); - } - - // prepare subscription resolvers - const subscriptionResolvers: IResolverObject = {}; - const subscriptionType = schema.getSubscriptionType(); - if (subscriptionType) { - const subscriptions = subscriptionType.getFields(); - Object.keys(subscriptions).forEach(key => { - subscriptionResolvers[key] = { - subscribe: createSubscriptionResolver(key, link) - }; - }); - } - - // merge resolvers into resolver map - const resolvers: IResolvers = { [queryType.name]: queryResolvers }; - - if (!isEmptyObject(mutationResolvers)) { - resolvers[mutationType.name] = mutationResolvers; - } - - if (!isEmptyObject(subscriptionResolvers)) { - resolvers[subscriptionType.name] = subscriptionResolvers; - } - - // add missing abstract resolvers (scalar, unions, interfaces) - const typeMap = schema.getTypeMap(); - const types = Object.keys(typeMap).map(name => typeMap[name]); - for (const type of types) { - if (type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType) { - resolvers[type.name] = { - __resolveType(parent: any, context: any, info: any) { - return resolveParentFromTypename(parent, info.schema); - } - }; - } else if (type instanceof GraphQLScalarType) { - if ( - !( - type === GraphQLID || - type === GraphQLString || - type === GraphQLFloat || - type === GraphQLBoolean || - type === GraphQLInt - ) - ) { - resolvers[type.name] = recreateType(type, (name: string) => null, false) as GraphQLScalarType; - } - } else if ( - type instanceof GraphQLObjectType && - type.name.slice(0, 2) !== '__' && - type !== queryType && - type !== mutationType && - type !== subscriptionType - ) { - const resolver = {}; - Object.keys(type.getFields()).forEach(field => { - resolver[field] = defaultMergedResolver; - }); - resolvers[type.name] = resolver; - } - } - - return makeExecutableSchema({ - typeDefs, - resolvers - }); -} - -export function createResolver(fetcher: Fetcher): GraphQLFieldResolver { - return async (root, args, context, info) => { - const fragments = Object.keys(info.fragments).map(fragment => info.fragments[fragment]); - const document = { - kind: Kind.DOCUMENT, - definitions: [info.operation, ...fragments] - }; - const result = await fetcher({ - query: document, - variables: info.variableValues, - context: { graphqlContext: context } - }); - return checkResultAndHandleErrors(result, info); - }; -} - -function createSubscriptionResolver(name: string, link: ApolloLink): ResolverFn { - return (root, args, context, info) => { - const fragments = Object.keys(info.fragments).map(fragment => info.fragments[fragment]); - const document = { - kind: Kind.DOCUMENT, - definitions: [info.operation, ...fragments] - }; - - const operation = { - query: document, - variables: info.variableValues, - context: { graphqlContext: context } - }; - - const observable = execute(link, operation); - - return observableToAsyncIterable(observable); - }; -} diff --git a/src/stitching/mergeSchemas.ts b/src/stitching/mergeSchemas.ts deleted file mode 100644 index d704262f11c..00000000000 --- a/src/stitching/mergeSchemas.ts +++ /dev/null @@ -1,499 +0,0 @@ -import { - DocumentNode, - GraphQLField, - GraphQLNamedType, - GraphQLObjectType, - GraphQLResolveInfo, - GraphQLScalarType, - GraphQLSchema, - extendSchema, - getNamedType, - isNamedType, - parse, - Kind, - GraphQLDirective, -} from 'graphql'; -import { - IDelegateToSchemaOptions, - IFieldResolver, - IResolvers, - MergeInfo, - MergeTypeCandidate, - TypeWithResolvers, - VisitTypeResult, - IResolversParameter, -} from '../Interfaces'; -import { - extractExtensionDefinitions, - addResolveFunctionsToSchema, -} from '../makeExecutableSchema'; -import { - recreateType, - recreateDirective, - fieldMapToFieldConfigMap, - createResolveType, -} from './schemaRecreation'; -import delegateToSchema from './delegateToSchema'; -import typeFromAST from './typeFromAST'; -import { - Transform, - ExpandAbstractTypes, - ReplaceFieldWithFragment, -} from '../transforms'; -import mergeDeep from '../mergeDeep'; -import { SchemaDirectiveVisitor } from '../schemaVisitor'; - -export type OnTypeConflict = ( - left: GraphQLNamedType, - right: GraphQLNamedType, - info?: { - left: { - schema?: GraphQLSchema; - }; - right: { - schema?: GraphQLSchema; - }; - }, -) => GraphQLNamedType; - -export default function mergeSchemas({ - schemas, - onTypeConflict, - resolvers, - schemaDirectives, - inheritResolversFromInterfaces, - mergeDirectives, -}: { - schemas: Array< - string | GraphQLSchema | DocumentNode | Array - >; - onTypeConflict?: OnTypeConflict; - resolvers?: IResolversParameter; - schemaDirectives?: { [name: string]: typeof SchemaDirectiveVisitor }; - inheritResolversFromInterfaces?: boolean; - mergeDirectives?: boolean, - -}): GraphQLSchema { - return mergeSchemasImplementation({ - schemas, - resolvers, - schemaDirectives, - inheritResolversFromInterfaces, - mergeDirectives, - }); -} - -function mergeSchemasImplementation({ - schemas, - resolvers, - schemaDirectives, - inheritResolversFromInterfaces, - mergeDirectives, -}: { - schemas: Array< - string | GraphQLSchema | DocumentNode | Array - >; - resolvers?: IResolversParameter; - schemaDirectives?: { [name: string]: typeof SchemaDirectiveVisitor }; - inheritResolversFromInterfaces?: boolean; - mergeDirectives?: boolean, - -}): GraphQLSchema { - const allSchemas: Array = []; - const typeCandidates: { [name: string]: Array } = {}; - const types: { [name: string]: GraphQLNamedType } = {}; - const extensions: Array = []; - const directives: Array = []; - const fragments: Array<{ - field: string; - fragment: string; - }> = []; - - const resolveType = createResolveType(name => { - if (types[name] === undefined) { - throw new Error(`Can't find type ${name}.`); - } - return types[name]; - }); - - schemas.forEach(schema => { - if (schema instanceof GraphQLSchema) { - allSchemas.push(schema); - const queryType = schema.getQueryType(); - const mutationType = schema.getMutationType(); - const subscriptionType = schema.getSubscriptionType(); - if (queryType) { - addTypeCandidate(typeCandidates, 'Query', { - schema, - type: queryType, - }); - } - if (mutationType) { - addTypeCandidate(typeCandidates, 'Mutation', { - schema, - type: mutationType, - }); - } - if (subscriptionType) { - addTypeCandidate(typeCandidates, 'Subscription', { - schema, - type: subscriptionType, - }); - } - - if (mergeDirectives) { - const directiveInstances = schema.getDirectives(); - directiveInstances.forEach(directive => { - directives.push(directive); - }); - } - - const typeMap = schema.getTypeMap(); - Object.keys(typeMap).forEach(typeName => { - const type: GraphQLNamedType = typeMap[typeName]; - if ( - isNamedType(type) && - getNamedType(type).name.slice(0, 2) !== '__' && - type !== queryType && - type !== mutationType && - type !== subscriptionType - ) { - addTypeCandidate(typeCandidates, type.name, { - schema, - type: type, - }); - } - }); - } else if ( - typeof schema === 'string' || - (schema && (schema as DocumentNode).kind === Kind.DOCUMENT) - ) { - let parsedSchemaDocument = - typeof schema === 'string' ? parse(schema) : (schema as DocumentNode); - parsedSchemaDocument.definitions.forEach(def => { - const type = typeFromAST(def); - if (type instanceof GraphQLDirective && mergeDirectives) { - directives.push(type); - } else if (type && !(type instanceof GraphQLDirective)) { - addTypeCandidate(typeCandidates, type.name, { - type: type, - }); - } - }); - - const extensionsDocument = extractExtensionDefinitions( - parsedSchemaDocument, - ); - if (extensionsDocument.definitions.length > 0) { - extensions.push(extensionsDocument); - } - } else if (Array.isArray(schema)) { - schema.forEach(type => { - addTypeCandidate(typeCandidates, type.name, { - type: type, - }); - }); - } else { - throw new Error(`Invalid schema passed`); - } - }); - - const mergeInfo = createMergeInfo(allSchemas, fragments); - - if (!resolvers) { - resolvers = {}; - } else if (typeof resolvers === 'function') { - console.warn( - 'Passing functions as resolver parameter is deprecated. Use `info.mergeInfo` instead.', - ); - resolvers = resolvers(mergeInfo); - } else if (Array.isArray(resolvers)) { - resolvers = resolvers.reduce((left, right) => { - if (typeof right === 'function') { - console.warn( - 'Passing functions as resolver parameter is deprecated. Use `info.mergeInfo` instead.', - ); - right = right(mergeInfo); - } - return mergeDeep(left, right); - }, {}); - } - - let generatedResolvers = {}; - - Object.keys(typeCandidates).forEach(typeName => { - const resultType: VisitTypeResult = defaultVisitType( - typeName, - typeCandidates[typeName], - ); - if (resultType === null) { - types[typeName] = null; - } else { - let type: GraphQLNamedType; - let typeResolvers: IResolvers; - if (isNamedType(resultType)) { - type = resultType; - } else if ((resultType).type) { - type = (resultType).type; - typeResolvers = (resultType).resolvers; - } else { - throw new Error(`Invalid visitType result for type ${typeName}`); - } - types[typeName] = recreateType(type, resolveType, false); - if (typeResolvers) { - generatedResolvers[typeName] = typeResolvers; - } - } - }); - - let mergedSchema = new GraphQLSchema({ - query: types.Query as GraphQLObjectType, - mutation: types.Mutation as GraphQLObjectType, - subscription: types.Subscription as GraphQLObjectType, - types: Object.keys(types).map(key => types[key]), - directives: directives.map((directive) => recreateDirective(directive, resolveType)) - }); - - extensions.forEach(extension => { - mergedSchema = (extendSchema as any)(mergedSchema, extension, { - commentDescriptions: true, - }); - }); - - if (!resolvers) { - resolvers = {}; - } else if (Array.isArray(resolvers)) { - resolvers = resolvers.reduce(mergeDeep, {}); - } - - Object.keys(resolvers).forEach(typeName => { - const type = resolvers[typeName]; - if (type instanceof GraphQLScalarType) { - return; - } - Object.keys(type).forEach(fieldName => { - const field = type[fieldName]; - if (field.fragment) { - fragments.push({ - field: fieldName, - fragment: field.fragment, - }); - } - }); - }); - - mergedSchema = addResolveFunctionsToSchema({ - schema: mergedSchema, - resolvers: mergeDeep(generatedResolvers, resolvers), - inheritResolversFromInterfaces - }); - - forEachField(mergedSchema, field => { - if (field.resolve) { - const fieldResolver = field.resolve; - field.resolve = (parent, args, context, info) => { - const newInfo = { ...info, mergeInfo }; - return fieldResolver(parent, args, context, newInfo); - }; - } - if (field.subscribe) { - const fieldResolver = field.subscribe; - field.subscribe = (parent, args, context, info) => { - const newInfo = { ...info, mergeInfo }; - return fieldResolver(parent, args, context, newInfo); - }; - } - }); - - if (schemaDirectives) { - SchemaDirectiveVisitor.visitSchemaDirectives( - mergedSchema, - schemaDirectives, - ); - } - - return mergedSchema; -} - -function createMergeInfo( - allSchemas: Array, - fragments: Array<{ - field: string; - fragment: string; - }>, -): MergeInfo { - return { - delegate( - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, - args: { [key: string]: any }, - context: { [key: string]: any }, - info: GraphQLResolveInfo, - transforms?: Array, - ) { - console.warn( - '`mergeInfo.delegate` is deprecated. ' + - 'Use `mergeInfo.delegateToSchema and pass explicit schema instances.', - ); - const schema = guessSchemaByRootField(allSchemas, operation, fieldName); - const expandTransforms = new ExpandAbstractTypes(info.schema, schema); - const fragmentTransform = new ReplaceFieldWithFragment(schema, fragments); - return delegateToSchema({ - schema, - operation, - fieldName, - args, - context, - info, - transforms: [ - ...(transforms || []), - expandTransforms, - fragmentTransform, - ], - }); - }, - - delegateToSchema(options: IDelegateToSchemaOptions) { - return delegateToSchema({ - ...options, - transforms: options.transforms - }); - }, - fragments - }; -} - -function guessSchemaByRootField( - schemas: Array, - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, -): GraphQLSchema { - for (const schema of schemas) { - let rootObject: GraphQLObjectType; - if (operation === 'subscription') { - rootObject = schema.getSubscriptionType(); - } else if (operation === 'mutation') { - rootObject = schema.getMutationType(); - } else { - rootObject = schema.getQueryType(); - } - if (rootObject) { - const fields = rootObject.getFields(); - if (fields[fieldName]) { - return schema; - } - } - } - throw new Error( - `Could not find subschema with field \`${operation}.${fieldName}\``, - ); -} - -function createDelegatingResolver( - schema: GraphQLSchema, - operation: 'query' | 'mutation' | 'subscription', - fieldName: string, -): IFieldResolver { - return (root, args, context, info) => { - return info.mergeInfo.delegateToSchema({ - schema, - operation, - fieldName, - args, - context, - info, - }); - }; -} - -type FieldIteratorFn = ( - fieldDef: GraphQLField, - typeName: string, - fieldName: string, -) => void; - -function forEachField(schema: GraphQLSchema, fn: FieldIteratorFn): void { - const typeMap = schema.getTypeMap(); - Object.keys(typeMap).forEach(typeName => { - const type = typeMap[typeName]; - - if ( - !getNamedType(type).name.startsWith('__') && - type instanceof GraphQLObjectType - ) { - const fields = type.getFields(); - Object.keys(fields).forEach(fieldName => { - const field = fields[fieldName]; - fn(field, typeName, fieldName); - }); - } - }); -} - -function addTypeCandidate( - typeCandidates: { [name: string]: Array }, - name: string, - typeCandidate: MergeTypeCandidate, -) { - if (!typeCandidates[name]) { - typeCandidates[name] = []; - } - typeCandidates[name].push(typeCandidate); -} - -function defaultVisitType( - name: string, - candidates: Array, - candidateSelector?: ( - candidates: Array, - ) => MergeTypeCandidate, -) { - if (!candidateSelector) { - candidateSelector = cands => cands[cands.length - 1]; - } - const resolveType = createResolveType((_, type) => type); - if (name === 'Query' || name === 'Mutation' || name === 'Subscription') { - let fields = {}; - let operationName: 'query' | 'mutation' | 'subscription'; - switch (name) { - case 'Query': - operationName = 'query'; - break; - case 'Mutation': - operationName = 'mutation'; - break; - case 'Subscription': - operationName = 'subscription'; - break; - default: - break; - } - const resolvers = {}; - const resolverKey = - operationName === 'subscription' ? 'subscribe' : 'resolve'; - candidates.forEach(({ type: candidateType, schema }) => { - const candidateFields = (candidateType as GraphQLObjectType).getFields(); - fields = { ...fields, ...candidateFields }; - Object.keys(candidateFields).forEach(fieldName => { - resolvers[fieldName] = { - [resolverKey]: createDelegatingResolver( - schema, - operationName, - fieldName, - ), - }; - }); - }); - const type = new GraphQLObjectType({ - name, - fields: fieldMapToFieldConfigMap(fields, resolveType, false), - }); - return { - type, - resolvers, - }; - } else { - const candidate = candidateSelector(candidates); - return candidate.type; - } -} diff --git a/src/stitching/resolveFromParentTypename.ts b/src/stitching/resolveFromParentTypename.ts deleted file mode 100644 index 88776b6838b..00000000000 --- a/src/stitching/resolveFromParentTypename.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { GraphQLObjectType, GraphQLSchema } from 'graphql'; - -export default function resolveFromParentTypename( - parent: any, - schema: GraphQLSchema, -) { - const parentTypename: string = parent['__typename']; - if (!parentTypename) { - throw new Error( - 'Did not fetch typename for object, unable to resolve interface.', - ); - } - - const resolvedType = schema.getType(parentTypename); - - if (!(resolvedType instanceof GraphQLObjectType)) { - throw new Error( - '__typename did not match an object type: ' + parentTypename, - ); - } - - return resolvedType; -} diff --git a/src/stitching/resolvers.ts b/src/stitching/resolvers.ts deleted file mode 100644 index 9a41972c468..00000000000 --- a/src/stitching/resolvers.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - GraphQLSchema, - GraphQLFieldResolver, - GraphQLObjectType, -} from 'graphql'; -import { IResolvers, Operation } from '../Interfaces'; -import delegateToSchema from './delegateToSchema'; -import { Transform } from '../transforms/index'; - -export type Mapping = { - [typeName: string]: { - [fieldName: string]: { - name: string; - operation: Operation; - }; - }; -}; - -export function generateProxyingResolvers( - targetSchema: GraphQLSchema, - transforms: Array, - mapping: Mapping, -): IResolvers { - const result = {}; - Object.keys(mapping).forEach(name => { - result[name] = {}; - const innerMapping = mapping[name]; - Object.keys(innerMapping).forEach(from => { - const to = innerMapping[from]; - const resolverType = - to.operation === 'subscription' ? 'subscribe' : 'resolve'; - result[name][from] = { - [resolverType]: createProxyingResolver( - targetSchema, - to.operation, - to.name, - transforms, - ), - }; - }); - }); - return result; -} - -export function generateSimpleMapping(targetSchema: GraphQLSchema): Mapping { - const query = targetSchema.getQueryType(); - const mutation = targetSchema.getMutationType(); - const subscription = targetSchema.getSubscriptionType(); - - const result: Mapping = {}; - if (query) { - result[query.name] = generateMappingFromObjectType(query, 'query'); - } - if (mutation) { - result[mutation.name] = generateMappingFromObjectType(mutation, 'mutation'); - } - if (subscription) { - result[subscription.name] = generateMappingFromObjectType( - subscription, - 'subscription', - ); - } - - return result; -} - -export function generateMappingFromObjectType( - type: GraphQLObjectType, - operation: Operation, -): { - [fieldName: string]: { - name: string; - operation: Operation; - }; -} { - const result = {}; - const fields = type.getFields(); - Object.keys(fields).forEach(fieldName => { - result[fieldName] = { - name: fieldName, - operation, - }; - }); - return result; -} - -function createProxyingResolver( - schema: GraphQLSchema, - operation: Operation, - fieldName: string, - transforms: Array, -): GraphQLFieldResolver { - return (parent, args, context, info) => delegateToSchema({ - schema, - operation, - fieldName, - args: {}, - context, - info, - transforms, - }); -} diff --git a/src/stitching/schemaRecreation.ts b/src/stitching/schemaRecreation.ts deleted file mode 100644 index 3c51530d93f..00000000000 --- a/src/stitching/schemaRecreation.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { - GraphQLArgument, - GraphQLArgumentConfig, - GraphQLEnumType, - GraphQLField, - GraphQLFieldConfig, - GraphQLFieldConfigArgumentMap, - GraphQLFieldConfigMap, - GraphQLFieldMap, - GraphQLInputField, - GraphQLInputFieldConfig, - GraphQLInputFieldConfigMap, - GraphQLInputFieldMap, - GraphQLInputObjectType, - GraphQLInterfaceType, - GraphQLList, - GraphQLNamedType, - GraphQLNonNull, - GraphQLObjectType, - GraphQLScalarType, - GraphQLType, - GraphQLUnionType, - GraphQLDirective, - Kind, - ValueNode, - getNamedType, - isNamedType, - GraphQLInt, - GraphQLFloat, - GraphQLString, - GraphQLBoolean, - GraphQLID, -} from 'graphql'; -import isSpecifiedScalarType from '../isSpecifiedScalarType'; -import { ResolveType } from '../Interfaces'; -import resolveFromParentTypename from './resolveFromParentTypename'; -import defaultMergedResolver from './defaultMergedResolver'; - -export function recreateType( - type: GraphQLNamedType, - resolveType: ResolveType, - keepResolvers: boolean, -): GraphQLNamedType { - if (type instanceof GraphQLObjectType) { - const fields = type.getFields(); - const interfaces = type.getInterfaces(); - - return new GraphQLObjectType({ - name: type.name, - description: type.description, - astNode: type.astNode, - isTypeOf: keepResolvers ? type.isTypeOf : undefined, - fields: () => - fieldMapToFieldConfigMap(fields, resolveType, keepResolvers), - interfaces: () => interfaces.map(iface => resolveType(iface)), - }); - } else if (type instanceof GraphQLInterfaceType) { - const fields = type.getFields(); - - return new GraphQLInterfaceType({ - name: type.name, - description: type.description, - astNode: type.astNode, - fields: () => - fieldMapToFieldConfigMap(fields, resolveType, keepResolvers), - resolveType: keepResolvers - ? type.resolveType - : (parent, context, info) => - resolveFromParentTypename(parent, info.schema), - }); - } else if (type instanceof GraphQLUnionType) { - return new GraphQLUnionType({ - name: type.name, - description: type.description, - astNode: type.astNode, - - types: () => type.getTypes().map(unionMember => resolveType(unionMember)), - resolveType: keepResolvers - ? type.resolveType - : (parent, context, info) => - resolveFromParentTypename(parent, info.schema), - }); - } else if (type instanceof GraphQLInputObjectType) { - return new GraphQLInputObjectType({ - name: type.name, - description: type.description, - astNode: type.astNode, - - fields: () => - inputFieldMapToFieldConfigMap(type.getFields(), resolveType), - }); - } else if (type instanceof GraphQLEnumType) { - const values = type.getValues(); - const newValues = {}; - values.forEach(value => { - newValues[value.name] = { - value: value.value, - deprecationReason: value.deprecationReason, - description: value.description, - astNode: value.astNode, - }; - }); - return new GraphQLEnumType({ - name: type.name, - description: type.description, - astNode: type.astNode, - values: newValues, - }); - } else if (type instanceof GraphQLScalarType) { - if (keepResolvers || isSpecifiedScalarType(type)) { - return type; - } else { - return new GraphQLScalarType({ - name: type.name, - description: type.description, - astNode: type.astNode, - serialize(value: any) { - return value; - }, - parseValue(value: any) { - return value; - }, - parseLiteral(ast: ValueNode) { - return parseLiteral(ast); - }, - }); - } - } else { - throw new Error(`Invalid type ${type}`); - } -} - -export function recreateDirective( - directive: GraphQLDirective, - resolveType: ResolveType, -): GraphQLDirective { - return new GraphQLDirective({ - name: directive.name, - description: directive.description, - locations: directive.locations, - args: argsToFieldConfigArgumentMap(directive.args, resolveType), - astNode: directive.astNode, - }); -} - -function parseLiteral(ast: ValueNode): any { - switch (ast.kind) { - case Kind.STRING: - case Kind.BOOLEAN: { - return ast.value; - } - case Kind.INT: - case Kind.FLOAT: { - return parseFloat(ast.value); - } - case Kind.OBJECT: { - const value = Object.create(null); - ast.fields.forEach(field => { - value[field.name.value] = parseLiteral(field.value); - }); - - return value; - } - case Kind.LIST: { - return ast.values.map(parseLiteral); - } - default: - return null; - } -} - -export function fieldMapToFieldConfigMap( - fields: GraphQLFieldMap, - resolveType: ResolveType, - keepResolvers: boolean, -): GraphQLFieldConfigMap { - const result: GraphQLFieldConfigMap = {}; - Object.keys(fields).forEach(name => { - const field = fields[name]; - const type = resolveType(field.type); - if (type !== null) { - result[name] = fieldToFieldConfig( - fields[name], - resolveType, - keepResolvers, - ); - } - }); - return result; -} - -export function createResolveType( - getType: (name: string, type: GraphQLType) => GraphQLType | null, -): ResolveType { - const resolveType = (type: T): T | GraphQLType => { - if (type instanceof GraphQLList) { - const innerType = resolveType(type.ofType); - if (innerType === null) { - return null; - } else { - return new GraphQLList(innerType) as T; - } - } else if (type instanceof GraphQLNonNull) { - const innerType = resolveType(type.ofType); - if (innerType === null) { - return null; - } else { - return new GraphQLNonNull(innerType) as T; - } - } else if (isNamedType(type)) { - const typeName = getNamedType(type).name; - switch (typeName) { - case GraphQLInt.name: - return GraphQLInt; - case GraphQLFloat.name: - return GraphQLFloat; - case GraphQLString.name: - return GraphQLString; - case GraphQLBoolean.name: - return GraphQLBoolean; - case GraphQLID.name: - return GraphQLID; - default: - return getType(typeName, type); - } - } else { - return type; - } - }; - return resolveType; -} - -export function fieldToFieldConfig( - field: GraphQLField, - resolveType: ResolveType, - keepResolvers: boolean, -): GraphQLFieldConfig { - return { - type: resolveType(field.type), - args: argsToFieldConfigArgumentMap(field.args, resolveType), - resolve: keepResolvers ? field.resolve : defaultMergedResolver, - subscribe: keepResolvers ? field.subscribe : null, - description: field.description, - deprecationReason: field.deprecationReason, - astNode: field.astNode, - }; -} - -export function argsToFieldConfigArgumentMap( - args: Array, - resolveType: ResolveType, -): GraphQLFieldConfigArgumentMap { - const result: GraphQLFieldConfigArgumentMap = {}; - args.forEach(arg => { - const newArg = argumentToArgumentConfig(arg, resolveType); - if (newArg) { - result[newArg[0]] = newArg[1]; - } - }); - return result; -} - -export function argumentToArgumentConfig( - argument: GraphQLArgument, - resolveType: ResolveType, -): [string, GraphQLArgumentConfig] | null { - const type = resolveType(argument.type); - if (type === null) { - return null; - } else { - return [ - argument.name, - { - type: type, - defaultValue: argument.defaultValue, - description: argument.description, - }, - ]; - } -} - -export function inputFieldMapToFieldConfigMap( - fields: GraphQLInputFieldMap, - resolveType: ResolveType, -): GraphQLInputFieldConfigMap { - const result: GraphQLInputFieldConfigMap = {}; - Object.keys(fields).forEach(name => { - const field = fields[name]; - const type = resolveType(field.type); - if (type !== null) { - result[name] = inputFieldToFieldConfig(fields[name], resolveType); - } - }); - return result; -} - -export function inputFieldToFieldConfig( - field: GraphQLInputField, - resolveType: ResolveType, -): GraphQLInputFieldConfig { - return { - type: resolveType(field.type), - defaultValue: field.defaultValue, - description: field.description, - astNode: field.astNode, - }; -} diff --git a/src/test/circularSchemaA.ts b/src/test/circularSchemaA.ts index 5642b5078c8..12c1bb403f6 100644 --- a/src/test/circularSchemaA.ts +++ b/src/test/circularSchemaA.ts @@ -1,6 +1,6 @@ import TypeB from './circularSchemaB'; -export default () => [ +const TypeA = () => [ ` type TypeA { id: ID @@ -8,3 +8,5 @@ type TypeA { }`, TypeB, ]; + +export default TypeA; diff --git a/src/test/circularSchemaB.ts b/src/test/circularSchemaB.ts index 5b564266722..e417fecc203 100644 --- a/src/test/circularSchemaB.ts +++ b/src/test/circularSchemaB.ts @@ -1,6 +1,6 @@ import TypeA from './circularSchemaA'; -export default () => [ +const TypeB = () => [ ` type TypeB { id: ID @@ -8,3 +8,5 @@ type TypeB { }`, TypeA, ]; + +export default TypeB; diff --git a/src/test/testAlternateMergeSchemas.ts b/src/test/testAlternateMergeSchemas.ts index e593d0028e0..af4fac1a9f1 100644 --- a/src/test/testAlternateMergeSchemas.ts +++ b/src/test/testAlternateMergeSchemas.ts @@ -1,17 +1,57 @@ -/* tslint:disable:no-unused-expression */ - +import { + graphql, + GraphQLSchema, + ExecutionResult, + subscribe, + parse, + GraphQLScalarType, + FieldNode, + printSchema, + graphqlSync, + GraphQLField, +} from 'graphql'; +import { forAwaitEach } from 'iterall'; import { expect } from 'chai'; -import { graphql, GraphQLSchema } from 'graphql'; -import mergeSchemas from '../stitching/mergeSchemas'; + import { transformSchema, - FilterRootFields, + wrapSchema, RenameTypes, RenameRootFields, -} from '../transforms'; -import { propertySchema, bookingSchema } from './testingSchemas'; + RenameObjectFields, + TransformObjectFields, + ExtendSchema, + WrapType, + WrapFields, + HoistField, + FilterRootFields, + FilterObjectFields, + RenameInterfaceFields, + TransformRootFields, +} from '../wrap/index'; +import { isSpecifiedScalarType, toConfig } from '../polyfills/index'; + +import { delegateToSchema } from '../delegate/index'; +import { makeExecutableSchema } from '../generate/index'; +import { mergeSchemas, createMergedResolver } from '../stitch/index'; +import { SubschemaConfig } from '../Interfaces'; +import { + filterSchema, + wrapFieldNode, + renameFieldNode, + hoistFieldNodes, + graphqlVersion, +} from '../utils/index'; + +import { + propertySchema, + remoteBookingSchema, + subscriptionSchema, + subscriptionPubSub, + subscriptionPubSubTrigger, +} from './testingSchemas'; -let linkSchema = ` +const linkSchema = ` """ A new type linking the Property type. """ @@ -56,80 +96,107 @@ let linkSchema = ` `; describe('merge schemas through transforms', () => { + let bookingSubschemaConfig: SubschemaConfig; let mergedSchema: GraphQLSchema; before(async () => { + bookingSubschemaConfig = await remoteBookingSchema; + // namespace and strip schemas - const transformedPropertySchema = transformSchema(propertySchema, [ + const propertySchemaTransforms = [ new FilterRootFields( (operation: string, rootField: string) => - 'Query.properties' === `${operation}.${rootField}`, + `${operation}.${rootField}` === 'Query.properties', ), new RenameTypes((name: string) => `Properties_${name}`), - new RenameRootFields((name: string) => `Properties_${name}`), - ]); - const transformedBookingSchema = transformSchema(bookingSchema, [ + new RenameRootFields( + (_operation: string, name: string) => `Properties_${name}`, + ), + ]; + const bookingSchemaTransforms = [ new FilterRootFields( (operation: string, rootField: string) => - 'Query.bookings' === `${operation}.${rootField}`, + `${operation}.${rootField}` === 'Query.bookings', ), new RenameTypes((name: string) => `Bookings_${name}`), new RenameRootFields( - (operation: string, name: string) => `Bookings_${name}`, + (_operation: string, name: string) => `Bookings_${name}`, ), - ]); + ]; + const subscriptionSchemaTransforms = [ + new FilterRootFields( + (operation: string, rootField: string) => + // must include a Query type otherwise graphql will error + `${operation}.${rootField}` === 'Query.notifications' || + `${operation}.${rootField}` === 'Subscription.notifications', + ), + new RenameTypes((name: string) => `Subscriptions_${name}`), + new RenameRootFields( + (_operation: string, name: string) => `Subscriptions_${name}`, + ), + ]; + + const propertySubschema = { + schema: propertySchema, + transforms: propertySchemaTransforms, + }; + const bookingSubschema = { + ...bookingSubschemaConfig, + transforms: bookingSchemaTransforms, + }; + const subscriptionSubschema = { + schema: subscriptionSchema, + transforms: subscriptionSchemaTransforms, + }; mergedSchema = mergeSchemas({ - schemas: [ - transformedPropertySchema, - transformedBookingSchema, - linkSchema, - ], + subschemas: [propertySubschema, bookingSubschema, subscriptionSubschema], + typeDefs: linkSchema, resolvers: { Query: { // delegating directly, no subschemas or mergeInfo - node(parent, args, context, info) { + node: (_parent, args, context, info) => { if (args.id.startsWith('p')) { return info.mergeInfo.delegateToSchema({ - schema: propertySchema, + schema: propertySubschema, operation: 'query', fieldName: 'propertyById', args, context, info, - transforms: transformedPropertySchema.transforms, + transforms: [], }); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegateToSchema({ - schema: bookingSchema, + return delegateToSchema({ + schema: bookingSubschema, operation: 'query', fieldName: 'bookingById', args, context, info, - transforms: transformedBookingSchema.transforms, + transforms: [], }); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegateToSchema({ - schema: bookingSchema, + return delegateToSchema({ + schema: bookingSubschema, operation: 'query', fieldName: 'customerById', args, context, info, - transforms: transformedBookingSchema.transforms, + transforms: [], }); - } else { - throw new Error('invalid id'); } + throw new Error('invalid id'); }, }, + // eslint-disable-next-line camelcase Properties_Property: { bookings: { fragment: 'fragment PropertyFragment on Property { id }', - resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ - schema: bookingSchema, + resolve: (parent, args, context, info) => + delegateToSchema({ + schema: bookingSubschema, operation: 'query', fieldName: 'bookingsByPropertyId', args: { @@ -138,17 +205,16 @@ describe('merge schemas through transforms', () => { }, context, info, - transforms: transformedBookingSchema.transforms, - }); - }, + }), }, }, + // eslint-disable-next-line camelcase Bookings_Booking: { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', - resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ - schema: propertySchema, + resolve: (parent, _args, context, info) => + info.mergeInfo.delegateToSchema({ + schema: propertySubschema, operation: 'query', fieldName: 'propertyById', args: { @@ -156,9 +222,7 @@ describe('merge schemas through transforms', () => { }, context, info, - transforms: transformedPropertySchema.transforms, - }); - }, + }), }, }, }, @@ -234,98 +298,1660 @@ describe('merge schemas through transforms', () => { }, }); }); + + it('local subscriptions should work even if root fields are renamed', (done) => { + const originalNotification = { + notifications: { + text: 'Hello world', + }, + }; + + const transformedNotification = { + // eslint-disable-next-line camelcase + Subscriptions_notifications: originalNotification.notifications, + }; + + const subscription = parse(` + subscription Subscription { + Subscriptions_notifications { + text + } + } + `); + + let notificationCnt = 0; + subscribe(mergedSchema, subscription) + .then((results) => { + forAwaitEach( + results as AsyncIterable, + (result: ExecutionResult) => { + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(transformedNotification); + if (!notificationCnt++) { + return done(); + } + }, + ).catch(done); + }) + .then(() => + subscriptionPubSub.publish( + subscriptionPubSubTrigger, + originalNotification, + ), + ) + .catch(done); + }); }); -describe('interface resolver inheritance', () => { - const testSchemaWithInterfaceResolvers = ` - interface Node { - id: ID! - } - type User implements Node { - id: ID! - name: String! - } - type Query { - user: User! - } - schema { - query: Query - } - `; - const user = { _id: 1, name: 'Ada', type: 'User' }; - const resolvers = { - Node: { - __resolveType: ({ type }: { type: string }) => type, - id: ({ _id }: { _id: number }) => `Node:${_id}`, - }, - User: { - name: ({ name }: { name: string}) => `User:${name}` - }, - Query: { - user: () => user - } - }; +describe('transform object fields', () => { + it('should work to add a resolver', async () => { + const transformedPropertySchema = transformSchema(propertySchema, [ + new TransformObjectFields( + ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => { + if (typeName !== 'Property' || fieldName !== 'name') { + return undefined; + } + return { + ...toConfig(field), + resolve: () => 'test', + }; + }, + (typeName: string, fieldName: string, fieldNode: FieldNode) => { + if (typeName !== 'Property' || fieldName !== 'name') { + return fieldNode; + } + const newFieldNode = { + ...fieldNode, + name: { + ...fieldNode.name, + value: 'id', + }, + }; + return newFieldNode; + }, + ), + ]); - it('copies resolvers from interface', async () => { - const mergedSchema = mergeSchemas({ - schemas: [ - // pull in an executable schema just so mergeSchema doesn't complain - // about not finding default types (e.g. ID) - propertySchema, - testSchemaWithInterfaceResolvers - ], - resolvers, - inheritResolversFromInterfaces: true + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + id + name + location { + name + } + } + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + expect(result).to.deep.equal({ + data: { + propertyById: { + id: 'p1', + name: 'test', + location: { + name: 'Helsinki', + }, + }, + }, }); - const query = `{ user { id name } }`; - const response = await graphql(mergedSchema, query); - expect(response).to.deep.equal({ + }); +}); + +describe('default values', () => { + it('should work to add a default value even when renaming root fields', async () => { + const transformedPropertySchema = transformSchema(propertySchema, [ + new TransformRootFields( + ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => { + if (typeName === 'Query' && fieldName === 'jsonTest') { + const fieldConfig = toConfig(field); + fieldConfig.args.input.defaultValue = { test: 'test' }; + return { name: 'renamedJsonTest', field: fieldConfig }; + } + }, + ), + ]); + + const result = await graphql( + transformedPropertySchema, + ` + query { + renamedJsonTest + } + `, + ); + + expect(result).to.deep.equal({ data: { - user: { - id: `Node:1`, - name: `User:Ada` + renamedJsonTest: { + test: 'test', + }, + }, + }); + }); +}); + +describe('rename fields that implement interface fields', () => { + it('should work', () => { + const originalItem = { + id: '123', + camel: "I'm a camel!", + }; + + const originalSchema = makeExecutableSchema({ + typeDefs: ` + interface Node { + id: ID! + } + interface Item { + node: Node + } + type Camel implements Node { + id: ID! + camel: String! + } + type Query implements Item { + node: Node + } + `, + resolvers: { + Query: { + node: () => originalItem, + }, + Node: { + __resolveType: () => 'Camel', + }, + }, + }); + + const wrappedSchema = wrapSchema(originalSchema, [ + new RenameRootFields((_operation, fieldName) => { + if (fieldName === 'node') { + return '_node'; + } + return fieldName; + }), + new RenameInterfaceFields((typeName, fieldName) => { + if (typeName === 'Item' && fieldName === 'node') { + return '_node'; + } + return fieldName; + }), + ]); + + const originalQuery = ` + query { + node { + id + ... on Camel { + camel + } + } + } + `; + + const newQuery = ` + query { + _node { + id + ... on Camel { + camel + } } } + `; + + const originalResult = graphqlSync(originalSchema, originalQuery); + expect(originalResult).to.deep.equal({ data: { node: originalItem } }); + + const newResult = graphqlSync(wrappedSchema, newQuery); + expect(newResult).to.deep.equal({ data: { _node: originalItem } }); + }); +}); + +describe('transform object fields', () => { + let schema: GraphQLSchema; + + before(() => { + const ITEM = { + id: '123', + // eslint-disable-next-line camelcase + camel_case: "I'm a camel!", + }; + + const itemSchema = makeExecutableSchema({ + typeDefs: ` + type Item { + id: ID! + camel_case: String + } + type ItemConnection { + edges: [ItemEdge!]! + } + type ItemEdge { + node: Item! + } + type Query { + item: Item + allItems: ItemConnection! + } + `, + resolvers: { + Query: { + item: () => ITEM, + allItems: () => ({ + edges: [ + { + node: ITEM, + }, + ], + }), + }, + }, }); + + schema = transformSchema(itemSchema, [ + new FilterObjectFields((_typeName, fieldName) => { + if (fieldName === 'id') { + return false; + } + return true; + }), + new RenameRootFields((_operation, fieldName) => { + if (fieldName === 'allItems') { + return 'items'; + } + return fieldName; + }), + new RenameObjectFields((_typeName, fieldName) => { + if (fieldName === 'camel_case') { + return 'camelCase'; + } + return fieldName; + }), + ]); }); - it('does not copy resolvers from interface when flag is false', -async () => { - const mergedSchema = mergeSchemas({ - schemas: [ - // pull in an executable schema just so mergeSchema doesn't complain - // about not finding default types (e.g. ID) - propertySchema, - testSchemaWithInterfaceResolvers - ], - resolvers, - inheritResolversFromInterfaces: false + it('renaming should work', async () => { + const result = await graphql( + schema, + ` + query { + item { + camelCase + } + items { + edges { + node { + camelCase + } + } + } + } + `, + ); + + const TRANSFORMED_ITEM = { + camelCase: "I'm a camel!", + }; + + expect(result).to.deep.equal({ + data: { + item: TRANSFORMED_ITEM, + items: { + edges: [ + { + node: TRANSFORMED_ITEM, + }, + ], + }, + }, }); - const query = `{ user { id name } }`; - const response = await graphql(mergedSchema, query); - expect(response.errors.length).to.equal(1); - expect(response.errors[0].message).to.equal('Cannot return null for ' + - 'non-nullable field User.id.'); - expect(response.errors[0].path).to.deep.equal(['user', 'id']); }); - it('does not copy resolvers from interface when flag is not provided', -async () => { - const mergedSchema = mergeSchemas({ - schemas: [ - // pull in an executable schema just so mergeSchema doesn't complain - // about not finding default types (e.g. ID) - propertySchema, - testSchemaWithInterfaceResolvers + it('filtering should work', async () => { + const result = await graphql( + schema, + ` + query { + items { + edges { + node { + id + } + } + } + } + `, + ); + + const expectedResult: any = { + errors: [ + { + locations: [ + { + column: 17, + line: 6, + }, + ], + message: 'Cannot query field "id" on type "Item".', + }, ], - resolvers - }); - const query = `{ user { id name } }`; - const response = await graphql(mergedSchema, query); - expect(response.errors.length).to.equal(1); - expect(response.errors[0].message).to.equal('Cannot return null for ' + - 'non-nullable field User.id.'); - expect(response.errors[0].path).to.deep.equal(['user', 'id']); + }; + + if (graphqlVersion() < 14) { + expectedResult.errors[0].path = undefined; + } + + expect(result).to.deep.equal(expectedResult); }); }); +describe('filter and rename object fields', () => { + let transformedPropertySchema: GraphQLSchema; + + before(() => { + transformedPropertySchema = filterSchema({ + schema: transformSchema(propertySchema, [ + new RenameTypes((name: string) => `New_${name}`), + new RenameObjectFields((typeName: string, fieldName: string) => + typeName === 'New_Property' ? `new_${fieldName}` : fieldName, + ), + ]), + rootFieldFilter: (operation: string, fieldName: string) => + `${operation}.${fieldName}` === 'Query.propertyById', + fieldFilter: (typeName: string, fieldName: string) => + typeName === 'New_Property' || fieldName === 'name', + typeFilter: (typeName: string, type) => + typeName === 'New_Property' || + typeName === 'New_Location' || + isSpecifiedScalarType(type), + }); + }); + + it('should filter', () => { + if (graphqlVersion() >= 15) { + expect(printSchema(transformedPropertySchema)).to + .equal(`type New_Property { + new_id: ID! + new_name: String! + new_location: New_Location + new_error: String +} + +type New_Location { + name: String! +} + +type Query { + propertyById(id: ID!): New_Property +} +`); + } else { + expect(printSchema(transformedPropertySchema)).to + .equal(`type New_Location { + name: String! +} + +type New_Property { + new_id: ID! + new_name: String! + new_location: New_Location + new_error: String +} + +type Query { + propertyById(id: ID!): New_Property +} +`); + } + }); + + it('should work', async () => { + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + new_id + new_name + new_location { + name + } + new_error + } + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + const expectedResult: any = { + data: { + propertyById: { + // eslint-disable-next-line camelcase + new_id: 'p1', + // eslint-disable-next-line camelcase + new_name: 'Super great hotel', + // eslint-disable-next-line camelcase + new_location: { + name: 'Helsinki', + }, + // eslint-disable-next-line camelcase + new_error: null, + }, + }, + errors: [ + { + locations: [ + { + column: 13, + line: 9, + }, + ], + message: 'Property.error error', + path: ['propertyById', 'new_error'], + }, + ], + }; + + if (graphqlVersion() >= 14) { + expectedResult.errors[0].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + } + + expect(result).to.deep.equal(expectedResult); + expect(result.errors[0].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', + }); + }); +}); + +describe('rename nested object fields with interfaces', () => { + it('should work', () => { + const originalNode = { + aList: [ + { + anInnerObject: { + _linkType: 'linkedItem', + aString: 'Hello, world', + }, + }, + ], + }; + + const transformedNode = { + ALIST: [ + { + ANINNEROBJECT: { + _linkType: 'linkedItem', + ASTRING: 'Hello, world', + }, + }, + ], + }; + + const originalSchema = makeExecutableSchema({ + typeDefs: ` + interface _Linkable { + _linkType: String! + } + type linkedItem implements _Linkable { + _linkType: String! + aString: String! + } + type aLink { + anInnerObject: _Linkable + } + type aObject { + aList: [aLink!] + } + type Query { + node: aObject + } + `, + resolvers: { + _Linkable: { + __resolveType: (linkable: { _linkType: string }) => + linkable._linkType, + }, + Query: { + node: () => originalNode, + }, + }, + }); + + const transformedSchema = transformSchema(originalSchema, [ + new RenameObjectFields((typeName, fieldName) => { + if (typeName === 'Query') { + return fieldName; + } + + // Remote uses leading underscores for special fields. Leave them alone. + if (fieldName[0] === '_') { + return fieldName; + } + + return fieldName.toUpperCase(); + }), + ]); + + const originalQuery = ` + query { + node { + aList { + anInnerObject { + _linkType + ... on linkedItem { + aString + } + } + } + } + } + `; + + const transformedQuery = ` + query { + node { + ALIST { + ANINNEROBJECT { + _linkType + ... on linkedItem { + ASTRING + } + } + } + } + } + `; + + const originalResult = graphqlSync(originalSchema, originalQuery); + const transformedResult = graphqlSync(transformedSchema, transformedQuery); + + expect(originalResult).to.deep.equal({ data: { node: originalNode } }); + expect(transformedResult).to.deep.equal({ + data: { node: transformedNode }, + }); + }); +}); + +describe('WrapType transform', () => { + it('should work', async () => { + const transformedPropertySchema = transformSchema(propertySchema, [ + new WrapType('Query', 'Namespace_Query', 'namespace'), + ]); + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + namespace { + propertyById(id: $pid) { + id + name + error + } + } + } + `, + undefined, + undefined, + { + pid: 'p1', + }, + ); + + const expectedResult: any = { + data: { + namespace: { + propertyById: { + id: 'p1', + name: 'Super great hotel', + error: null, + }, + }, + }, + errors: [ + { + locations: [ + { + column: 15, + line: 7, + }, + ], + message: 'Property.error error', + path: ['namespace', 'propertyById', 'error'], + }, + ], + }; + + if (graphqlVersion() >= 14) { + expectedResult.errors[0].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + } + + expect(result).to.deep.equal(expectedResult); + expect(result.errors[0].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', + }); + }); +}); + +describe('schema transformation with extraction of nested fields', () => { + it('should work via ExtendSchema transform', async () => { + const transformedPropertySchema = transformSchema(propertySchema, [ + new ExtendSchema({ + typeDefs: ` + extend type Property { + locationName: String + renamedError: String + } + `, + resolvers: { + Property: { + locationName: createMergedResolver({ fromPath: ['location'] }), + }, + }, + fieldNodeTransformerMap: { + Property: { + locationName: (fieldNode) => + wrapFieldNode(renameFieldNode(fieldNode, 'name'), ['location']), + renamedError: (fieldNode) => renameFieldNode(fieldNode, 'error'), + }, + }, + }), + ]); + + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + id + name + test: locationName + renamedError + } + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + const expectedResult: any = { + data: { + propertyById: { + id: 'p1', + name: 'Super great hotel', + test: 'Helsinki', + renamedError: null, + }, + }, + errors: [ + { + locations: [ + { + column: 13, + line: 7, + }, + ], + message: 'Property.error error', + path: ['propertyById', 'renamedError'], + }, + ], + }; + + if (graphqlVersion() >= 14) { + expectedResult.errors[0].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + } + + expect(result).to.deep.equal(expectedResult); + expect(result.errors[0].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', + }); + }); + + it('should work via HoistField transform', async () => { + const transformedPropertySchema = transformSchema(propertySchema, [ + new HoistField('Property', ['location', 'name'], 'locationName'), + ]); + + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + test: locationName + } + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + expect(result).to.deep.equal({ + data: { + propertyById: { + test: 'Helsinki', + }, + }, + }); + }); +}); + +describe('schema transformation with wrapping of object fields', () => { + it('should work via ExtendSchema transform', async () => { + const transformedPropertySchema = transformSchema(propertySchema, [ + new ExtendSchema({ + typeDefs: ` + extend type Property { + outerWrap: OuterWrap + } + + type OuterWrap { + innerWrap: InnerWrap + } + + type InnerWrap { + id: ID + name: String + error: String + } + `, + resolvers: { + Property: { + outerWrap: createMergedResolver({ dehoist: true }), + }, + }, + fieldNodeTransformerMap: { + Property: { + outerWrap: (fieldNode, fragments) => + hoistFieldNodes({ + fieldNode, + fieldNames: ['id', 'name', 'error'], + path: ['innerWrap'], + fragments, + }), + }, + }, + }), + ]); + + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + test1: outerWrap { + innerWrap { + ...W1 + } + } + test2: outerWrap { + innerWrap { + ...W2 + } + } + } + } + fragment W1 on InnerWrap { + one: id + two: error + } + fragment W2 on InnerWrap { + one: name + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + const expectedResult: any = { + data: { + propertyById: { + test1: { + innerWrap: { + one: 'p1', + two: null, + }, + }, + test2: { + innerWrap: { + one: 'Super great hotel', + }, + }, + }, + }, + errors: [ + { + locations: [ + { + column: 11, + line: 18, + }, + ], + message: 'Property.error error', + path: ['propertyById', 'test1', 'innerWrap', 'two'], + }, + ], + }; + + if (graphqlVersion() >= 14) { + expectedResult.errors[0].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + } + + expect(result).to.deep.equal(expectedResult); + expect(result.errors[0].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', + }); + }); + + describe('WrapFields transform', () => { + it('should work', async () => { + const transformedPropertySchema = transformSchema(propertySchema, [ + new WrapFields( + 'Property', + ['outerWrap'], + ['OuterWrap'], + ['id', 'name', 'error'], + ), + ]); + + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + test1: outerWrap { + ...W1 + } + test2: outerWrap { + ...W2 + } + } + } + fragment W1 on OuterWrap { + one: id + two: error + } + fragment W2 on OuterWrap { + one: name + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + const expectedResult: any = { + data: { + propertyById: { + test1: { + one: 'p1', + two: null, + }, + test2: { + one: 'Super great hotel', + }, + }, + }, + errors: [ + { + locations: [ + { + column: 13, + line: 14, + }, + ], + message: 'Property.error error', + path: ['propertyById', 'test1', 'two'], + }, + ], + }; + + if (graphqlVersion() >= 14) { + expectedResult.errors[0].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + } + + expect(result).to.deep.equal(expectedResult); + expect(result.errors[0].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', + }); + }); + + it('should work, even with multiple fields', async () => { + const transformedPropertySchema = transformSchema(propertySchema, [ + new WrapFields( + 'Property', + ['outerWrap', 'innerWrap'], + ['OuterWrap', 'InnerWrap'], + ['id', 'name', 'error'], + ), + ]); + + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + test1: outerWrap { + innerWrap { + ...W1 + } + } + test2: outerWrap { + innerWrap { + ...W2 + } + } + } + } + fragment W1 on InnerWrap { + one: id + two: error + } + fragment W2 on InnerWrap { + one: name + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + const expectedResult: any = { + data: { + propertyById: { + test1: { + innerWrap: { + one: 'p1', + two: null, + }, + }, + test2: { + innerWrap: { + one: 'Super great hotel', + }, + }, + }, + }, + errors: [ + { + locations: [ + { + column: 13, + line: 18, + }, + ], + message: 'Property.error error', + path: ['propertyById', 'test1', 'innerWrap', 'two'], + }, + ], + }; + + if (graphqlVersion() >= 14) { + expectedResult.errors[0].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + } + + expect(result).to.deep.equal(expectedResult); + expect(result.errors[0].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', + }); + }); + }); +}); + +describe('schema transformation with renaming of object fields', () => { + let transformedPropertySchema: GraphQLSchema; + + before(() => { + transformedPropertySchema = transformSchema(propertySchema, [ + new ExtendSchema({ + typeDefs: ` + extend type Property { + new_error: String + } + `, + fieldNodeTransformerMap: { + Property: { + // eslint-disable-next-line camelcase + new_error: (fieldNode) => renameFieldNode(fieldNode, 'error'), + }, + }, + }), + ]); + }); + + it('should work, even with aliases, and should preserve errors', async () => { + const result = await graphql( + transformedPropertySchema, + ` + query($pid: ID!) { + propertyById(id: $pid) { + new_error + } + } + `, + {}, + {}, + { + pid: 'p1', + }, + ); + + const expectedResult: any = { + data: { + propertyById: { + // eslint-disable-next-line camelcase + new_error: null, + }, + }, + errors: [ + { + locations: [ + { + column: 13, + line: 4, + }, + ], + message: 'Property.error error', + path: ['propertyById', 'new_error'], + }, + ], + }; + + if (graphqlVersion() >= 14) { + expectedResult.errors[0].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + } + + expect(result).to.deep.equal(expectedResult); + expect(result.errors[0].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', + }); + }); +}); + +describe('interface resolver inheritance', () => { + const testSchemaWithInterfaceResolvers = ` + interface Node { + id: ID! + } + type User implements Node { + id: ID! + name: String! + } + type Query { + user: User! + } + schema { + query: Query + } + `; + const user = { _id: 1, name: 'Ada', type: 'User' }; + const resolvers = { + Node: { + __resolveType: ({ type }: { type: string }) => type, + id: ({ _id }: { _id: number }) => `Node:${_id.toString()}`, + }, + User: { + name: ({ name }: { name: string }) => `User:${name}`, + }, + Query: { + user: () => user, + }, + }; + + it('copies resolvers from interface', async () => { + const mergedSchema = mergeSchemas({ + schemas: [ + // pull in an executable schema just so mergeSchema doesn't complain + // about not finding default types (e.g. ID) + propertySchema, + testSchemaWithInterfaceResolvers, + ], + resolvers, + inheritResolversFromInterfaces: true, + }); + const query = '{ user { id name } }'; + const response = await graphql(mergedSchema, query); + expect(response).to.deep.equal({ + data: { + user: { + id: 'Node:1', + name: 'User:Ada', + }, + }, + }); + }); + + it('does not copy resolvers from interface when flag is false', async () => { + const mergedSchema = mergeSchemas({ + schemas: [ + // pull in an executable schema just so mergeSchema doesn't complain + // about not finding default types (e.g. ID) + propertySchema, + testSchemaWithInterfaceResolvers, + ], + resolvers, + inheritResolversFromInterfaces: false, + }); + const query = '{ user { id name } }'; + const response = await graphql(mergedSchema, query); + expect(response.errors.length).to.equal(1); + expect(response.errors[0].message).to.equal( + 'Cannot return null for non-nullable field User.id.', + ); + expect(response.errors[0].path).to.deep.equal(['user', 'id']); + }); + + it('does not copy resolvers from interface when flag is not provided', async () => { + const mergedSchema = mergeSchemas({ + schemas: [ + // pull in an executable schema just so mergeSchema doesn't complain + // about not finding default types (e.g. ID) + propertySchema, + testSchemaWithInterfaceResolvers, + ], + resolvers, + }); + const query = '{ user { id name } }'; + const response = await graphql(mergedSchema, query); + expect(response.errors.length).to.equal(1); + expect(response.errors[0].message).to.equal( + 'Cannot return null for non-nullable field User.id.', + ); + expect(response.errors[0].path).to.deep.equal(['user', 'id']); + }); +}); + +describe('mergeSchemas', () => { + it('can merge null root fields', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + test: Test + } + type Test { + field: String + } + `, + resolvers: { + Query: { + test: () => null, + }, + }, + }); + const mergedSchema = mergeSchemas({ + schemas: [schema], + }); + + const query = '{ test { field } }'; + const response = await graphql(mergedSchema, query); + expect(response.data.test).to.equal(null); + expect(response.errors).to.equal(undefined); + }); + + it('can merge default input types', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + input InputWithDefault { + field: String = "test" + } + type Query { + getInput(input: InputWithDefault!): String + } + `, + resolvers: { + Query: { + getInput: (_root, args) => args.input.field, + }, + }, + }); + const mergedSchema = mergeSchemas({ + schemas: [schema], + }); + + const query = '{ getInput(input: {}) }'; + const response = await graphql(mergedSchema, query); + + if (graphqlVersion() >= 15) { + expect(printSchema(schema)).to.equal(`input InputWithDefault { + field: String = "test" +} + +type Query { + getInput(input: InputWithDefault!): String +} +`); + } else { + expect(printSchema(schema)).to.equal(printSchema(mergedSchema)); + } + expect(response.data?.getInput).to.equal('test'); + }); + + it('can override scalars with new internal values', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + scalar TestScalar + type Query { + getTestScalar: TestScalar + } + `, + resolvers: { + TestScalar: new GraphQLScalarType({ + name: 'TestScalar', + description: undefined, + serialize: (value) => (value as string).slice(1), + parseValue: (value) => `_${value as string}`, + parseLiteral: (ast: any) => `_${ast.value as string}`, + }), + Query: { + getTestScalar: () => '_test', + }, + }, + }); + const mergedSchema = mergeSchemas({ + schemas: [schema], + resolvers: { + TestScalar: new GraphQLScalarType({ + name: 'TestScalar', + description: undefined, + serialize: (value) => (value as string).slice(2), + parseValue: (value) => `__${value as string}`, + parseLiteral: (ast: any) => `__${ast.value as string}`, + }), + }, + }); + + const query = '{ getTestScalar }'; + const response = await graphql(mergedSchema, query); + + expect(response.data?.getTestScalar).to.equal('test'); + }); + + it('can override scalars with new internal values when using default input types', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + scalar TestScalar + type Query { + getTestScalar(input: TestScalar = "test"): TestScalar + } + `, + resolvers: { + TestScalar: new GraphQLScalarType({ + name: 'TestScalar', + description: undefined, + serialize: (value) => (value as string).slice(1), + parseValue: (value) => `_${value as string}`, + parseLiteral: (ast: any) => `_${ast.value as string}`, + }), + Query: { + getTestScalar: () => '_test', + }, + }, + }); + const mergedSchema = mergeSchemas({ + schemas: [schema], + resolvers: { + TestScalar: new GraphQLScalarType({ + name: 'TestScalar', + description: undefined, + serialize: (value) => (value as string).slice(2), + parseValue: (value) => `__${value as string}`, + parseLiteral: (ast: any) => `__${ast.value as string}`, + }), + }, + }); + + const query = '{ getTestScalar }'; + const response = await graphql(mergedSchema, query); + + expect(response.data?.getTestScalar).to.equal('test'); + }); + + it('can use @include directives', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type WrappingType { + subfield: String + } + type Query { + get1: WrappingType + } + `, + resolvers: { + Query: { + get1: () => ({ subfield: 'test' }), + }, + }, + }); + const mergedSchema = mergeSchemas({ + schemas: [ + schema, + ` + type Query { + get2: WrappingType + } + `, + ], + resolvers: { + Query: { + get2: (_root, _args, context, info) => + delegateToSchema({ + schema, + operation: 'query', + fieldName: 'get1', + context, + info, + }), + }, + }, + }); + + const query = ` + { + get2 @include(if: true) { + subfield + } + } + `; + const response = await graphql(mergedSchema, query); + expect(response.data?.get2.subfield).to.equal('test'); + }); + + it('can use functions in subfields', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type WrappingObject { + functionField: Int! + } + type Query { + wrappingObject: WrappingObject + } + `, + }); + + const mergedSchema = mergeSchemas({ + schemas: [schema], + resolvers: { + Query: { + wrappingObject: () => ({ + functionField: () => 8, + }), + }, + }, + }); + + const query = '{ wrappingObject { functionField } }'; + const response = await graphql(mergedSchema, query); + expect(response.data?.wrappingObject.functionField).to.equal(8); + }); +}); + +describe('onTypeConflict', () => { + let schema1: GraphQLSchema; + let schema2: GraphQLSchema; + + beforeEach(() => { + const typeDefs1 = ` + type Query { + test1: Test + } + + type Test { + fieldA: String + fieldB: String + } + `; + + const typeDefs2 = ` + type Query { + test2: Test + } + + type Test { + fieldA: String + fieldC: String + } + `; + + schema1 = makeExecutableSchema({ + typeDefs: typeDefs1, + resolvers: { + Query: { + test1: () => ({}), + }, + Test: { + fieldA: () => 'A', + fieldB: () => 'B', + }, + }, + }); + + schema2 = makeExecutableSchema({ + typeDefs: typeDefs2, + resolvers: { + Query: { + test2: () => ({}), + }, + Test: { + fieldA: () => 'A', + fieldC: () => 'C', + }, + }, + }); + }); + + it('by default takes last type', async () => { + const mergedSchema = mergeSchemas({ + schemas: [schema1, schema2], + }); + const result1 = await graphql(mergedSchema, '{ test2 { fieldC } }'); + expect(result1.data?.test2.fieldC).to.equal('C'); + const result2 = await graphql(mergedSchema, '{ test2 { fieldB } }'); + expect(result2.data).to.equal(undefined); + }); + + it('can use onTypeConflict to select last type', async () => { + const mergedSchema = mergeSchemas({ + schemas: [schema1, schema2], + onTypeConflict: (_left, right) => right, + }); + const result1 = await graphql(mergedSchema, '{ test2 { fieldC } }'); + expect(result1.data?.test2.fieldC).to.equal('C'); + const result2 = await graphql(mergedSchema, '{ test2 { fieldB } }'); + expect(result2.data).to.equal(undefined); + }); + + it('can use onTypeConflict to select first type', async () => { + const mergedSchema = mergeSchemas({ + schemas: [schema1, schema2], + onTypeConflict: (left) => left, + }); + const result1 = await graphql(mergedSchema, '{ test1 { fieldB } }'); + expect(result1.data?.test1.fieldB).to.equal('B'); + const result2 = await graphql(mergedSchema, '{ test1 { fieldC } }'); + expect(result2.data).to.equal(undefined); + }); +}); + +describe('mergeTypes', () => { + let schema1: GraphQLSchema; + let schema2: GraphQLSchema; + + beforeEach(() => { + const typeDefs1 = ` + type Query { + rootField1: Wrapper + getTest(id: ID): Test + } + + type Wrapper { + test: Test + } + + type Test { + id: ID + field1: String + } + `; + + const typeDefs2 = ` + type Query { + rootField2: Wrapper + getTest(id: ID): Test + } + + type Wrapper { + test: Test + } + + type Test { + id: ID + field2: String + } + `; + + schema1 = makeExecutableSchema({ + typeDefs: typeDefs1, + resolvers: { + Query: { + rootField1: () => ({ test: { id: '1' } }), + getTest: (_parent, { id }) => ({ id }), + }, + Test: { + field1: (parent) => parent.id, + }, + }, + }); + + schema2 = makeExecutableSchema({ + typeDefs: typeDefs2, + resolvers: { + Query: { + rootField2: () => ({ test: { id: '2' } }), + getTest: (_parent, { id }) => ({ id }), + }, + Test: { + field2: (parent) => parent.id, + }, + }, + }); + }); + + it('can merge types', async () => { + const subschemaConfig1: SubschemaConfig = { + schema: schema1, + merge: { + Test: { + selectionSet: '{ id }', + resolve: (originalResult, context, info, subschema, selectionSet) => + delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'getTest', + args: { id: originalResult.id }, + selectionSet, + context, + info, + skipTypeMerging: true, + }), + }, + }, + }; + + const subschemaConfig2: SubschemaConfig = { + schema: schema2, + merge: { + Test: { + selectionSet: '{ id }', + resolve: (originalResult, context, info, subschema, selectionSet) => + delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'getTest', + args: { id: originalResult.id }, + selectionSet, + context, + info, + skipTypeMerging: true, + }), + }, + }, + }; + + const mergedSchema = mergeSchemas({ + subschemas: [subschemaConfig1, subschemaConfig2], + }); + + const result1 = await graphql( + mergedSchema, + ` + { + rootField1 { + test { + field1 + ... on Test { + field2 + } + } + } + } + `, + ); + expect(result1).to.deep.equal({ + data: { + rootField1: { + test: { + field1: '1', + field2: '1', + }, + }, + }, + }); + }); +}); diff --git a/src/test/testDataloader.ts b/src/test/testDataloader.ts new file mode 100644 index 00000000000..307f79a6f49 --- /dev/null +++ b/src/test/testDataloader.ts @@ -0,0 +1,122 @@ +import DataLoader from 'dataloader'; +import { graphql, GraphQLList } from 'graphql'; +import { expect } from 'chai'; + +import { delegateToSchema } from '../delegate/index'; +import { makeExecutableSchema } from '../generate/index'; +import { mergeSchemas } from '../stitch/index'; +import { IGraphQLToolsResolveInfo } from '../Interfaces'; + +describe('dataloader', () => { + it('should work', async () => { + const taskSchema = makeExecutableSchema({ + typeDefs: ` + type Task { + id: ID! + text: String + userId: ID! + } + type Query { + task(id: ID!): Task + } + `, + resolvers: { + Query: { + task: (_root, { id }) => ({ + id, + text: `task ${id as string}`, + userId: id, + }), + }, + }, + }); + + const userSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + email: String! + } + type Query { + usersByIds(ids: [ID!]!): [User]! + } + `, + resolvers: { + Query: { + usersByIds: (_root, { ids }) => + ids.map((id: string) => ({ id, email: `${id}@tasks.com` })), + }, + }, + }); + + const schema = mergeSchemas({ + schemas: [taskSchema, userSchema], + typeDefs: ` + extend type Task { + user: User! + } + `, + resolvers: { + Task: { + user: { + fragment: '... on Task { userId }', + resolve(task, _args, context, info) { + return context.usersLoader.load({ id: task.userId, info }); + }, + }, + }, + }, + }); + + const usersLoader = new DataLoader( + async (keys: Array<{ id: any; info: IGraphQLToolsResolveInfo }>) => { + const users = await delegateToSchema({ + schema: userSchema, + operation: 'query', + fieldName: 'usersByIds', + args: { + ids: keys.map((k: { id: any }) => k.id), + }, + context: null, + info: keys[0].info, + returnType: new GraphQLList(keys[0].info.returnType), + }); + + expect(users).to.deep.equal([ + { + id: '1', + email: '1@tasks.com', + }, + ]); + + return users; + }, + ); + + const query = `{ + task(id: "1") { + id + text + user { + id + email + } + } + }`; + + const result = await graphql(schema, query, null, { usersLoader }); + + expect(result).to.deep.equal({ + data: { + task: { + id: '1', + text: 'task 1', + user: { + id: '1', + email: '1@tasks.com', + }, + }, + }, + }); + }); +}); diff --git a/src/test/testDelegateToSchema.ts b/src/test/testDelegateToSchema.ts index 0ff3ecb9b99..c94718fdf72 100644 --- a/src/test/testDelegateToSchema.ts +++ b/src/test/testDelegateToSchema.ts @@ -1,19 +1,23 @@ -/* tslint:disable:no-unused-expression */ - +import { GraphQLSchema, graphql } from 'graphql'; import { expect } from 'chai'; -import { - GraphQLSchema, - graphql -} from 'graphql'; -import { propertySchema, bookingSchema, sampleData, Property } from './testingSchemas'; -import delegateToSchema from '../stitching/delegateToSchema'; -import mergeSchemas from '../stitching/mergeSchemas'; + +import delegateToSchema from '../delegate/delegateToSchema'; +import mergeSchemas from '../stitch/mergeSchemas'; import { IResolvers } from '../Interfaces'; +import { makeExecutableSchema } from '../generate'; +import { wrapSchema } from '../wrap'; + +import { + propertySchema, + bookingSchema, + sampleData, + Property, +} from './testingSchemas'; -function findPropertyByLocationName ( +function findPropertyByLocationName( properties: { [key: string]: Property }, - name: string -): Property { + name: string, +): Property | undefined { for (const key of Object.keys(properties)) { const property = properties[key]; if (property.location.name === name) { @@ -34,35 +38,37 @@ const COORDINATES_QUERY = ` } `; -function proxyResolvers (spec: string): IResolvers { +function proxyResolvers(spec: string): IResolvers { return { Booking: { property: { fragment: '... on Booking { propertyId }', - resolve (booking, args, context, info) { - const delegateFn = spec === 'standalone' ? delegateToSchema : - info.mergeInfo.delegateToSchema; - return delegateFn({ + resolve(booking, _args, context, info) { + const delegateFn = + spec === 'standalone' + ? delegateToSchema + : info.mergeInfo.delegateToSchema; + return delegateFn?.({ schema: propertySchema, operation: 'query', fieldName: 'propertyById', args: { id: booking.propertyId }, context, - info + info, }); - } - } + }, + }, }, Location: { coordinates: { fragment: '... on Location { name }', - resolve (location, args, context, info) { + resolve: (location) => { const name = location.name; - return findPropertyByLocationName(sampleData.Property, name) - .location.coordinates; - } - } - } + return findPropertyByLocationName(sampleData.Property, name).location + .coordinates; + }, + }, + }, }; } @@ -77,32 +83,131 @@ const proxyTypeDefs = ` describe('stitching', () => { describe('delegateToSchema', () => { - ['standalone', 'info.mergeInfo'].forEach(spec => { - context(spec, () => { + ['standalone', 'info.mergeInfo'].forEach((spec) => { + describe(spec, () => { let schema: GraphQLSchema; before(() => { schema = mergeSchemas({ schemas: [bookingSchema, propertySchema, proxyTypeDefs], - resolvers: proxyResolvers(spec) + resolvers: proxyResolvers(spec), }); }); it('should add fragments for deep types', async () => { - const result = await graphql(schema, COORDINATES_QUERY, - {}, {}, { bookingId: 'b1' }); + const result = await graphql( + schema, + COORDINATES_QUERY, + {}, + {}, + { bookingId: 'b1' }, + ); expect(result).to.deep.equal({ data: { bookingById: { property: { location: { - coordinates: sampleData.Property.p1.location.coordinates - } - } - } - } + coordinates: sampleData.Property.p1.location.coordinates, + }, + }, + }, + }, }); }); }); }); }); }); + +describe('schema delegation', () => { + it('should work even when there are default fields', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + scalar JSON + type Data { + json(input: JSON = "test"): JSON + } + type Query { + data: Data + } + `, + resolvers: { + Query: { + data: () => ({}), + }, + Data: { + json: (_root, args, context, info) => + delegateToSchema({ + schema: propertySchema, + fieldName: 'jsonTest', + args, + context, + info, + }), + }, + }, + }); + + const result = await graphql( + schema, + ` + query { + data { + json + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + data: { + json: 'test', + }, + }, + }); + }); + + it('should work even with variables', async () => { + const innerSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id(show: Boolean): ID + } + type Query { + user: User + } + `, + resolvers: { + Query: { + user: () => ({}), + }, + User: { + id: () => '123', + }, + }, + }); + const schema = wrapSchema(innerSchema); + + const result = await graphql( + schema, + ` + query($show: Boolean) { + user { + id(show: $show) + } + } + `, + null, + null, + { show: true }, + ); + + expect(result).to.deep.equal({ + data: { + user: { + id: '123', + }, + }, + }); + }); +}); diff --git a/src/test/testDirectives.ts b/src/test/testDirectives.ts index b4d65a14172..276aa0b4715 100644 --- a/src/test/testDirectives.ts +++ b/src/test/testDirectives.ts @@ -1,13 +1,5 @@ -import { assert } from 'chai'; -import { - makeExecutableSchema, -} from '../makeExecutableSchema'; -import { - VisitableSchemaType, - SchemaDirectiveVisitor, - SchemaVisitor, - visitSchema, -} from '../schemaVisitor'; +import crypto from 'crypto'; + import { ExecutionResult, GraphQLArgument, @@ -28,85 +20,162 @@ import { GraphQLList, GraphQLUnionType, GraphQLInt, + GraphQLOutputType, + isNonNullType, + isScalarType, + isListType, + TypeSystemExtensionNode, } from 'graphql'; +import { assert } from 'chai'; +import formatDate from 'dateformat'; -import formatDate = require('dateformat'); +import { makeExecutableSchema } from '../generate/index'; +import { VisitableSchemaType } from '../Interfaces'; +import { + SchemaDirectiveVisitor, + SchemaVisitor, + visitSchema, + graphqlVersion, +} from '../utils/index'; const typeDefs = ` directive @schemaDirective(role: String) on SCHEMA +directive @schemaExtensionDirective(role: String) on SCHEMA directive @queryTypeDirective on OBJECT +directive @queryTypeExtensionDirective on OBJECT directive @queryFieldDirective on FIELD_DEFINITION directive @enumTypeDirective on ENUM +directive @enumTypeExtensionDirective on ENUM directive @enumValueDirective on ENUM_VALUE directive @dateDirective(tz: String) on SCALAR +directive @dateExtensionDirective(tz: String) on SCALAR directive @interfaceDirective on INTERFACE +directive @interfaceExtensionDirective on INTERFACE directive @interfaceFieldDirective on FIELD_DEFINITION directive @inputTypeDirective on INPUT_OBJECT +directive @inputTypeExtensionDirective on INPUT_OBJECT directive @inputFieldDirective on INPUT_FIELD_DEFINITION directive @mutationTypeDirective on OBJECT +directive @mutationTypeExtensionDirective on OBJECT directive @mutationArgumentDirective on ARGUMENT_DEFINITION directive @mutationMethodDirective on FIELD_DEFINITION directive @objectTypeDirective on OBJECT +directive @objectTypeExtensionDirective on OBJECT directive @objectFieldDirective on FIELD_DEFINITION directive @unionDirective on UNION +directive @unionExtensionDirective on UNION schema @schemaDirective(role: "admin") { query: Query mutation: Mutation } +${ + graphqlVersion() >= 14 + ? 'extend schema @schemaExtensionDirective(role: "admin")' + : '' +} + type Query @queryTypeDirective { people: [Person] @queryFieldDirective } +${ + graphqlVersion() >= 13 ? 'extend type Query @queryTypeExtensionDirective' : '' +} + enum Gender @enumTypeDirective { NONBINARY @enumValueDirective FEMALE MALE } +${ + graphqlVersion() >= 14 ? 'extend enum Gender @enumTypeExtensionDirective' : '' +} + scalar Date @dateDirective(tz: "utc") +${ + graphqlVersion() >= 14 + ? 'extend scalar Date @dateExtensionDirective(tz: "utc")' + : '' +} + interface Named @interfaceDirective { name: String! @interfaceFieldDirective } +${ + graphqlVersion() >= 13 + ? 'extend interface Named @interfaceExtensionDirective' + : '' +} + input PersonInput @inputTypeDirective { name: String! @inputFieldDirective gender: Gender } +${ + graphqlVersion() >= 14 + ? 'extend input PersonInput @inputTypeExtensionDirective' + : '' +} + type Mutation @mutationTypeDirective { addPerson( input: PersonInput @mutationArgumentDirective ): Person @mutationMethodDirective } +${ + graphqlVersion() >= 13 + ? 'extend type Mutation @mutationTypeExtensionDirective' + : '' +} + type Person implements Named @objectTypeDirective { id: ID! @objectFieldDirective name: String! } +${ + graphqlVersion() >= 14 + ? 'extend type Person @objectTypeExtensionDirective' + : '' +} + union WhateverUnion @unionDirective = Person | Query | Mutation + +${ + graphqlVersion() >= 14 + ? 'extend union WhateverUnion @unionExtensionDirective' + : '' +} `; describe('@directives', () => { it('are included in the schema AST', () => { const schema = makeExecutableSchema({ typeDefs, + resolvers: { + Gender: { + NONBINARY: 'NB', + FEMALE: 'F', + MALE: 'M', + }, + }, }); function checkDirectives( type: VisitableSchemaType, - typeDirectiveNames: [string], - fieldDirectiveMap: { [key: string]: string[] } = {}, + typeDirectiveNames: Array, + fieldDirectiveMap: { [key: string]: Array } = {}, ) { - assert.deepEqual( - getDirectiveNames(type), - typeDirectiveNames, - ); + assert.deepEqual(getDirectiveNames(type), typeDirectiveNames); - Object.keys(fieldDirectiveMap).forEach(key => { + Object.keys(fieldDirectiveMap).forEach((key) => { assert.deepEqual( getDirectiveNames((type as GraphQLObjectType).getFields()[key]), fieldDirectiveMap[key], @@ -114,94 +183,185 @@ describe('@directives', () => { }); } - function getDirectiveNames( - type: VisitableSchemaType, - ): string[] { - return type.astNode.directives.map(d => d.name.value); + function getDirectiveNames(type: VisitableSchemaType): Array { + let directives = type.astNode.directives.map((d) => d.name.value); + const extensionASTNodes = (type as { + extensionASTNodes?: Array; + }).extensionASTNodes; + if (extensionASTNodes != null) { + extensionASTNodes.forEach((extensionASTNode) => { + directives = directives.concat( + extensionASTNode.directives.map((d) => d.name.value), + ); + }); + } + return directives; } assert.deepEqual( getDirectiveNames(schema), - ['schemaDirective'], + graphqlVersion() >= 14 + ? ['schemaDirective', 'schemaExtensionDirective'] + : ['schemaDirective'], ); - checkDirectives(schema.getQueryType(), ['queryTypeDirective'], { - people: ['queryFieldDirective'], - }); + checkDirectives( + schema.getQueryType(), + graphqlVersion() >= 13 + ? ['queryTypeDirective', 'queryTypeExtensionDirective'] + : ['queryTypeDirective'], + { + people: ['queryFieldDirective'], + }, + ); assert.deepEqual( getDirectiveNames(schema.getType('Gender')), - ['enumTypeDirective'], + graphqlVersion() >= 14 + ? ['enumTypeDirective', 'enumTypeExtensionDirective'] + : ['enumTypeDirective'], ); - const nonBinary = (schema.getType('Gender') as GraphQLEnumType).getValues()[0]; - assert.deepEqual( - getDirectiveNames(nonBinary), - ['enumValueDirective'], - ); + const nonBinary = (schema.getType( + 'Gender', + ) as GraphQLEnumType).getValues()[0]; + assert.deepEqual(getDirectiveNames(nonBinary), ['enumValueDirective']); - checkDirectives(schema.getType('Date'), ['dateDirective']); + checkDirectives( + schema.getType('Date') as GraphQLObjectType, + graphqlVersion() >= 14 + ? ['dateDirective', 'dateExtensionDirective'] + : ['dateDirective'], + ); - checkDirectives(schema.getType('Named'), ['interfaceDirective'], { - name: ['interfaceFieldDirective'], - }); + checkDirectives( + schema.getType('Named') as GraphQLObjectType, + graphqlVersion() >= 13 + ? ['interfaceDirective', 'interfaceExtensionDirective'] + : ['interfaceDirective'], + { + name: ['interfaceFieldDirective'], + }, + ); - checkDirectives(schema.getType('PersonInput'), ['inputTypeDirective'], { - name: ['inputFieldDirective'], - gender: [], - }); + checkDirectives( + schema.getType('PersonInput') as GraphQLObjectType, + graphqlVersion() >= 14 + ? ['inputTypeDirective', 'inputTypeExtensionDirective'] + : ['inputTypeDirective'], + { + name: ['inputFieldDirective'], + gender: [], + }, + ); - checkDirectives(schema.getMutationType(), ['mutationTypeDirective'], { - addPerson: ['mutationMethodDirective'], - }); + checkDirectives( + schema.getMutationType(), + graphqlVersion() >= 13 + ? ['mutationTypeDirective', 'mutationTypeExtensionDirective'] + : ['mutationTypeDirective'], + { + addPerson: ['mutationMethodDirective'], + }, + ); assert.deepEqual( getDirectiveNames(schema.getMutationType().getFields().addPerson.args[0]), ['mutationArgumentDirective'], ); - checkDirectives(schema.getType('Person'), ['objectTypeDirective'], { - id: ['objectFieldDirective'], - name: [], + checkDirectives( + schema.getType('Person'), + graphqlVersion() >= 14 + ? ['objectTypeDirective', 'objectTypeExtensionDirective'] + : ['objectTypeDirective'], + { + id: ['objectFieldDirective'], + name: [], + }, + ); + + checkDirectives( + schema.getType('WhateverUnion'), + graphqlVersion() >= 14 + ? ['unionDirective', 'unionExtensionDirective'] + : ['unionDirective'], + ); + }); + + it('works with enum and its resolvers', () => { + const schema = makeExecutableSchema({ + typeDefs: ` + enum DateFormat { + LOCAL + ISO + } + + directive @date(format: DateFormat) on FIELD_DEFINITION + + scalar Date + + type Query { + today: Date @date(format: LOCAL) + } + `, + resolvers: { + DateFormat: { + LOCAL: 'local', + ISO: 'iso', + }, + }, }); - checkDirectives(schema.getType('WhateverUnion'), ['unionDirective']); + assert.exists(schema.getType('DateFormat')); + assert.lengthOf(schema.getDirectives(), 4); + assert.exists(schema.getDirective('date')); }); it('can be implemented with SchemaDirectiveVisitor', () => { - const visited: Set = new Set; + const visited: Set = new Set(); const schema = makeExecutableSchema({ typeDefs }); - let visitCount = 0; SchemaDirectiveVisitor.visitSchemaDirectives(schema, { // The directive subclass can be defined anonymously inline! queryTypeDirective: class extends SchemaDirectiveVisitor { public static description = 'A @directive for query object types'; public visitObject(object: GraphQLObjectType) { + assert.strictEqual(object, schema.getQueryType()); + visited.add(object); + } + }, + queryTypeExtensionDirective: class extends SchemaDirectiveVisitor { + public static description = 'A @directive for query object types'; + public visitObject(object: GraphQLObjectType) { + assert.strictEqual(object, schema.getQueryType()); visited.add(object); - visitCount++; } }, }); assert.strictEqual(visited.size, 1); - assert.strictEqual(visitCount, 1); - visited.forEach(object => { - assert.strictEqual(object, schema.getType('Query')); - }); }); it('can visit the schema itself', () => { - const visited: GraphQLSchema[] = []; + const visited: Array = []; const schema = makeExecutableSchema({ typeDefs }); SchemaDirectiveVisitor.visitSchemaDirectives(schema, { schemaDirective: class extends SchemaDirectiveVisitor { public visitSchema(s: GraphQLSchema) { visited.push(s); } - } + }, + schemaExtensionDirective: class extends SchemaDirectiveVisitor { + public visitSchema(s: GraphQLSchema) { + visited.push(s); + } + }, }); - assert.strictEqual(visited.length, 1); + assert.strictEqual(visited.length, graphqlVersion() >= 14 ? 2 : 1); assert.strictEqual(visited[0], schema); + if (graphqlVersion() >= 14) { + assert.strictEqual(visited[1], schema); + } }); it('can visit fields within object types', () => { @@ -221,10 +381,21 @@ describe('@directives', () => { } }, + mutationTypeExtensionDirective: class extends SchemaDirectiveVisitor { + public visitObject(object: GraphQLObjectType) { + mutationObjectType = object; + assert.strictEqual(this.visitedType, object); + assert.strictEqual(object.name, 'Mutation'); + } + }, + mutationMethodDirective: class extends SchemaDirectiveVisitor { - public visitFieldDefinition(field: GraphQLField, details: { - objectType: GraphQLObjectType, - }) { + public visitFieldDefinition( + field: GraphQLField, + details: { + objectType: GraphQLObjectType; + }, + ) { assert.strictEqual(this.visitedType, field); assert.strictEqual(field.name, 'addPerson'); assert.strictEqual(details.objectType, mutationObjectType); @@ -234,10 +405,13 @@ describe('@directives', () => { }, mutationArgumentDirective: class extends SchemaDirectiveVisitor { - public visitArgumentDefinition(arg: GraphQLArgument, details: { - field: GraphQLField, - objectType: GraphQLObjectType, - }) { + public visitArgumentDefinition( + arg: GraphQLArgument, + details: { + field: GraphQLField; + objectType: GraphQLObjectType; + }, + ) { assert.strictEqual(this.visitedType, arg); assert.strictEqual(arg.name, 'input'); assert.strictEqual(details.field, mutationField); @@ -254,10 +428,21 @@ describe('@directives', () => { } }, + enumTypeExtensionDirective: class extends SchemaDirectiveVisitor { + public visitEnum(enumType: GraphQLEnumType) { + assert.strictEqual(this.visitedType, enumType); + assert.strictEqual(enumType.name, 'Gender'); + enumObjectType = enumType; + } + }, + enumValueDirective: class extends SchemaDirectiveVisitor { - public visitEnumValue(value: GraphQLEnumValue, details: { - enumType: GraphQLEnumType, - }) { + public visitEnumValue( + value: GraphQLEnumValue, + details: { + enumType: GraphQLEnumType; + }, + ) { assert.strictEqual(this.visitedType, value); assert.strictEqual(value.name, 'NONBINARY'); assert.strictEqual(value.value, 'NONBINARY'); @@ -273,23 +458,33 @@ describe('@directives', () => { } }, + inputTypeExtensionDirective: class extends SchemaDirectiveVisitor { + public visitInputObject(object: GraphQLInputObjectType) { + inputObjectType = object; + assert.strictEqual(this.visitedType, object); + assert.strictEqual(object.name, 'PersonInput'); + } + }, + inputFieldDirective: class extends SchemaDirectiveVisitor { - public visitInputFieldDefinition(field: GraphQLInputField, details: { - objectType: GraphQLInputObjectType, - }) { + public visitInputFieldDefinition( + field: GraphQLInputField, + details: { + objectType: GraphQLInputObjectType; + }, + ) { assert.strictEqual(this.visitedType, field); assert.strictEqual(field.name, 'name'); assert.strictEqual(details.objectType, inputObjectType); } - } + }, }); }); it('can check if a visitor method is implemented', () => { class Visitor extends SchemaVisitor { - public notVisitorMethod() { - return; // Just to keep the tslint:no-empty rule satisfied. - } + // eslint-disable-next-line @typescript-eslint/no-empty-function + public notVisitorMethod() {} public visitObject(object: GraphQLObjectType) { return object; @@ -301,10 +496,7 @@ describe('@directives', () => { false, ); - assert.strictEqual( - Visitor.implementsVisitorMethod('visitObject'), - true, - ); + assert.strictEqual(Visitor.implementsVisitorMethod('visitObject'), true); assert.strictEqual( Visitor.implementsVisitorMethod('visitInputFieldDefinition'), @@ -320,7 +512,7 @@ describe('@directives', () => { it('can use visitSchema for simple visitor patterns', () => { class SimpleVisitor extends SchemaVisitor { public visitCount = 0; - public names: string[] = []; + public names: Array = []; constructor(s: GraphQLSchema) { super(); @@ -343,11 +535,10 @@ describe('@directives', () => { const schema = makeExecutableSchema({ typeDefs }); const visitor = new SimpleVisitor(schema); visitor.visit(); - assert.deepEqual(visitor.names.sort(), [ - 'Mutation', - 'Person', - 'Query', - ]); + assert.deepEqual( + visitor.names.sort((a, b) => a.localeCompare(b)), + ['Mutation', 'Person', 'Query'], + ); }); it('can use SchemaDirectiveVisitor as a no-op visitor', () => { @@ -359,7 +550,8 @@ describe('@directives', () => { // Pretend this class implements all visitor methods. This is safe // because the SchemaVisitor base class provides empty stubs for all // the visitor methods that might be called. - return methodNamesEncountered[name] = true; + methodNamesEncountered[name] = true; + return methodNamesEncountered[name]; } } @@ -383,10 +575,10 @@ describe('@directives', () => { }); assert.deepEqual( - Object.keys(methodNamesEncountered).sort(), + Object.keys(methodNamesEncountered).sort((a, b) => a.localeCompare(b)), Object.keys(SchemaVisitor.prototype) - .filter(name => name.startsWith('visit')) - .sort() + .filter((name) => name.startsWith('visit')) + .sort((a, b) => a.localeCompare(b)), ); }); @@ -418,50 +610,56 @@ describe('@directives', () => { fieldCount: 0, }; - const visitors = SchemaDirectiveVisitor.visitSchemaDirectives(schema, { - oyez: class extends SchemaDirectiveVisitor { - public static getDirectiveDeclaration( - name: string, - theSchema: GraphQLSchema, - ) { - assert.strictEqual(theSchema, schema); - const prev = schema.getDirective(name); - prev.args.some(arg => { - if (arg.name === 'times') { - // Override the default value of the times argument to be 3 - // instead of 5. - arg.defaultValue = 3; - return true; - } - }); - return prev; - } - - public visitObject(object: GraphQLObjectType) { - ++this.context.objectCount; - assert.strictEqual(this.args.times, 3); - } + const visitors = SchemaDirectiveVisitor.visitSchemaDirectives( + schema, + { + oyez: class extends SchemaDirectiveVisitor { + public static getDirectiveDeclaration( + name: string, + theSchema: GraphQLSchema, + ) { + assert.strictEqual(theSchema, schema); + const prev = schema.getDirective(name); + prev.args.some((arg) => { + if (arg.name === 'times') { + // Override the default value of the times argument to be 3 + // instead of 5. + arg.defaultValue = 3; + return true; + } + return false; + }); + return prev; + } - public visitFieldDefinition(field: GraphQLField) { - ++this.context.fieldCount; - if (field.name === 'judge') { - assert.strictEqual(this.args.times, 0); - } else if (field.name === 'marshall') { + public visitObject() { + ++this.context.objectCount; assert.strictEqual(this.args.times, 3); } - assert.strictEqual(this.args.party, 'IMPARTIAL'); - } - } - }, context); + + public visitFieldDefinition(field: GraphQLField) { + ++this.context.fieldCount; + if (field.name === 'judge') { + assert.strictEqual(this.args.times, 0); + } else if (field.name === 'marshall') { + assert.strictEqual(this.args.times, 3); + } + assert.strictEqual(this.args.party, 'IMPARTIAL'); + } + }, + }, + context, + ); assert.strictEqual(context.objectCount, 1); assert.strictEqual(context.fieldCount, 2); assert.deepEqual(Object.keys(visitors), ['oyez']); assert.deepEqual( - visitors.oyez.map(v => { - return (v.visitedType as GraphQLObjectType | GraphQLField).name; - }), + visitors.oyez.map( + (v) => + (v.visitedType as GraphQLObjectType | GraphQLField).name, + ), ['Courtroom', 'judge', 'marshall'], ); }); @@ -478,7 +676,7 @@ describe('@directives', () => { upper: class extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const { resolve = defaultFieldResolver } = field; - field.resolve = async function (...args: any[]) { + field.resolve = async function (...args) { const result = await resolve.apply(this, args); if (typeof result === 'string') { return result.toUpperCase(); @@ -486,24 +684,27 @@ describe('@directives', () => { return result; }; } - } + }, }, resolvers: { Query: { hello() { return 'hello world'; - } - } - } + }, + }, + }, }); - return graphql(schema, ` - query { - hello - } - `).then(({ data }) => { + return graphql( + schema, + ` + query { + hello + } + `, + ).then(({ data }) => { assert.deepEqual(data, { - hello: 'HELLO WORLD' + hello: 'HELLO WORLD', }); }); }); @@ -525,30 +726,33 @@ describe('@directives', () => { const { resolve = defaultFieldResolver } = field; const { format } = this.args; field.type = GraphQLString; - field.resolve = async function (...args: any[]) { + field.resolve = async function (...args) { const date = await resolve.apply(this, args); return formatDate(date, format, true); }; } - } + }, }, resolvers: { Query: { today() { return new Date(1519688273858).toUTCString(); - } - } - } + }, + }, + }, }); - return graphql(schema, ` - query { - today - } - `).then(({ data }) => { + return graphql( + schema, + ` + query { + today + } + `, + ).then(({ data }) => { assert.deepEqual(data, { - today: 'February 26, 2018' + today: 'February 26, 2018', }); }); }); @@ -559,16 +763,23 @@ describe('@directives', () => { const { resolve = defaultFieldResolver } = field; const { defaultFormat } = this.args; - field.args.push({ - name: 'format', - type: GraphQLString - } as any); + field.args.push( + Object.create({ + name: 'format', + type: GraphQLString, + }), + ); field.type = GraphQLString; - field.resolve = async function (source, { format, ...args }, context, info) { - format = format || defaultFormat; + field.resolve = async function ( + source, + { format, ...args }, + context, + info, + ) { + const newFormat = format || defaultFormat; const date = await resolve.call(this, source, args, context, info); - return formatDate(date, format, true); + return formatDate(date, newFormat, true); }; } } @@ -586,50 +797,44 @@ describe('@directives', () => { }`, schemaDirectives: { - date: FormattableDateDirective + date: FormattableDateDirective, }, resolvers: { Query: { today() { return new Date(1521131357195); - } - } - } + }, + }, + }, }); - const resultNoArg = await graphql(schema, `query { today }`); + const resultNoArg = await graphql(schema, 'query { today }'); - if (resultNoArg.errors) { + if (resultNoArg.errors != null) { assert.deepEqual(resultNoArg.errors, []); } - assert.deepEqual( - resultNoArg.data, - { today: 'March 15, 2018' } - ); + assert.deepEqual(resultNoArg.data, { today: 'March 15, 2018' }); - const resultWithArg = await graphql(schema, ` - query { - today(format: "dd mmm yyyy") - }`); + const resultWithArg = await graphql( + schema, + ` + query { + today(format: "dd mmm yyyy") + } + `, + ); - if (resultWithArg.errors) { + if (resultWithArg.errors != null) { assert.deepEqual(resultWithArg.errors, []); } - assert.deepEqual( - resultWithArg.data, - { today: '15 Mar 2018' } - ); + assert.deepEqual(resultWithArg.data, { today: '15 Mar 2018' }); }); it('can be used to implement the @intl example', () => { - function translate( - text: string, - path: string[], - locale: string, - ) { + function translate(text: string, path: Array, locale: string) { assert.strictEqual(text, 'hello'); assert.deepEqual(path, ['Query', 'greeting']); assert.strictEqual(locale, 'fr'); @@ -637,7 +842,7 @@ describe('@directives', () => { } const context = { - locale: 'fr' + locale: 'fr', }; const schema = makeExecutableSchema({ @@ -650,11 +855,14 @@ describe('@directives', () => { schemaDirectives: { intl: class extends SchemaDirectiveVisitor { - public visitFieldDefinition(field: GraphQLField, details: { - objectType: GraphQLObjectType, - }) { + public visitFieldDefinition( + field: GraphQLField, + details: { + objectType: GraphQLObjectType; + }, + ) { const { resolve = defaultFieldResolver } = field; - field.resolve = async function (...args: any[]) { + field.resolve = async function (...args: Array) { const defaultText = await resolve.apply(this, args); // In this example, path would be ["Query", "greeting"]: const path = [details.objectType.name, field.name]; @@ -662,36 +870,36 @@ describe('@directives', () => { return translate(defaultText, path, context.locale); }; } - } + }, }, resolvers: { Query: { greeting() { return 'hello'; - } - } - } + }, + }, + }, }); - return graphql(schema, ` - query { - greeting - } - `, null, context).then(({ data }) => { + return graphql( + schema, + ` + query { + greeting + } + `, + null, + context, + ).then(({ data }) => { assert.deepEqual(data, { - greeting: 'bonjour' + greeting: 'bonjour', }); }); }); it('can be used to implement the @auth example', async () => { - const roles = [ - 'UNKNOWN', - 'USER', - 'REVIEWER', - 'ADMIN', - ]; + const roles = ['UNKNOWN', 'USER', 'REVIEWER', 'ADMIN']; function getUser(token: string) { return { @@ -699,7 +907,7 @@ describe('@directives', () => { const tokenIndex = roles.indexOf(token); const roleIndex = roles.indexOf(role); return roleIndex >= 0 && tokenIndex >= roleIndex; - } + }, }; } @@ -708,6 +916,7 @@ describe('@directives', () => { this.ensureFieldsWrapped(type); (type as any)._requiredAuthRole = this.args.requires; } + // Visitor methods for nested types like fields and arguments // also receive a details object that provides information about // the parent and grandparent types. @@ -728,23 +937,23 @@ describe('@directives', () => { const fields = objectType.getFields(); - Object.keys(fields).forEach(fieldName => { + Object.keys(fields).forEach((fieldName) => { const field = fields[fieldName]; const { resolve = defaultFieldResolver } = field; - field.resolve = async function (...args: any[]) { + field.resolve = function (...args: Array) { // Get the required Role from the field first, falling back // to the objectType if no Role is required by the field: const requiredRole = (field as any)._requiredAuthRole || (objectType as any)._requiredAuthRole; - if (! requiredRole) { + if (!requiredRole) { return resolve.apply(this, args); } const context = args[2]; - const user = await getUser(context.headers.authToken); - if (! user.hasRole(requiredRole)) { + const user = getUser(context.headers.authToken); + if (!user.hasRole(requiredRole)) { throw new Error('not authorized'); } @@ -778,52 +987,62 @@ describe('@directives', () => { }`, schemaDirectives: { - auth: AuthDirective + auth: AuthDirective, }, resolvers: { Query: { users() { - return [{ - banned: true, - canPost: false, - name: 'Ben' - }]; - } - } - } + return [ + { + banned: true, + canPost: false, + name: 'Ben', + }, + ]; + }, + }, + }, }); function execWithRole(role: string): Promise { - return graphql(schema, ` - query { - users { - name - banned - canPost - } - } - `, null, { - headers: { - authToken: role, - } - }); + return graphql( + schema, + ` + query { + users { + name + banned + canPost + } + } + `, + null, + { + headers: { + authToken: role, + }, + }, + ); } function checkErrors( expectedCount: number, - ...expectedNames: string[] + ...expectedNames: Array ) { - return function ({ errors = [], data }: { - errors: any[], - data: any, + return function ({ + errors = [], + data, + }: { + errors: Array; + data: any; }) { assert.strictEqual(errors.length, expectedCount); - assert(errors.every(error => error.message === 'not authorized')); - const actualNames = errors.map(error => error.path.slice(-1)[0]); + assert(errors.every((error) => error.message === 'not authorized')); + const actualNames = errors.map((error) => error.path.slice(-1)[0]); assert.deepEqual( - expectedNames.sort(), - actualNames.sort(), + expectedNames.sort((a, b) => a.localeCompare(b)), + actualNames.sort((a, b) => a.localeCompare(b)), ); return data; }; @@ -833,12 +1052,14 @@ describe('@directives', () => { execWithRole('UNKNOWN').then(checkErrors(3, 'banned', 'canPost', 'name')), execWithRole('USER').then(checkErrors(2, 'banned', 'canPost')), execWithRole('REVIEWER').then(checkErrors(1, 'banned')), - execWithRole('ADMIN').then(checkErrors(0)).then(data => { - assert.strictEqual(data.users.length, 1); - assert.strictEqual(data.users[0].banned, true); - assert.strictEqual(data.users[0].canPost, false); - assert.strictEqual(data.users[0].name, 'Ben'); - }), + execWithRole('ADMIN') + .then(checkErrors(0)) + .then((data) => { + assert.strictEqual(data.users.length, 1); + assert.strictEqual(data.users[0].banned, true); + assert.strictEqual(data.users[0].canPost, false); + assert.strictEqual(data.users[0].name, 'Ben'); + }), ]); }); @@ -846,13 +1067,13 @@ describe('@directives', () => { class LimitedLengthType extends GraphQLScalarType { constructor(type: GraphQLScalarType, maxLength: number) { super({ - name: `LengthAtMost${maxLength}`, + name: `LengthAtMost${maxLength.toString()}`, serialize(value: string) { - value = type.serialize(value); - assert.strictEqual(typeof value.length, 'number'); - assert.isAtMost(value.length, maxLength); - return value; + const newValue = type.serialize(value); + assert.strictEqual(typeof newValue.length, 'number'); + assert.isAtMost(newValue.length, maxLength); + return newValue; }, parseValue(value: string) { @@ -861,7 +1082,7 @@ describe('@directives', () => { parseLiteral(ast: StringValueNode) { return type.parseLiteral(ast, {}); - } + }, }); } } @@ -897,64 +1118,69 @@ describe('@directives', () => { } private wrapType(field: GraphQLInputField | GraphQLField) { - if (field.type instanceof GraphQLNonNull && - field.type.ofType instanceof GraphQLScalarType) { + if (isNonNullType(field.type) && isScalarType(field.type.ofType)) { field.type = new GraphQLNonNull( - new LimitedLengthType(field.type.ofType, this.args.max)); - } else if (field.type instanceof GraphQLScalarType) { + new LimitedLengthType(field.type.ofType, this.args.max), + ); + } else if (isScalarType(field.type)) { field.type = new LimitedLengthType(field.type, this.args.max); } else { - throw new Error(`Not a scalar type: ${field.type}`); + throw new Error(`Not a scalar type: ${field.type.toString()}`); } } - } + }, }, resolvers: { Query: { books() { - return [{ - title: 'abcdefghijklmnopqrstuvwxyz' - }]; - } + return [ + { + title: 'abcdefghijklmnopqrstuvwxyz', + }, + ]; + }, }, Mutation: { - createBook(parent, args) { + createBook(_parent, args) { return args.book; - } - } - } + }, + }, + }, }); - const { errors } = await graphql(schema, ` - query { - books { - title - } - } - `); - assert.strictEqual(errors.length, 1); - assert.strictEqual( - errors[0].message, - 'expected 26 to be at most 10', + const { errors } = await graphql( + schema, + ` + query { + books { + title + } + } + `, ); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].message, 'expected 26 to be at most 10'); - const result = await graphql(schema, ` - mutation { - createBook(book: { title: "safe title" }) { - title - } - } - `); + const result = await graphql( + schema, + ` + mutation { + createBook(book: { title: "safe title" }) { + title + } + } + `, + ); - if (result.errors) { + if (result.errors != null) { assert.deepEqual(result.errors, []); } assert.deepEqual(result.data, { createBook: { - title: 'safe title' - } + title: 'safe title', + }, }); }); @@ -982,114 +1208,126 @@ describe('@directives', () => { uniqueID: class extends SchemaDirectiveVisitor { public visitObject(type: GraphQLObjectType) { const { name, from } = this.args; - type.getFields()[name] = { - name: name, + type.getFields()[name] = Object.create({ + name, type: GraphQLID, description: 'Unique ID', args: [], resolve(object: any) { - const hash = require('crypto').createHash('sha1'); + const hash = crypto.createHash('sha1'); hash.update(type.name); from.forEach((fieldName: string) => { hash.update(String(object[fieldName])); }); return hash.digest('hex'); - } - } as any; + }, + }); } - } + }, }, resolvers: { Query: { - people(...args: any[]) { - return [{ - personID: 1, - name: 'Ben', - }]; + people() { + return [ + { + personID: 1, + name: 'Ben', + }, + ]; }, - locations(...args: any[]) { - return [{ - locationID: 1, - address: '140 10th St', - }]; - } - } - } + locations() { + return [ + { + locationID: 1, + address: '140 10th St', + }, + ]; + }, + }, + }, }); - return graphql(schema, ` - query { - people { - uid - personID - name - } - locations { - uid - locationID - address - } - } - `, null, context).then(result => { + return graphql( + schema, + ` + query { + people { + uid + personID + name + } + locations { + uid + locationID + address + } + } + `, + null, + context, + ).then((result) => { const { data } = result; - assert.deepEqual(data.people, [{ - uid: '580a207c8e94f03b93a2b01217c3cc218490571a', - personID: 1, - name: 'Ben', - }]); - - assert.deepEqual(data.locations, [{ - uid: 'c31b71e6e23a7ae527f94341da333590dd7cba96', - locationID: 1, - address: '140 10th St', - }]); + assert.deepEqual(data.people, [ + { + uid: '580a207c8e94f03b93a2b01217c3cc218490571a', + personID: 1, + name: 'Ben', + }, + ]); + + assert.deepEqual(data.locations, [ + { + uid: 'c31b71e6e23a7ae527f94341da333590dd7cba96', + locationID: 1, + address: '140 10th St', + }, + ]); }); }); it('automatically updates references to changed types', () => { - let HumanType: GraphQLObjectType = null; - const schema = makeExecutableSchema({ typeDefs, schemaDirectives: { objectTypeDirective: class extends SchemaDirectiveVisitor { public visitObject(object: GraphQLObjectType) { - return HumanType = Object.create(object, { - name: { value: 'Human' } + return Object.create(object, { + name: { value: 'Human' }, }); } - } - } + }, + }, }); const Query = schema.getType('Query') as GraphQLObjectType; const peopleType = Query.getFields().people.type; - if (peopleType instanceof GraphQLList) { - assert.strictEqual(peopleType.ofType, HumanType); + if (isListType(peopleType)) { + assert.strictEqual(peopleType.ofType, schema.getType('Human')); } else { throw new Error('Query.people not a GraphQLList type'); } const Mutation = schema.getType('Mutation') as GraphQLObjectType; const addPersonResultType = Mutation.getFields().addPerson.type; - assert.strictEqual(addPersonResultType, HumanType); + assert.strictEqual( + addPersonResultType, + schema.getType('Human') as GraphQLOutputType, + ); const WhateverUnion = schema.getType('WhateverUnion') as GraphQLUnionType; - const found = WhateverUnion.getTypes().some(type => { + const found = WhateverUnion.getTypes().some((type) => { if (type.name === 'Human') { - assert.strictEqual(type, HumanType); + assert.strictEqual(type, schema.getType('Human')); return true; } + return false; }); assert.strictEqual(found, true); // Make sure that the Person type was actually removed. - assert.strictEqual( - typeof schema.getType('Person'), - 'undefined' - ); + assert.strictEqual(typeof schema.getType('Person'), 'undefined'); }); it('can remove enum values', () => { @@ -1109,19 +1347,19 @@ describe('@directives', () => { schemaDirectives: { remove: class extends SchemaDirectiveVisitor { - public visitEnumValue(value: GraphQLEnumValue): null { + public visitEnumValue(): null { if (this.args.if) { return null; } } - } - } + }, + }, }); const AgeUnit = schema.getType('AgeUnit') as GraphQLEnumType; assert.deepEqual( - AgeUnit.getValues().map(value => value.name), - ['DOG_YEARS', 'PERSON_YEARS'] + AgeUnit.getValues().map((value) => value.name), + ['DOG_YEARS', 'PERSON_YEARS'], ); }); @@ -1149,16 +1387,13 @@ describe('@directives', () => { public visitObject(object: GraphQLObjectType) { object.name = this.args.to; } - } - } + }, + }, }); const Human = schema.getType('Human') as GraphQLObjectType; assert.strictEqual(Human.name, 'Human'); - assert.strictEqual( - Human.getFields().heightInInches.type, - GraphQLInt, - ); + assert.strictEqual(Human.getFields().heightInInches.type, GraphQLInt); const Person = schema.getType('Person') as GraphQLObjectType; assert.strictEqual(Person.name, 'Person'); @@ -1168,16 +1403,15 @@ describe('@directives', () => { ); const Query = schema.getType('Query') as GraphQLObjectType; - const peopleType = Query.getFields().people.type as GraphQLList; - assert.strictEqual( - peopleType.ofType, - Human - ); + const peopleType = Query.getFields().people.type as GraphQLList< + GraphQLObjectType + >; + assert.strictEqual(peopleType.ofType, Human); }); it('does not enforce query directive locations (issue #680)', () => { const visited = new Set(); - const schema = makeExecutableSchema({ + makeExecutableSchema({ typeDefs: ` directive @hasScope(scope: [String]) on QUERY | FIELD | OBJECT @@ -1191,14 +1425,11 @@ describe('@directives', () => { assert.strictEqual(object.name, 'Query'); visited.add(object); } - } - } + }, + }, }); assert.strictEqual(visited.size, 1); - visited.forEach(object => { - assert.strictEqual(schema.getType('Query'), object); - }); }); it('allows multiple directives when first replaces type (issue #851)', () => { @@ -1214,9 +1445,9 @@ describe('@directives', () => { upper: class extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const { resolve = defaultFieldResolver } = field; - const newField = {...field}; + const newField = { ...field }; - newField.resolve = async function(...args: any[]) { + newField.resolve = async function (...args: Array) { const result = await resolve.apply(this, args); if (typeof result === 'string') { return result.toUpperCase(); @@ -1230,13 +1461,10 @@ describe('@directives', () => { reverse: class extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const { resolve = defaultFieldResolver } = field; - field.resolve = async function(...args: any[]) { + field.resolve = async function (...args: Array) { const result = await resolve.apply(this, args); if (typeof result === 'string') { - return result - .split('') - .reverse() - .join(''); + return result.split('').reverse().join(''); } return result; }; diff --git a/src/test/testErrors.ts b/src/test/testErrors.ts index 05bcf69c292..c43e77dc3ae 100644 --- a/src/test/testErrors.ts +++ b/src/test/testErrors.ts @@ -1,16 +1,12 @@ -import { assert } from 'chai'; -import { GraphQLResolveInfo, GraphQLError } from 'graphql'; -import { checkResultAndHandleErrors, getErrorsFromParent, ERROR_SYMBOL } from '../stitching/errors'; +import { expect, assert } from 'chai'; +import { GraphQLError, graphql } from 'graphql'; -import 'mocha'; - -class ErrorWithResult extends GraphQLError { - public result: any; - constructor(message: string, result: any) { - super(message); - this.result = result; - } -} +import { relocatedError } from '../stitch/errors'; +import { getErrors, ERROR_SYMBOL } from '../stitch/proxiedResult'; +import { checkResultAndHandleErrors } from '../delegate/checkResultAndHandleErrors'; +import { makeExecutableSchema } from '../generate/index'; +import { mergeSchemas } from '../stitch/index'; +import { IGraphQLToolsResolveInfo } from '../Interfaces'; class ErrorWithExtensions extends GraphQLError { constructor(message: string, code: string) { @@ -19,31 +15,58 @@ class ErrorWithExtensions extends GraphQLError { } describe('Errors', () => { - describe('getErrorsFromParent', () => { - it('should return OWN error kind if path is not defined', () => { - const mockErrors = { + describe('relocatedError', () => { + it('should adjust the path of a GraphqlError', () => { + const originalError = new GraphQLError('test', null, null, null, [ + 'test', + ]); + const newError = relocatedError(originalError, null, ['test', 1]); + const expectedError = new GraphQLError('test', null, null, null, [ + 'test', + 1, + ]); + assert.deepEqual(newError, expectedError); + }); + + it('should also locate a non GraphQLError', () => { + const originalError = new Error('test'); + const newError = relocatedError(originalError, null, ['test', 1]); + const expectedError = new GraphQLError('test', null, null, null, [ + 'test', + 1, + ]); + assert.deepEqual(newError, expectedError); + }); + }); + + describe('getErrors', () => { + it('should return all errors including if path is not defined', () => { + const error = { + message: 'Test error without path', + }; + const mockErrors: any = { responseKey: '', - [ERROR_SYMBOL]: [ - { - message: 'Test error without path' - } - ] + [ERROR_SYMBOL]: [error], }; - assert.deepEqual(getErrorsFromParent(mockErrors, 'responseKey'), { - kind: 'OWN', - error: mockErrors[ERROR_SYMBOL][0] - }); + assert.deepEqual(getErrors(mockErrors, 'responseKey'), [ + mockErrors[ERROR_SYMBOL][0], + ]); }); }); describe('checkResultAndHandleErrors', () => { - it('persists single error with a result', () => { + it('persists single error', () => { const result = { - errors: [new ErrorWithResult('Test error', 'result')] + errors: [new GraphQLError('Test error')], }; try { - checkResultAndHandleErrors(result, {} as GraphQLResolveInfo, 'responseKey'); + checkResultAndHandleErrors( + result, + {}, + ({} as unknown) as IGraphQLToolsResolveInfo, + 'responseKey', + ); } catch (e) { assert.equal(e.message, 'Test error'); assert.isUndefined(e.originalError.errors); @@ -52,10 +75,15 @@ describe('Errors', () => { it('persists single error with extensions', () => { const result = { - errors: [new ErrorWithExtensions('Test error', 'UNAUTHENTICATED')] + errors: [new ErrorWithExtensions('Test error', 'UNAUTHENTICATED')], }; try { - checkResultAndHandleErrors(result, {} as GraphQLResolveInfo, 'responseKey'); + checkResultAndHandleErrors( + result, + {}, + ({} as unknown) as IGraphQLToolsResolveInfo, + 'responseKey', + ); } catch (e) { assert.equal(e.message, 'Test error'); assert.equal(e.extensions && e.extensions.code, 'UNAUTHENTICATED'); @@ -63,14 +91,19 @@ describe('Errors', () => { } }); - it('persists original errors without a result', () => { + it('combines errors and persists the original errors', () => { const result = { - errors: [new GraphQLError('Test error')] + errors: [new GraphQLError('Error1'), new GraphQLError('Error2')], }; try { - checkResultAndHandleErrors(result, {} as GraphQLResolveInfo, 'responseKey'); + checkResultAndHandleErrors( + result, + {}, + ({} as unknown) as IGraphQLToolsResolveInfo, + 'responseKey', + ); } catch (e) { - assert.equal(e.message, 'Test error'); + assert.equal(e.message, 'Error1\nError2'); assert.isNotEmpty(e.originalError); assert.isNotEmpty(e.originalError.errors); assert.lengthOf(e.originalError.errors, result.errors.length); @@ -79,25 +112,215 @@ describe('Errors', () => { }); } }); + }); +}); - it('combines errors and persists the original errors', () => { - const result = { - errors: [ - new GraphQLError('Error1'), - new GraphQLError('Error2'), - ] - }; - try { - checkResultAndHandleErrors(result, {} as GraphQLResolveInfo, 'responseKey'); - } catch (e) { - assert.equal(e.message, 'Error1\nError2'); - assert.isNotEmpty(e.originalError); - assert.isNotEmpty(e.originalError.errors); - assert.lengthOf(e.originalError.errors, result.errors.length); - result.errors.forEach((error, i) => { - assert.deepEqual(e.originalError.errors[i], error); - }); +describe('passes along errors for missing fields on list', () => { + it('if non-null', async () => { + const typeDefs = ` + type Query { + getOuter: Outer + } + type Outer { + innerList: [Inner!]! + } + type Inner { + mandatoryField: String! } + `; + + const schema = makeExecutableSchema({ + typeDefs, + resolvers: { + Query: { + getOuter: () => ({ + innerList: [{ mandatoryField: 'test' }, {}], + }), + }, + }, + }); + + const mergedSchema = mergeSchemas({ + schemas: [schema], + }); + const result = await graphql( + mergedSchema, + '{ getOuter { innerList { mandatoryField } } }', + ); + expect(result).to.deep.equal({ + data: { + getOuter: null, + }, + errors: [ + { + locations: [ + { + column: 26, + line: 1, + }, + ], + message: + 'Cannot return null for non-nullable field Inner.mandatoryField.', + path: ['getOuter', 'innerList', 1, 'mandatoryField'], + }, + ], + }); + }); + + it('even if nullable', async () => { + const typeDefs = ` + type Query { + getOuter: Outer + } + type Outer { + innerList: [Inner]! + } + type Inner { + mandatoryField: String! + } + `; + + const schema = makeExecutableSchema({ + typeDefs, + resolvers: { + Query: { + getOuter: () => ({ + innerList: [{ mandatoryField: 'test' }, {}], + }), + }, + }, + }); + + const mergedSchema = mergeSchemas({ + schemas: [schema], + }); + const result = await graphql( + mergedSchema, + '{ getOuter { innerList { mandatoryField } } }', + ); + expect(result).to.deep.equal({ + data: { + getOuter: { + innerList: [{ mandatoryField: 'test' }, null], + }, + }, + errors: [ + { + locations: [ + { + column: 26, + line: 1, + }, + ], + message: + 'Cannot return null for non-nullable field Inner.mandatoryField.', + path: ['getOuter', 'innerList', 1, 'mandatoryField'], + }, + ], + }); + }); +}); + +describe('passes along errors when list field errors', () => { + it('if non-null', async () => { + const typeDefs = ` + type Query { + getOuter: Outer + } + type Outer { + innerList: [Inner!]! + } + type Inner { + mandatoryField: String! + } + `; + + const schema = makeExecutableSchema({ + typeDefs, + resolvers: { + Query: { + getOuter: () => ({ + innerList: [{ mandatoryField: 'test' }, new Error('test')], + }), + }, + }, + }); + + const mergedSchema = mergeSchemas({ + schemas: [schema], + }); + const result = await graphql( + mergedSchema, + '{ getOuter { innerList { mandatoryField } } }', + ); + expect(result).to.deep.equal({ + data: { + getOuter: null, + }, + errors: [ + { + locations: [ + { + column: 14, + line: 1, + }, + ], + message: 'test', + path: ['getOuter', 'innerList', 1], + }, + ], + }); + }); + + it('even if nullable', async () => { + const typeDefs = ` + type Query { + getOuter: Outer + } + type Outer { + innerList: [Inner]! + } + type Inner { + mandatoryField: String! + } + `; + + const schema = makeExecutableSchema({ + typeDefs, + resolvers: { + Query: { + getOuter: () => ({ + innerList: [{ mandatoryField: 'test' }, new Error('test')], + }), + }, + }, + }); + + const mergedSchema = mergeSchemas({ + schemas: [schema], + }); + const result = await graphql( + mergedSchema, + '{ getOuter { innerList { mandatoryField } } }', + ); + expect(result).to.deep.equal({ + data: { + getOuter: { + innerList: [{ mandatoryField: 'test' }, null], + }, + }, + errors: [ + { + locations: [ + { + column: 14, + line: 1, + }, + ], + message: 'test', + path: ['getOuter', 'innerList', 1], + }, + ], }); }); }); diff --git a/src/test/testExtensionExtraction.ts b/src/test/testExtensionExtraction.ts index 89320c85231..ae726991f92 100644 --- a/src/test/testExtensionExtraction.ts +++ b/src/test/testExtensionExtraction.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import { parse } from 'graphql'; -import extractExtensionDefinitons from '../generate/extractExtensionDefinitions'; -import 'mocha'; + +import { extractExtensionDefinitions } from '../generate/extensionDefinitions'; describe('Extension extraction', () => { it('extracts extended inputs', () => { @@ -16,10 +16,12 @@ describe('Extension extraction', () => { `; const astDocument = parse(typeDefs); - const extensionAst = extractExtensionDefinitons(astDocument); + const extensionAst = extractExtensionDefinitions(astDocument); expect(extensionAst.definitions).to.have.length(1); - expect(extensionAst.definitions[0].kind).to.equal('InputObjectTypeExtension'); + expect(extensionAst.definitions[0].kind).to.equal( + 'InputObjectTypeExtension', + ); }); it('extracts extended unions', () => { @@ -39,7 +41,7 @@ describe('Extension extraction', () => { `; const astDocument = parse(typeDefs); - const extensionAst = extractExtensionDefinitons(astDocument); + const extensionAst = extractExtensionDefinitions(astDocument); expect(extensionAst.definitions).to.have.length(1); expect(extensionAst.definitions[0].kind).to.equal('UnionTypeExtension'); @@ -58,10 +60,9 @@ describe('Extension extraction', () => { `; const astDocument = parse(typeDefs); - const extensionAst = extractExtensionDefinitons(astDocument); + const extensionAst = extractExtensionDefinitions(astDocument); expect(extensionAst.definitions).to.have.length(1); expect(extensionAst.definitions[0].kind).to.equal('EnumTypeExtension'); }); }); - diff --git a/src/test/testFragmentsAreNotDuplicated.ts b/src/test/testFragmentsAreNotDuplicated.ts index 7b45c605dd5..97ff3086fef 100644 --- a/src/test/testFragmentsAreNotDuplicated.ts +++ b/src/test/testFragmentsAreNotDuplicated.ts @@ -1,10 +1,7 @@ -import {expect} from 'chai'; -import {ExecutionResult, graphql} from 'graphql'; -import { - addMockFunctionsToSchema, - makeExecutableSchema, - transformSchema, -} from '..'; +import { expect } from 'chai'; +import { ExecutionResult, graphql } from 'graphql'; + +import { addMocksToSchema, makeExecutableSchema, transformSchema } from '..'; describe('Merging schemas', () => { it('should not throw `There can be only one fragment named "FieldName"` errors', async () => { @@ -12,7 +9,7 @@ describe('Merging schemas', () => { typeDefs: rawSchema, }); - addMockFunctionsToSchema({schema: originalSchema}); + addMocksToSchema({ schema: originalSchema }); const originalResult = await graphql( originalSchema, @@ -83,7 +80,7 @@ const variables = { function assertNoDuplicateFragmentErrors(result: ExecutionResult) { // Run assertion against each array element for better test failure output. - if (result.errors) { - result.errors.forEach(error => expect(error.message).to.equal('')); + if (result.errors != null) { + result.errors.forEach((error) => expect(error.message).to.equal('')); } } diff --git a/src/test/testGatsbyTransforms.ts b/src/test/testGatsbyTransforms.ts new file mode 100644 index 00000000000..a4d802bc47b --- /dev/null +++ b/src/test/testGatsbyTransforms.ts @@ -0,0 +1,173 @@ +import { expect } from 'chai'; +import { + GraphQLObjectType, + GraphQLSchema, + GraphQLFieldResolver, + GraphQLNonNull, + graphql, +} from 'graphql'; + +import { VisitSchemaKind } from '../Interfaces'; +import { transformSchema, RenameTypes } from '../wrap/index'; +import { cloneType, healSchema, visitSchema } from '../utils/index'; +import { makeExecutableSchema } from '../generate/index'; +import { addMocksToSchema } from '../mock/index'; + +// see https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-source-graphql/src/transforms.js +// and https://github.com/gatsbyjs/gatsby/issues/22128 + +class NamespaceUnderFieldTransform { + private readonly typeName: string; + private readonly fieldName: string; + private readonly resolver: GraphQLFieldResolver; + + constructor({ + typeName, + fieldName, + resolver, + }: { + typeName: string; + fieldName: string; + resolver: GraphQLFieldResolver; + }) { + this.typeName = typeName; + this.fieldName = fieldName; + this.resolver = resolver; + } + + transformSchema(schema: GraphQLSchema) { + const query = schema.getQueryType(); + + const nestedType = cloneType(query); + nestedType.name = this.typeName; + + const typeMap = schema.getTypeMap(); + typeMap[this.typeName] = nestedType; + + const newQuery = new GraphQLObjectType({ + name: query.name, + fields: { + [this.fieldName]: { + type: new GraphQLNonNull(nestedType), + resolve: (parent, args, context, info) => { + if (this.resolver != null) { + return this.resolver(parent, args, context, info); + } + + return {}; + }, + }, + }, + }); + typeMap[query.name] = newQuery; + + return healSchema(schema); + } +} + +class StripNonQueryTransform { + transformSchema(schema: GraphQLSchema) { + return visitSchema(schema, { + [VisitSchemaKind.MUTATION]() { + return null; + }, + [VisitSchemaKind.SUBSCRIPTION]() { + return null; + }, + }); + } +} + +describe('Gatsby transforms', () => { + it('work', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + directive @cacheControl(maxAge: Int, scope: CacheControlScope) on FIELD_DEFINITION | OBJECT | INTERFACE + + enum CacheControlScope { + PUBLIC + PRIVATE + } + + type Continent { + code: String + name: String + countries: [Country] + } + + type Country { + code: String + name: String + native: String + phone: String + continent: Continent + currency: String + languages: [Language] + emoji: String + emojiU: String + states: [State] + } + + type Language { + code: String + name: String + native: String + rtl: Int + } + + type Query { + continents: [Continent] + continent(code: String): Continent + countries: [Country] + country(code: String): Country + languages: [Language] + language(code: String): Language + } + + type State { + code: String + name: String + country: Country + } + + scalar Upload + `, + }); + + addMocksToSchema({ schema }); + + const transformedSchema = transformSchema(schema, [ + new StripNonQueryTransform(), + new RenameTypes((name) => `CountriesQuery_${name}`), + new NamespaceUnderFieldTransform({ + typeName: 'CountriesQuery', + fieldName: 'countries', + resolver: () => ({}), + }), + ]); + + expect(transformedSchema).to.be.instanceOf(GraphQLSchema); + + const result = await graphql( + transformedSchema, + ` + { + countries { + language(code: "en") { + name + } + } + } + `, + ); + expect(result).to.deep.equal({ + data: { + countries: { + language: { + name: 'Hello World', + }, + }, + }, + }); + }); +}); diff --git a/src/test/testLogger.ts b/src/test/testLogger.ts index e226e62b1e9..a4c09f0e50c 100644 --- a/src/test/testLogger.ts +++ b/src/test/testLogger.ts @@ -1,11 +1,11 @@ import { assert } from 'chai'; import { graphql } from 'graphql'; -import { Logger } from '../Logger'; -import { makeExecutableSchema } from '../makeExecutableSchema'; -import 'mocha'; + +import { makeExecutableSchema } from '../generate/index'; +import { Logger } from '../generate/Logger'; describe('Logger', () => { - it('logs the errors', done => { + it('logs the errors', () => { const shorthand = ` type RootQuery { just_a_field: Int @@ -39,15 +39,14 @@ describe('Logger', () => { const testQuery = 'mutation { species, stuff }'; const expected0 = 'Error in resolver RootMutation.species\noops!'; const expected1 = 'Error in resolver RootMutation.stuff\noh noes!'; - graphql(jsSchema, testQuery).then(() => { + return graphql(jsSchema, testQuery).then(() => { assert.equal(logger.errors.length, 2); assert.equal(logger.errors[0].message, expected0); assert.equal(logger.errors[1].message, expected1); - done(); }); }); - it('also forwards the errors when you tell it to', done => { + it('also forwards the errors when you tell it to', () => { const shorthand = ` type RootQuery { species(name: String): String @@ -73,13 +72,12 @@ describe('Logger', () => { logger, }); const testQuery = '{ species }'; - graphql(jsSchema, testQuery).then(() => { + return graphql(jsSchema, testQuery).then(() => { assert.equal(loggedErr, logger.errors[0]); - done(); }); }); - it('prints the errors when you want it to', done => { + it('prints the errors when you want it to', () => { const shorthand = ` type RootQuery { species(name: String): String @@ -90,7 +88,7 @@ describe('Logger', () => { `; const resolve = { RootQuery: { - species: (root: any, { name }: { name: string }) => { + species: (_root: any, { name }: { name: string }) => { if (name) { throw new Error(name); } @@ -105,15 +103,14 @@ describe('Logger', () => { logger, }); const testQuery = '{ q: species, p: species(name: "Peter") }'; - graphql(jsSchema, testQuery).then(() => { + return graphql(jsSchema, testQuery).then(() => { const allErrors = logger.printAllErrors(); assert.match(allErrors, /oops/); assert.match(allErrors, /Peter/); - done(); }); }); - it('logs any Promise reject errors', done => { + it('logs any Promise reject errors', () => { const shorthand = ` type RootQuery { just_a_field: Int @@ -129,16 +126,14 @@ describe('Logger', () => { `; const resolve = { RootMutation: { - species: () => { - return new Promise((_, reject) => { + species: () => + new Promise((_, reject) => { reject(new Error('oops!')); - }); - }, - stuff: () => { - return new Promise((_, reject) => { + }), + stuff: () => + new Promise((_, reject) => { reject(new Error('oh noes!')); - }); - }, + }), }, }; const logger = new Logger(); @@ -151,15 +146,14 @@ describe('Logger', () => { const testQuery = 'mutation { species, stuff }'; const expected0 = 'Error in resolver RootMutation.species\noops!'; const expected1 = 'Error in resolver RootMutation.stuff\noh noes!'; - graphql(jsSchema, testQuery).then(() => { + return graphql(jsSchema, testQuery).then(() => { assert.equal(logger.errors.length, 2); assert.equal(logger.errors[0].message, expected0); assert.equal(logger.errors[1].message, expected1); - done(); }); }); - it('all Promise rejects will log an Error', done => { + it('all Promise rejects will log an Error', () => { const shorthand = ` type RootQuery { species(name: String): String @@ -170,11 +164,10 @@ describe('Logger', () => { `; const resolve = { RootQuery: { - species: () => { - return new Promise((_, reject) => { - reject('oops!'); - }); - }, + species: () => + new Promise((_, reject) => { + reject(new Error('oops!')); + }), }, }; @@ -189,9 +182,8 @@ describe('Logger', () => { }); const testQuery = '{ species }'; - graphql(jsSchema, testQuery).then(() => { + return graphql(jsSchema, testQuery).then(() => { assert.equal(loggedErr, logger.errors[0]); - done(); }); }); }); diff --git a/src/test/testMakeRemoteExecutableSchema.ts b/src/test/testMakeRemoteExecutableSchema.ts index 795fb32762f..d263724627f 100644 --- a/src/test/testMakeRemoteExecutableSchema.ts +++ b/src/test/testMakeRemoteExecutableSchema.ts @@ -1,27 +1,82 @@ -/* tslint:disable:no-unused-expression */ - import { expect } from 'chai'; import { forAwaitEach } from 'iterall'; -import { GraphQLSchema, ExecutionResult, subscribe, parse } from 'graphql'; import { + GraphQLSchema, + ExecutionResult, + subscribe, + parse, + graphql, +} from 'graphql'; + +import { makeRemoteExecutableSchema } from '../wrap/index'; +import { + propertySchema, subscriptionSchema, subscriptionPubSubTrigger, subscriptionPubSub, - makeSchemaRemoteFromLink + makeSchemaRemoteFromLink, } from '../test/testingSchemas'; -import { makeRemoteExecutableSchema } from '../stitching'; + +describe('remote queries', () => { + let schema: GraphQLSchema; + before(async () => { + const remoteSubschemaConfig = await makeSchemaRemoteFromLink( + propertySchema, + ); + schema = makeRemoteExecutableSchema({ + schema: remoteSubschemaConfig.schema, + link: remoteSubschemaConfig.link, + }); + }); + + it('should work', async () => { + const query = ` + { + interfaceTest(kind: ONE) { + kind + testString + ...on TestImpl1 { + foo + } + ...on TestImpl2 { + bar + } + } + } + `; + + const expected = { + data: { + interfaceTest: { + foo: 'foo', + kind: 'ONE', + testString: 'test', + }, + }, + }; + + const result = await graphql(schema, query); + expect(result).to.deep.equal(expected); + }); +}); describe('remote subscriptions', () => { let schema: GraphQLSchema; before(async () => { - schema = await makeSchemaRemoteFromLink(subscriptionSchema); + const remoteSubschemaConfig = await makeSchemaRemoteFromLink( + subscriptionSchema, + ); + schema = makeRemoteExecutableSchema({ + schema: remoteSubschemaConfig.schema, + link: remoteSubschemaConfig.link, + }); }); - it('should work', done => { + it('should work', (done) => { const mockNotification = { notifications: { - text: 'Hello world' - } + text: 'Hello world', + }, }; const subscription = parse(` @@ -33,24 +88,30 @@ describe('remote subscriptions', () => { `); let notificationCnt = 0; - subscribe(schema, subscription).then(results => - forAwaitEach(results as AsyncIterable, (result: ExecutionResult) => { - expect(result).to.have.property('data'); - expect(result.data).to.deep.equal(mockNotification); - !notificationCnt++ ? done() : null; + subscribe(schema, subscription) + .then((results) => { + forAwaitEach( + results as AsyncIterable, + (result: ExecutionResult) => { + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(mockNotification); + if (!notificationCnt++) { + done(); + } + }, + ).catch(done); }) - ); - - setTimeout(() => { - subscriptionPubSub.publish(subscriptionPubSubTrigger, mockNotification); - }); + .then(() => + subscriptionPubSub.publish(subscriptionPubSubTrigger, mockNotification), + ) + .catch(done); }); - it('should work without triggering multiple times per notification', done => { + it('should work without triggering multiple times per notification', (done) => { const mockNotification = { notifications: { - text: 'Hello world' - } + text: 'Hello world', + }, }; const subscription = parse(` @@ -62,29 +123,42 @@ describe('remote subscriptions', () => { `); let notificationCnt = 0; - subscribe(schema, subscription).then(results => - forAwaitEach(results as AsyncIterable, (result: ExecutionResult) => { - expect(result).to.have.property('data'); - expect(result.data).to.deep.equal(mockNotification); - notificationCnt++; - }) - ); - - subscribe(schema, subscription).then(results => - forAwaitEach(results as AsyncIterable, (result: ExecutionResult) => { - expect(result).to.have.property('data'); - expect(result.data).to.deep.equal(mockNotification); - }) - ); + const sub1 = subscribe(schema, subscription).then((results) => { + forAwaitEach( + results as AsyncIterable, + (result: ExecutionResult) => { + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(mockNotification); + notificationCnt++; + }, + ).catch(done); + }); - setTimeout(() => { - subscriptionPubSub.publish(subscriptionPubSubTrigger, mockNotification); - subscriptionPubSub.publish(subscriptionPubSubTrigger, mockNotification); - setTimeout(() => { - expect(notificationCnt).to.eq(2); - done(); - }); + const sub2 = subscribe(schema, subscription).then((results) => { + forAwaitEach( + results as AsyncIterable, + (result: ExecutionResult) => { + expect(result).to.have.property('data'); + expect(result.data).to.deep.equal(mockNotification); + }, + ).catch(done); }); + + Promise.all([sub1, sub2]) + .then(() => { + subscriptionPubSub + .publish(subscriptionPubSubTrigger, mockNotification) + .catch(done); + subscriptionPubSub + .publish(subscriptionPubSubTrigger, mockNotification) + .catch(done); + + setTimeout(() => { + expect(notificationCnt).to.eq(2); + done(); + }, 0); + }) + .catch(done); }); }); @@ -109,7 +183,7 @@ describe('respects buildSchema options', () => { it('with comment descriptions', () => { const remoteSchema = makeRemoteExecutableSchema({ schema, - buildSchemaOptions: { commentDescriptions: true } + buildSchemaOptions: { commentDescriptions: true }, }); const field = remoteSchema.getQueryType().getFields()['custom']; diff --git a/src/test/testMapSchema.ts b/src/test/testMapSchema.ts new file mode 100644 index 00000000000..6ae1aeb58a9 --- /dev/null +++ b/src/test/testMapSchema.ts @@ -0,0 +1,65 @@ +import { expect } from 'chai'; +import { GraphQLObjectType, GraphQLSchema, graphqlSync } from 'graphql'; + +import { makeExecutableSchema, mapSchema } from '../index'; +import { MapperKind } from '../Interfaces'; +import { toConfig } from '../polyfills/index'; + +describe('mapSchema', () => { + it('does not throw', () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + version: String + } + `, + }); + + const newSchema = mapSchema(schema, {}); + expect(newSchema).to.be.instanceOf(GraphQLSchema); + }); + + it('can add a resolver', () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + version: Int + } + `, + }); + + const newSchema = mapSchema(schema, { + [MapperKind.QUERY]: (type) => { + const queryConfig = toConfig(type); + queryConfig.fields.version.resolve = () => 1; + return new GraphQLObjectType(queryConfig); + }, + }); + + expect(newSchema).to.be.instanceOf(GraphQLSchema); + + const result = graphqlSync(newSchema, '{ version }'); + expect(result.data.version).to.equal(1); + }); + + it('can change the root query name', () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + version: Int + } + `, + }); + + const newSchema = mapSchema(schema, { + [MapperKind.QUERY]: (type) => { + const queryConfig = toConfig(type); + queryConfig.name = 'RootQuery'; + return new GraphQLObjectType(queryConfig); + }, + }); + + expect(newSchema).to.be.instanceOf(GraphQLSchema); + expect(newSchema.getQueryType().name).to.equal('RootQuery'); + }); +}); diff --git a/src/test/testMergeSchemas.ts b/src/test/testMergeSchemas.ts index 8e86a160dd3..f14258446e8 100644 --- a/src/test/testMergeSchemas.ts +++ b/src/test/testMergeSchemas.ts @@ -1,19 +1,31 @@ -/* tslint:disable:no-unused-expression */ - +import { forAwaitEach } from 'iterall'; import { expect } from 'chai'; import { graphql, GraphQLSchema, + GraphQLField, GraphQLObjectType, GraphQLScalarType, subscribe, parse, ExecutionResult, defaultFieldResolver, - GraphQLField, findDeprecatedUsages, + printSchema, } from 'graphql'; -import mergeSchemas from '../stitching/mergeSchemas'; + +import { delegateToSchema } from '../delegate/index'; +import { makeExecutableSchema } from '../generate/index'; +import { IResolvers, SubschemaConfig } from '../Interfaces'; +import { mergeSchemas } from '../stitch/index'; +import { + cloneSchema, + getResolversFromSchema, + graphqlVersion, + SchemaDirectiveVisitor, +} from '../utils/index'; +import { addMocksToSchema } from '../mock/index'; + import { propertySchema as localPropertySchema, productSchema as localProductSchema, @@ -25,10 +37,9 @@ import { subscriptionPubSub, subscriptionPubSubTrigger, } from './testingSchemas'; -import { SchemaDirectiveVisitor } from '../schemaVisitor'; -import { forAwaitEach } from 'iterall'; -import { makeExecutableSchema } from '../makeExecutableSchema'; -import { IResolvers } from '../Interfaces'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const removeLocations = ({ locations, ...rest }: any): any => ({ ...rest }); const testCombinations = [ { @@ -49,9 +60,18 @@ const testCombinations = [ property: remotePropertySchema, product: localProductSchema, }, + { + name: 'recreated', + booking: cloneSchema(localBookingSchema), + property: makeExecutableSchema({ + typeDefs: printSchema(localPropertySchema), + resolvers: getResolversFromSchema(localPropertySchema), + }), + product: cloneSchema(localProductSchema), + }, ]; -let scalarTest = ` +const scalarTest = ` """ Description of TestScalar. """ @@ -70,11 +90,39 @@ let scalarTest = ` } type Query { - testingScalar: TestingScalar + testingScalar(input: TestScalar): TestingScalar + listTestingScalar(input: TestScalar): [TestingScalar] } `; -let enumTest = ` +const scalarSchema = makeExecutableSchema({ + typeDefs: scalarTest, + resolvers: { + TestScalar: new GraphQLScalarType({ + name: 'TestScalar', + description: undefined, + serialize: (value) => (value as string).slice(1), + parseValue: (value) => `_${value as string}`, + parseLiteral: (ast: any) => `_${ast.value as string}`, + }), + Query: { + testingScalar(_parent, args) { + return { + value: args.input[0] === '_' ? args.input : null, + }; + }, + listTestingScalar(_parent, args) { + return [ + { + value: args.input[0] === '_' ? args.input : null, + }, + ]; + }, + }, + }, +}); + +const enumTest = ` """ A type that uses an Enum. """ @@ -95,19 +143,27 @@ let enumTest = ` TEST @deprecated(reason: "This is deprecated") } + type EnumWrapper { + color: Color + numericEnum: NumericEnum + } + + union UnionWithEnum = EnumWrapper + schema { query: Query } type Query { - color: Color + color(input: Color): Color numericEnum: NumericEnum + listNumericEnum: [NumericEnum] + wrappedEnum: EnumWrapper + unionWithEnum: UnionWithEnum } `; -let enumSchema: GraphQLSchema; - -enumSchema = makeExecutableSchema({ +const enumSchema = makeExecutableSchema({ typeDefs: enumTest, resolvers: { Color: { @@ -116,18 +172,36 @@ enumSchema = makeExecutableSchema({ NumericEnum: { TEST: 1, }, + UnionWithEnum: { + __resolveType: () => 'EnumWrapper', + }, Query: { - color() { - return '#EA3232'; + color(_parent, args) { + return args.input === '#EA3232' ? args.input : null; }, numericEnum() { return 1; }, + listNumericEnum() { + return [1]; + }, + wrappedEnum() { + return { + color: '#EA3232', + numericEnum: 1, + }; + }, + unionWithEnum() { + return { + color: '#EA3232', + numericEnum: 1, + }; + }, }, }, }); -let linkSchema = ` +const linkSchema = ` """ A new type linking the Property type. """ @@ -241,7 +315,7 @@ const codeCoverageTypeDefs = ` } `; -let schemaDirectiveTypeDefs = ` +const schemaDirectiveTypeDefs = ` directive @upper on FIELD_DEFINITION directive @withEnumArg(enumArg: DirectiveEnum = FOO) on FIELD_DEFINITION @@ -258,12 +332,12 @@ let schemaDirectiveTypeDefs = ` } `; -testCombinations.forEach(async combination => { +testCombinations.forEach((combination) => { describe('merging ' + combination.name, () => { - let mergedSchema: GraphQLSchema, - propertySchema: GraphQLSchema, - productSchema: GraphQLSchema, - bookingSchema: GraphQLSchema; + let mergedSchema: GraphQLSchema; + let propertySchema: GraphQLSchema | SubschemaConfig; + let productSchema: GraphQLSchema | SubschemaConfig; + let bookingSchema: GraphQLSchema | SubschemaConfig; before(async () => { propertySchema = await combination.property; @@ -275,8 +349,8 @@ testCombinations.forEach(async combination => { propertySchema, bookingSchema, productSchema, - scalarTest, interfaceExtensionTest, + scalarSchema, enumSchema, linkSchema, loneExtend, @@ -288,7 +362,7 @@ testCombinations.forEach(async combination => { upper: class extends SchemaDirectiveVisitor { public visitFieldDefinition(field: GraphQLField) { const { resolve = defaultFieldResolver } = field; - field.resolve = async function(...args: any[]) { + field.resolve = async function (...args) { const result = await resolve.apply(this, args); if (typeof result === 'string') { return result.toUpperCase(); @@ -304,18 +378,32 @@ testCombinations.forEach(async combination => { bookings: { fragment: '... on Property { id }', resolve(parent, args, context, info) { - // Use the old mergeInfo.delegate API just this once, to make - // sure it continues to work. - return info.mergeInfo.delegate( - 'query', - 'bookingsByPropertyId', - { + if (combination.name === 'local') { + // Use the old mergeInfo.delegate API just this once, to make + // sure it continues to work. + return info.mergeInfo.delegate( + 'query', + 'bookingsByPropertyId', + { + propertyId: parent.id, + limit: args.limit ? args.limit : null, + }, + context, + info, + ); + } + + return delegateToSchema({ + schema: bookingSchema, + operation: 'query', + fieldName: 'bookingsByPropertyId', + args: { propertyId: parent.id, limit: args.limit ? args.limit : null, }, context, info, - ); + }); }, }, someField: { @@ -327,8 +415,8 @@ testCombinations.forEach(async combination => { Booking: { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', - resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + resolve(parent, _args, context, info) { + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'propertyById', @@ -342,8 +430,8 @@ testCombinations.forEach(async combination => { }, textDescription: { fragment: '... on Booking { id }', - resolve(parent, args, context, info) { - return `Booking #${parent.id}`; + resolve(parent, _args, _context, _info) { + return `Booking #${parent.id as string}`; }, }, }, @@ -354,8 +442,8 @@ testCombinations.forEach(async combination => { }, LinkType: { property: { - resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + resolve(_parent, _args, context, info) { + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'propertyById', @@ -368,9 +456,14 @@ testCombinations.forEach(async combination => { }, }, }, + TestScalar: new GraphQLScalarType({ + name: 'TestScalar', + description: undefined, + serialize: (value) => value, + }), Query: { - delegateInterfaceTest(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + delegateInterfaceTest(_parent, _args, context, info) { + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'interfaceTest', @@ -381,8 +474,8 @@ testCombinations.forEach(async combination => { info, }); }, - delegateArgumentTest(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + delegateArgumentTest(_parent, _args, context, info) { + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'propertyById', @@ -401,9 +494,9 @@ testCombinations.forEach(async combination => { node: { // fragment doesn't work fragment: '... on Node { id }', - resolve(parent, args, context, info) { + resolve(_parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'propertyById', @@ -412,7 +505,7 @@ testCombinations.forEach(async combination => { info, }); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: bookingSchema, operation: 'query', fieldName: 'bookingById', @@ -421,7 +514,7 @@ testCombinations.forEach(async combination => { info, }); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: bookingSchema, operation: 'query', fieldName: 'customerById', @@ -429,20 +522,20 @@ testCombinations.forEach(async combination => { context, info, }); - } else { - throw new Error('invalid id'); } + + throw new Error('invalid id'); }, }, - async nodes(parent, args, context, info) { - const bookings = await info.mergeInfo.delegateToSchema({ + async nodes(_parent, _args, context, info) { + const bookings = await delegateToSchema({ schema: bookingSchema, operation: 'query', fieldName: 'bookings', context, info, }); - const properties = await info.mergeInfo.delegateToSchema({ + const properties = await delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'properties', @@ -459,7 +552,7 @@ testCombinations.forEach(async combination => { describe('basic', () => { it('works with context', async () => { const propertyResult = await graphql( - propertySchema, + localPropertySchema, ` query { contextTest(key: "test") @@ -495,7 +588,7 @@ testCombinations.forEach(async combination => { it('works with custom scalars', async () => { const propertyResult = await graphql( - propertySchema, + localPropertySchema, ` query { dateTimeTest @@ -529,32 +622,58 @@ testCombinations.forEach(async combination => { expect(mergedResult).to.deep.equal(propertyResult); }); - it('works with custom enums', async () => { - const localSchema = makeExecutableSchema({ - typeDefs: enumTest, - resolvers: { - Color: { - RED: '#EA3232', - }, - NumericEnum: { - TEST: 1, + it('works with custom scalars', async () => { + const scalarResult = await graphql( + scalarSchema, + ` + query { + testingScalar(input: "test") { + value + } + listTestingScalar(input: "test") { + value + } + } + `, + ); + + const mergedResult = await graphql( + mergedSchema, + ` + query { + testingScalar(input: "test") { + value + } + listTestingScalar(input: "test") { + value + } + } + `, + ); + + expect(scalarResult).to.deep.equal({ + data: { + testingScalar: { + value: 'test', }, - Query: { - color() { - return '#EA3232'; - }, - numericEnum() { - return 1; + listTestingScalar: [ + { + value: 'test', }, - }, + ], }, }); + expect(mergedResult).to.deep.equal(scalarResult); + }); + + it('works with custom enums', async () => { const enumResult = await graphql( - localSchema, + enumSchema, ` query { - color + color(input: RED) numericEnum + listNumericEnum numericEnumInfo: __type(name: "NumericEnum") { enumValues(includeDeprecated: true) { name @@ -569,6 +688,16 @@ testCombinations.forEach(async combination => { description } } + wrappedEnum { + color + numericEnum + } + unionWithEnum { + ... on EnumWrapper { + color + numericEnum + } + } } `, ); @@ -577,8 +706,9 @@ testCombinations.forEach(async combination => { mergedSchema, ` query { - color + color(input: RED) numericEnum + listNumericEnum numericEnumInfo: __type(name: "NumericEnum") { enumValues(includeDeprecated: true) { name @@ -593,6 +723,16 @@ testCombinations.forEach(async combination => { description } } + wrappedEnum { + color + numericEnum + } + unionWithEnum { + ... on EnumWrapper { + color + numericEnum + } + } } `, ); @@ -601,6 +741,7 @@ testCombinations.forEach(async combination => { data: { color: 'RED', numericEnum: 'TEST', + listNumericEnum: ['TEST'], numericEnumInfo: { enumValues: [ { @@ -619,6 +760,14 @@ testCombinations.forEach(async combination => { }, ], }, + wrappedEnum: { + color: 'RED', + numericEnum: 'TEST', + }, + unionWithEnum: { + color: 'RED', + numericEnum: 'TEST', + }, }, }); expect(mergedResult).to.deep.equal(enumResult); @@ -643,12 +792,12 @@ bookingById(id: "b1") { `; const propertyResult = await graphql( - propertySchema, + localPropertySchema, `query { ${propertyFragment} }`, ); const bookingResult = await graphql( - bookingSchema, + localBookingSchema, `query { ${bookingFragment} }`, ); @@ -689,7 +838,7 @@ bookingById(id: "b1") { }; const bookingResult = await graphql( - bookingSchema, + localBookingSchema, mutationFragment, {}, {}, @@ -710,7 +859,7 @@ bookingById(id: "b1") { expect(mergedResult).to.deep.equal(bookingResult); }); - it('local subscriptions working in merged schema', done => { + it('local subscriptions working in merged schema', (done) => { const mockNotification = { notifications: { text: 'Hello world', @@ -727,27 +876,28 @@ bookingById(id: "b1") { let notificationCnt = 0; subscribe(mergedSchema, subscription) - .then(results => { + .then((results) => { forAwaitEach( results as AsyncIterable, (result: ExecutionResult) => { expect(result).to.have.property('data'); expect(result.data).to.deep.equal(mockNotification); - !notificationCnt++ ? done() : null; + if (!notificationCnt++) { + done(); + } }, ).catch(done); }) + .then(() => + subscriptionPubSub.publish( + subscriptionPubSubTrigger, + mockNotification, + ), + ) .catch(done); - - setTimeout(() => { - subscriptionPubSub.publish( - subscriptionPubSubTrigger, - mockNotification - ); - }); }); - it('subscription errors are working correctly in merged schema', done => { + it('subscription errors are working correctly in merged schema', (done) => { const mockNotification = { notifications: { text: 'Hello world', @@ -786,7 +936,7 @@ bookingById(id: "b1") { let notificationCnt = 0; subscribe(mergedSchema, subscription) - .then(results => { + .then((results) => { forAwaitEach( results as AsyncIterable, (result: ExecutionResult) => { @@ -795,18 +945,19 @@ bookingById(id: "b1") { expect(result.errors).to.have.lengthOf(1); expect(result.errors).to.deep.equal(expectedResult.errors); expect(result.data).to.deep.equal(expectedResult.data); - !notificationCnt++ ? done() : null; + if (!notificationCnt++) { + done(); + } }, ).catch(done); }) + .then(() => + subscriptionPubSub.publish( + subscriptionPubSubTrigger, + mockNotification, + ), + ) .catch(done); - - setTimeout(() => { - subscriptionPubSub.publish( - subscriptionPubSubTrigger, - mockNotification - ); - }); }); it('links in queries', async () => { @@ -912,7 +1063,7 @@ bookingById(id: "b1") { } } `; - const propertyResult = await graphql(propertySchema, query); + const propertyResult = await graphql(localPropertySchema, query); const mergedResult = await graphql(mergedSchema, query); expect(propertyResult).to.deep.equal({ @@ -1264,8 +1415,8 @@ bookingById(id: "b1") { TestScalar: new GraphQLScalarType({ name: 'TestScalar', description: undefined, - serialize: value => value, - parseValue: value => value, + serialize: (value) => value, + parseValue: (value) => value, parseLiteral: () => null, }), }; @@ -1282,7 +1433,7 @@ bookingById(id: "b1") { bookings: { fragment: 'fragment PropertyFragment on Property { id }', resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: bookingSchema, operation: 'query', fieldName: 'bookingsByPropertyId', @@ -1301,8 +1452,8 @@ bookingById(id: "b1") { Booking: { property: { fragment: 'fragment BookingFragment on Booking { propertyId }', - resolve(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + resolve(parent, _args, context, info) { + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'propertyById', @@ -1328,8 +1479,8 @@ bookingById(id: "b1") { }; const Query2: IResolvers = { Query: { - delegateInterfaceTest(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + delegateInterfaceTest(_parent, _args, context, info) { + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'interfaceTest', @@ -1340,8 +1491,8 @@ bookingById(id: "b1") { info, }); }, - delegateArgumentTest(parent, args, context, info) { - return info.mergeInfo.delegateToSchema({ + delegateArgumentTest(_parent, _args, context, info) { + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'propertyById', @@ -1360,9 +1511,9 @@ bookingById(id: "b1") { node: { // fragment doesn't work fragment: 'fragment NodeFragment on Node { id }', - resolve(parent, args, context, info) { + resolve(_parent, args, context, info) { if (args.id.startsWith('p')) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'propertyById', @@ -1371,7 +1522,7 @@ bookingById(id: "b1") { info, }); } else if (args.id.startsWith('b')) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: bookingSchema, operation: 'query', fieldName: 'bookingById', @@ -1380,7 +1531,7 @@ bookingById(id: "b1") { info, }); } else if (args.id.startsWith('c')) { - return info.mergeInfo.delegateToSchema({ + return delegateToSchema({ schema: bookingSchema, operation: 'query', fieldName: 'customerById', @@ -1388,9 +1539,9 @@ bookingById(id: "b1") { context, info, }); - } else { - throw new Error('invalid id'); } + + throw new Error('invalid id'); }, }, }, @@ -1398,15 +1549,15 @@ bookingById(id: "b1") { const AsyncQuery: IResolvers = { Query: { - async nodes(parent, args, context, info) { - const bookings = await info.mergeInfo.delegateToSchema({ + async nodes(_parent, _args, context, info) { + const bookings = await delegateToSchema({ schema: bookingSchema, operation: 'query', fieldName: 'bookings', context, info, }); - const properties = await info.mergeInfo.delegateToSchema({ + const properties = await delegateToSchema({ schema: propertySchema, operation: 'query', fieldName: 'properties', @@ -1485,7 +1636,7 @@ fragment BookingFragment on Booking { `; const propertyResult = await graphql( - propertySchema, + localPropertySchema, ` ${propertyFragment} query { @@ -1497,7 +1648,7 @@ fragment BookingFragment on Booking { ); const bookingResult = await graphql( - bookingSchema, + localBookingSchema, ` ${bookingFragment} query { @@ -1556,12 +1707,12 @@ bookingById(id: "b1") { `; const propertyResult = await graphql( - propertySchema, + localPropertySchema, `query { ${propertyFragment} }`, ); const bookingResult = await graphql( - bookingSchema, + localBookingSchema, `query { ${bookingFragment} }`, ); @@ -1667,7 +1818,7 @@ fragment BookingFragment on Booking { `; const propertyResult = await graphql( - propertySchema, + localPropertySchema, ` ${propertyFragment1} ${propertyFragment2} @@ -1681,7 +1832,7 @@ fragment BookingFragment on Booking { ); const bookingResult = await graphql( - bookingSchema, + localBookingSchema, ` ${bookingFragment} query { @@ -1905,7 +2056,7 @@ fragment BookingFragment on Booking { `; const propertyResult = await graphql( - propertySchema, + localPropertySchema, `query($p1: ID!) { ${propertyFragment} }`, {}, {}, @@ -1915,7 +2066,7 @@ fragment BookingFragment on Booking { ); const bookingResult = await graphql( - bookingSchema, + localBookingSchema, `query($b1: ID!) { ${bookingFragment} }`, {}, {}, @@ -2144,14 +2295,14 @@ fragment BookingFragment on Booking { `; const propertyResult = await graphql( - propertySchema, + localPropertySchema, `query { ${propertyFragment} }`, ); const bookingResult = await graphql( - bookingSchema, + localBookingSchema, `query { ${bookingFragment} }`, @@ -2164,13 +2315,13 @@ fragment BookingFragment on Booking { ${bookingFragment} }`, ); - expect(mergedResult).to.deep.equal({ - errors: propertyResult.errors, - data: { - ...propertyResult.data, - ...bookingResult.data, - }, + expect(mergedResult.data).to.deep.equal({ + ...propertyResult.data, + ...bookingResult.data, }); + expect(mergedResult.errors.map(removeLocations)).to.deep.equal( + propertyResult.errors.map(removeLocations), + ); const mergedResult2 = await graphql( mergedSchema, @@ -2182,21 +2333,13 @@ fragment BookingFragment on Booking { `, ); - expect(mergedResult2).to.deep.equal({ - errors: [ - { - locations: [ - { - column: 19, - line: 3, - }, - ], - message: 'Sample error non-null!', - path: ['errorTestNonNull'], - }, - ], - data: null, - }); + expect(mergedResult2.data).to.equal(null); + expect(mergedResult2.errors.map(removeLocations)).to.deep.equal([ + { + message: 'Sample error non-null!', + path: ['errorTestNonNull'], + }, + ]); }); it('nested errors', async () => { @@ -2217,114 +2360,113 @@ fragment BookingFragment on Booking { `, ); - expect(result).to.deep.equal({ - data: { - propertyById: { - bookings: [ - { - bookingErrorAlias: null, - error: null, - id: 'b1', - }, - { - bookingErrorAlias: null, - error: null, - id: 'b2', - }, - { - bookingErrorAlias: null, - error: null, - id: 'b3', - }, - ], - error: null, - errorAlias: null, - }, + expect(result.data).to.deep.equal({ + propertyById: { + bookings: [ + { + bookingErrorAlias: null, + error: null, + id: 'b1', + }, + { + bookingErrorAlias: null, + error: null, + id: 'b2', + }, + { + bookingErrorAlias: null, + error: null, + id: 'b3', + }, + ], + error: null, + errorAlias: null, }, - errors: [ - { - locations: [ - { - column: 17, - line: 4, - }, - ], - message: 'Property.error error', - path: ['propertyById', 'error'], - }, - { - locations: [ - { - column: 17, - line: 5, - }, - ], - message: 'Property.error error', - path: ['propertyById', 'errorAlias'], - }, - { - locations: [ - { - column: 19, - line: 8, - }, - ], - message: 'Booking.error error', - path: ['propertyById', 'bookings', 0, 'error'], - }, - { - locations: [ - { - column: 19, - line: 9, - }, - ], - message: 'Booking.error error', - path: ['propertyById', 'bookings', 0, 'bookingErrorAlias'], - }, - { - locations: [ - { - column: 19, - line: 8, - }, - ], - message: 'Booking.error error', - path: ['propertyById', 'bookings', 1, 'error'], - }, - { - locations: [ - { - column: 19, - line: 9, - }, - ], - message: 'Booking.error error', - path: ['propertyById', 'bookings', 1, 'bookingErrorAlias'], - }, - { - locations: [ - { - column: 19, - line: 8, - }, - ], - message: 'Booking.error error', - path: ['propertyById', 'bookings', 2, 'error'], - }, - { - locations: [ - { - column: 19, - line: 9, - }, - ], - message: 'Booking.error error', - path: ['propertyById', 'bookings', 2, 'bookingErrorAlias'], - }, - ], + }); + + const errorsWithoutLocations = result.errors.map(removeLocations); + + const expectedErrors: Array = [ + { + message: 'Property.error error', + path: ['propertyById', 'error'], + }, + { + message: 'Property.error error', + path: ['propertyById', 'errorAlias'], + }, + { + message: 'Booking.error error', + path: ['propertyById', 'bookings', 0, 'error'], + }, + { + message: 'Booking.error error', + path: ['propertyById', 'bookings', 0, 'bookingErrorAlias'], + }, + { + message: 'Booking.error error', + path: ['propertyById', 'bookings', 1, 'error'], + }, + { + message: 'Booking.error error', + path: ['propertyById', 'bookings', 1, 'bookingErrorAlias'], + }, + { + message: 'Booking.error error', + path: ['propertyById', 'bookings', 2, 'error'], + }, + { + message: 'Booking.error error', + path: ['propertyById', 'bookings', 2, 'bookingErrorAlias'], + }, + ]; + + if (graphqlVersion() >= 14) { + expectedErrors[0].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + expectedErrors[1].extensions = { + code: 'SOME_CUSTOM_CODE', + }; + } + + expect(errorsWithoutLocations).to.deep.equal(expectedErrors); + expect(result.errors[0].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', + }); + expect(result.errors[1].extensions).to.deep.equal({ + code: 'SOME_CUSTOM_CODE', }); }); + + it( + 'should preserve custom error extensions from the original schema, ' + + 'when merging schemas', + async () => { + const propertyQuery = ` + query { + properties(limit: 1) { + error + } + } + `; + + const propertyResult = await graphql( + localPropertySchema, + propertyQuery, + ); + + const mergedResult = await graphql(mergedSchema, propertyQuery); + + [propertyResult, mergedResult].forEach((result) => { + expect(result.errors).to.not.equal(undefined); + expect(result.errors.length > 0).to.equal(true); + const error = result.errors[0]; + expect(error.extensions).to.not.equal(undefined); + expect(error.extensions.code).to.equal('SOME_CUSTOM_CODE'); + }); + }, + ); }); describe('types in schema extensions', () => { @@ -2603,38 +2745,40 @@ fragment BookingFragment on Booking { }); }); - it('interface extensions', async () => { - const result = await graphql( - mergedSchema, - ` - query { - products { - id - __typename - ... on Downloadable { - filesize + if (graphqlVersion() >= 13) { + it('interface extensions', async () => { + const result = await graphql( + mergedSchema, + ` + query { + products { + id + __typename + ... on Downloadable { + filesize + } } } - } - `, - ); + `, + ); - expect(result).to.deep.equal({ - data: { - products: [ - { - id: 'pd1', - __typename: 'SimpleProduct', - }, - { - id: 'pd2', - __typename: 'DownloadableProduct', - filesize: 1024, - }, - ], - }, + expect(result).to.deep.equal({ + data: { + products: [ + { + id: 'pd1', + __typename: 'SimpleProduct', + }, + { + id: 'pd2', + __typename: 'DownloadableProduct', + filesize: 1024, + }, + ], + }, + }); }); - }); + } it('arbitrary transforms that return interfaces', async () => { const result = await graphql( @@ -2741,7 +2885,7 @@ fragment BookingFragment on Booking { }); }); - it('defaultMergedResolver should work with non-root aliases', async () => { + it('defaultMergedResolver should work with aliases if parent merged resolver is manually overwritten', async () => { // Source: https://github.com/apollographql/graphql-tools/issues/967 const typeDefs = ` type Query { @@ -2755,26 +2899,23 @@ fragment BookingFragment on Booking { const resolvers = { Query: { - book: () => ({ category: 'Test' }) - } + book: () => ({ category: 'Test' }), + }, }; schema = mergeSchemas({ schemas: [schema], - resolvers + resolvers, }); - const result = await graphql( - schema, - `{ book { cat: category } }`, - ); + const result = await graphql(schema, '{ book { cat: category } }'); expect(result.data.book.cat).to.equal('Test'); }); }); describe('deprecation', () => { - it('should retain deprecation information', async () => { + it('should retain deprecation information', () => { const typeDefs = ` type Query { book: Book @@ -2792,19 +2933,24 @@ fragment BookingFragment on Booking { const resolvers = { Query: { - book: () => ({ category: 'Test' }) - } + book: () => ({ category: 'Test' }), + }, }; const schema = mergeSchemas({ schemas: [propertySchema, typeDefs], - resolvers + resolvers, }); const deprecatedUsages = findDeprecatedUsages(schema, parse(query)); - expect(deprecatedUsages).not.empty; expect(deprecatedUsages.length).to.equal(1); - expect(deprecatedUsages.find(error => Boolean(error && error.message.match(/deprecated/) && error.message.match(/yolo/)))); + expect( + deprecatedUsages.find( + (error) => + error.message.match(/deprecated/g) != null && + error.message.match(/yolo/g) != null, + ), + ).to.not.equal(undefined); }); }); }); @@ -2869,4 +3015,60 @@ fragment BookingFragment on Booking { }); }); }); + + describe('new root type name', () => { + it('works', async () => { + const bookSchema = makeExecutableSchema({ + typeDefs: ` + type Query { + book: Book + } + type Book { + name: String + } + `, + }); + + const movieSchema = makeExecutableSchema({ + typeDefs: ` + type Query { + movie: Movie + } + + type Movie { + name: String + } + `, + }); + + addMocksToSchema({ schema: bookSchema }); + addMocksToSchema({ schema: movieSchema }); + + const mergedSchema = mergeSchemas({ + schemas: [bookSchema, movieSchema], + queryTypeName: 'RootQuery', + }); + + const result = await graphql( + mergedSchema, + ` + query { + ... on RootQuery { + book { + name + } + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + book: { + name: 'Hello World', + }, + }, + }); + }); + }); }); diff --git a/src/test/testMocking.ts b/src/test/testMocking.ts index 01c217796ae..fab9ece9eaa 100644 --- a/src/test/testMocking.ts +++ b/src/test/testMocking.ts @@ -1,12 +1,18 @@ import { expect } from 'chai'; -import { graphql, GraphQLResolveInfo } from 'graphql'; -import { addMockFunctionsToSchema, MockList, mockServer } from '../mock'; +import { + graphql, + GraphQLResolveInfo, + GraphQLSchema, + GraphQLFieldResolver, +} from 'graphql'; + +import { addMocksToSchema, MockList, mockServer } from '../mock/index'; import { buildSchemaFromTypeDefinitions, - addResolveFunctionsToSchema, + addResolversToSchema, makeExecutableSchema, -} from '../makeExecutableSchema'; -import 'mocha'; +} from '../generate/index'; +import { IMocks } from '../Interfaces'; describe('Mock', () => { const shorthand = ` @@ -72,33 +78,34 @@ describe('Mock', () => { const resolveFunctions = { BirdsAndBees: { - __resolveType(data: any, context: any, info: GraphQLResolveInfo) { + __resolveType(data: any, _context: any, info: GraphQLResolveInfo) { return info.schema.getType(data.__typename); }, }, Flying: { - __resolveType(data: any, context: any, info: GraphQLResolveInfo) { + __resolveType(data: any, _context: any, info: GraphQLResolveInfo) { return info.schema.getType(data.__typename); }, }, }; it('throws an error if you forget to pass schema', () => { - expect(() => (addMockFunctionsToSchema)({})).to.throw( - 'Must provide schema to mock', - ); + expect(() => addMocksToSchema({})).to.throw('Must provide schema to mock'); }); it('throws an error if the property "schema" on the first argument is not of type GraphQLSchema', () => { - expect(() => (addMockFunctionsToSchema)({ schema: {} })).to.throw( - 'Value at "schema" must be of type GraphQLSchema', - ); + expect(() => + addMocksToSchema({ schema: ({} as unknown) as GraphQLSchema }), + ).to.throw('Value at "schema" must be of type GraphQLSchema'); }); it('throws an error if second argument is not a Map', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); expect(() => - (addMockFunctionsToSchema)({ schema: jsSchema, mocks: ['a'] }), + addMocksToSchema({ + schema: jsSchema, + mocks: (['a'] as unknown) as IMocks, + }), ).to.throw('mocks must be of type Object'); }); @@ -106,14 +113,17 @@ describe('Mock', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { Int: 55 }; expect(() => - (addMockFunctionsToSchema)({ schema: jsSchema, mocks: mockMap }), + addMocksToSchema({ + schema: jsSchema, + mocks: (mockMap as unknown) as IMocks, + }), ).to.throw('mockFunctionMap[Int] must be a function'); }); it('mocks the default types for you', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = {}; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnInt returnFloat @@ -121,7 +131,7 @@ describe('Mock', () => { returnString returnID }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnInt']).to.be.within(-1000, 1000); expect(res.data['returnFloat']).to.be.within(-1000, 1000); expect(res.data['returnBoolean']).to.be.a('boolean'); @@ -157,9 +167,7 @@ describe('Mock', () => { .query(testQuery) .then((res: any) => { expect(res.data.returnInt).to.equal(12345); - expect(res.data.returnFloat) - .to.be.a('number') - .within(-1000, 1000); + expect(res.data.returnFloat).to.be.a('number').within(-1000, 1000); expect(res.data.returnBoolean).to.be.a('boolean'); expect(res.data.returnString).to.be.a('string'); expect(res.data.returnID).to.be.a('string'); @@ -177,7 +185,7 @@ describe('Mock', () => { returnString: () => 'someString', }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); + addResolversToSchema(jsSchema, resolvers); const testQuery = `{ returnInt returnString @@ -235,9 +243,7 @@ describe('Mock', () => { .query(testQuery) .then((res: any) => { expect(res.data.returnInt).to.equal(12345); - expect(res.data.returnFloat) - .to.be.a('number') - .within(-1000, 1000); + expect(res.data.returnFloat).to.be.a('number').within(-1000, 1000); expect(res.data.returnBoolean).to.be.a('boolean'); expect(res.data.returnString).to.be.a('string'); expect(res.data.returnID).to.be.a('string'); @@ -253,14 +259,14 @@ describe('Mock', () => { let spy = 0; const resolvers = { BirdsAndBees: { - __resolveType(data: any, context: any, info: GraphQLResolveInfo) { + __resolveType(data: any, _context: any, info: GraphQLResolveInfo) { ++spy; return info.schema.getType(data.__typename); }, }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); - addMockFunctionsToSchema({ + addResolversToSchema(jsSchema, resolvers); + addMocksToSchema({ schema: jsSchema, mocks: {}, preserveResolvers: true, @@ -277,7 +283,7 @@ describe('Mock', () => { } } }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((_res) => { // the resolveType has been called twice expect(spy).to.equal(2); }); @@ -287,18 +293,18 @@ describe('Mock', () => { it('can mock Enum', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = {}; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnEnum }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnEnum']).to.be.oneOf(['A', 'B', 'C']); }); }); it('can mock Unions', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); - addResolveFunctionsToSchema(jsSchema, resolveFunctions); + addResolversToSchema(jsSchema, resolveFunctions); const mockMap = { Int: () => 10, String: () => 'aha', @@ -307,7 +313,7 @@ describe('Mock', () => { returnBirdsAndBees: () => new MockList(40), }), }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnBirdsAndBees { ... on Bird { @@ -320,7 +326,7 @@ describe('Mock', () => { } } }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { // XXX this test is expected to fail once every 2^40 times ;-) expect(res.data['returnBirdsAndBees']).to.deep.include({ returnInt: 10, @@ -335,7 +341,7 @@ describe('Mock', () => { it('can mock Interfaces by default', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); - addResolveFunctionsToSchema(jsSchema, resolveFunctions); + addResolversToSchema(jsSchema, resolveFunctions); const mockMap = { Int: () => 10, String: () => 'aha', @@ -344,7 +350,7 @@ describe('Mock', () => { returnFlying: () => new MockList(40), }), }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -361,7 +367,7 @@ describe('Mock', () => { } } }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnFlying']).to.deep.include({ returnInt: 10, returnString: 'aha', @@ -375,27 +381,28 @@ describe('Mock', () => { it('can support explicit Interface mock', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); - addResolveFunctionsToSchema(jsSchema, resolveFunctions); + addResolversToSchema(jsSchema, resolveFunctions); let spy = 0; const mockMap = { - Bird: (root: any, args: any) => ({ + Bird: (_root: any, args: any) => ({ id: args.id, returnInt: 100, }), - Bee: (root: any, args: any) => ({ + Bee: (_root: any, args: any) => ({ id: args.id, returnInt: 200, }), - Flying: (root: any, args: any) => { + Flying: (_root: any, args: any) => { spy++; const { id } = args; const type = id.split(':')[0]; - // tslint:disable-next-line - const __typename = ['Bird', 'Bee'].find(r => r.toLowerCase() === type); + const __typename = ['Bird', 'Bee'].find( + (r) => r.toLowerCase() === type, + ); return { __typename }; }, }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -407,7 +414,7 @@ describe('Mock', () => { } }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(spy).to.equal(1); // to make sure that Flying possible types are not randomly selected expect(res.data['node']).to.include({ id: 'bee:123456', @@ -418,27 +425,27 @@ describe('Mock', () => { it('can support explicit UnionType mock', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); - addResolveFunctionsToSchema(jsSchema, resolveFunctions); + addResolversToSchema(jsSchema, resolveFunctions); let spy = 0; const mockMap = { - Bird: (root: any, args: any) => ({ + Bird: (_root: any, args: any) => ({ id: args.id, returnInt: 100, }), - Bee: (root: any, args: any) => ({ + Bee: (_root: any, args: any) => ({ id: args.id, returnEnum: 'A', }), - BirdsAndBees: (root: any, args: any) => { + BirdsAndBees: (_root: any, args: any) => { spy++; const { id } = args; const type = id.split(':')[0]; return { - __typename: ['Bird', 'Bee'].find(r => r.toLowerCase() === type), + __typename: ['Bird', 'Bee'].find((r) => r.toLowerCase() === type), }; }, }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -452,7 +459,7 @@ describe('Mock', () => { } }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(spy).to.equal(1); expect(res.data['node2']).to.include({ id: 'bee:123456', @@ -463,21 +470,19 @@ describe('Mock', () => { it('throws an error when __typename is not returned within an explicit interface mock', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); - addResolveFunctionsToSchema(jsSchema, resolveFunctions); + addResolversToSchema(jsSchema, resolveFunctions); const mockMap = { - Bird: (root: any, args: any) => ({ + Bird: (_root: any, args: any) => ({ id: args.id, returnInt: 100, }), - Bee: (root: any, args: any) => ({ + Bee: (_root: any, args: any) => ({ id: args.id, returnInt: 100, }), - Flying: (root: any, args: any): void => { - return; - }, + Flying: (_root: any, _args: any) => ({}), }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ node(id:"bee:123456"){ id, @@ -485,21 +490,21 @@ describe('Mock', () => { } }`; const expected = 'Please return a __typename in "Flying"'; - return graphql(jsSchema, testQuery).then(res => { - expect((res.errors[0]).originalError.message).to.equal(expected); + return graphql(jsSchema, testQuery).then((res) => { + expect(res.errors[0].originalError.message).to.equal(expected); }); }); it('throws an error in resolve if mock type is not defined', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = {}; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnMockError }`; const expected = 'No mock defined for type "MissingMockType"'; - return graphql(jsSchema, testQuery).then(res => { - expect((res.errors[0]).originalError.message).to.equal(expected); + return graphql(jsSchema, testQuery).then((res) => { + expect(res.errors[0].originalError.message).to.equal(expected); }); }); @@ -512,13 +517,13 @@ describe('Mock', () => { __parseLiteral: (val: string) => val, }, RootQuery: { - returnMockError: () => undefined, + returnMockError: (): string => undefined, }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); + addResolversToSchema(jsSchema, resolvers); const mockMap = {}; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -527,8 +532,8 @@ describe('Mock', () => { returnMockError }`; const expected = 'No mock defined for type "MissingMockType"'; - return graphql(jsSchema, testQuery).then(res => { - expect((res.errors[0]).originalError.message).to.equal(expected); + return graphql(jsSchema, testQuery).then((res) => { + expect(res.errors[0].originalError.message).to.equal(expected); }); }); @@ -544,10 +549,10 @@ describe('Mock', () => { returnMockError: () => '10-11-2012', }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); + addResolversToSchema(jsSchema, resolvers); const mockMap = {}; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -558,7 +563,7 @@ describe('Mock', () => { const expected = { returnMockError: '10-11-2012', }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); expect(res.errors).to.equal(undefined); }); @@ -567,11 +572,11 @@ describe('Mock', () => { it('can mock an Int', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { Int: () => 55 }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnInt }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnInt']).to.equal(55); }); }); @@ -579,77 +584,77 @@ describe('Mock', () => { it('can mock a Float', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { Float: () => 55.5 }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnFloat }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnFloat']).to.equal(55.5); }); }); it('can mock a String', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { String: () => 'a string' }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnString }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnString']).to.equal('a string'); }); }); it('can mock a Boolean', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { Boolean: () => true }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnBoolean }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnBoolean']).to.equal(true); }); }); it('can mock an ID', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { ID: () => 'ea5bdc19' }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnID }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnID']).to.equal('ea5bdc19'); }); }); it('nullable type is nullable', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { String: (): null => null }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnNullableString }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnNullableString']).to.equal(null); }); }); it('can mock a nonNull type', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { String: () => 'nonnull' }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnNonNullString }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnNonNullString']).to.equal('nonnull'); }); }); it('nonNull type is not nullable', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { String: (): null => null }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnNonNullString }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.equal(null); expect(res.errors.length).to.equal(1); }); @@ -660,14 +665,14 @@ describe('Mock', () => { String: () => 'abc', Int: () => 123, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnObject { returnInt, returnString } }`; const expected = { returnObject: { returnInt: 123, returnString: 'abc' }, }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -675,14 +680,14 @@ describe('Mock', () => { it('can mock a list of ints', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { Int: () => 123 }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnListOfInt }`; const expected = { returnListOfInt: [123, 123], }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -693,7 +698,7 @@ describe('Mock', () => { String: () => 'a', Int: () => 1, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnListOfListOfObject { returnInt, returnString } }`; @@ -709,18 +714,18 @@ describe('Mock', () => { ], ], }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); - it('does not mask resolve functions if you tell it not to', () => { + it('does not mask resolvers if you tell it not to', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { RootQuery: () => ({ - returnInt: (root: any, args: { [key: string]: any }) => 42, // a) in resolvers, will not be used - returnFloat: (root: any, args: { [key: string]: any }) => 1.3, // b) not in resolvers, will be used - returnString: (root: any, args: { [key: string]: any }) => + returnInt: (_root: any, _args: { [key: string]: any }) => 42, // a) in resolvers, will not be used + returnFloat: (_root: any, _args: { [key: string]: any }) => 1.3, // b) not in resolvers, will be used + returnString: (_root: any, _args: { [key: string]: any }) => Promise.resolve('foo'), // c) in resolvers, will not be used }), }; @@ -730,8 +735,8 @@ describe('Mock', () => { returnString: () => Promise.resolve('bar'), // see c) }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); - addMockFunctionsToSchema({ + addResolversToSchema(jsSchema, resolvers); + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -746,7 +751,7 @@ describe('Mock', () => { returnFloat: 1.3, // b) from mock returnString: 'bar', // c) from resolvers, not masked by mock (and promise) }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -760,7 +765,7 @@ describe('Mock', () => { }), Int: () => 15, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnObject{ returnInt @@ -775,7 +780,7 @@ describe('Mock', () => { }, returnInt: 15, }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -791,7 +796,7 @@ describe('Mock', () => { }), }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); + addResolversToSchema(jsSchema, resolvers); const mockMap = { returnListOfInt: () => [5, 6, 7], Bird: () => ({ @@ -799,7 +804,7 @@ describe('Mock', () => { returnString: 'woot!?', // b) another part of a Bird }), }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -818,7 +823,7 @@ describe('Mock', () => { returnString: 'woot!?', // from the mock, see b) }, }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -834,14 +839,14 @@ describe('Mock', () => { }), }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); + addResolversToSchema(jsSchema, resolvers); const mockMap = { Bird: () => ({ returnInt: 3, // see a) returnString: 'woot!?', // b) another part of a Bird }), }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -858,7 +863,7 @@ describe('Mock', () => { returnString: 'woot!?', // from the mock, see b) }, }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -876,14 +881,14 @@ describe('Mock', () => { returnObject: () => objProxy, }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); + addResolversToSchema(jsSchema, resolvers); const mockMap = { Bird: () => ({ returnInt: 3, // see a) returnString: 'woot!?', // b) another part of a Bird }), }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -900,7 +905,7 @@ describe('Mock', () => { returnString: 'woot!?', // from the mock, see b) }, }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -924,7 +929,7 @@ describe('Mock', () => { const mockMap = { Int: () => 123, // b) mock of Int. }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -943,7 +948,7 @@ describe('Mock', () => { }, returnString: 'woot!?', // from the mock, see a) }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -969,7 +974,7 @@ describe('Mock', () => { const mockMap = { Int: () => 123, // b) mock of Int. }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -988,7 +993,7 @@ describe('Mock', () => { }, returnString: 'woot!?', // from the mock, see a) }; - return graphql(jsSchema, testQuery, undefined, {}).then(res => { + return graphql(jsSchema, testQuery, undefined, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1015,7 +1020,7 @@ describe('Mock', () => { const mockMap = { Int: () => 123, // b) mock of Int. }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -1034,7 +1039,7 @@ describe('Mock', () => { }, returnString: 'woot!?', // from the mock, see a) }; - return graphql(jsSchema, testQuery, undefined, {}).then(res => { + return graphql(jsSchema, testQuery, undefined, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1046,11 +1051,11 @@ describe('Mock', () => { returnString: (): string => null, // a) resolve of a string }, }; - addResolveFunctionsToSchema(jsSchema, resolvers); + addResolversToSchema(jsSchema, resolvers); const mockMap = { Int: () => 666, // b) mock of Int. }; - addMockFunctionsToSchema({ + addMocksToSchema({ schema: jsSchema, mocks: mockMap, preserveResolvers: true, @@ -1067,9 +1072,9 @@ describe('Mock', () => { returnInt: 666, // from the mock, see b) returnString: 'Hello World', // from mock default values. }, - returnString: null as string, /// from the mock, see a) + returnString: null as string, // from the mock, see a) }; - return graphql(jsSchema, testQuery, undefined, {}).then(res => { + return graphql(jsSchema, testQuery, undefined, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1078,17 +1083,17 @@ describe('Mock', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { RootQuery: () => ({ - returnStringArgument: (o: any, a: { [key: string]: any }) => a['s'], + returnStringArgument: (_o: any, a: { [key: string]: any }) => a['s'], }), }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnStringArgument(s: "adieu") }`; const expected = { returnStringArgument: 'adieu', }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1097,17 +1102,17 @@ describe('Mock', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { RootMutation: () => ({ - returnStringArgument: (o: any, a: { [key: string]: any }) => a['s'], + returnStringArgument: (_o: any, a: { [key: string]: any }) => a['s'], }), }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `mutation { returnStringArgument(s: "adieu") }`; const expected = { returnStringArgument: 'adieu', }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1118,14 +1123,14 @@ describe('Mock', () => { RootQuery: () => ({ returnListOfInt: () => new MockList(3) }), Int: () => 12, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnListOfInt }`; const expected = { returnListOfInt: [12, 12, 12], }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1136,11 +1141,11 @@ describe('Mock', () => { RootQuery: () => ({ returnListOfInt: () => new MockList([10, 20]) }), Int: () => 12, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnListOfInt }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['returnListOfInt']).to.have.length.within(10, 20); expect(res.data['returnListOfInt'][0]).to.equal(12); }); @@ -1150,17 +1155,17 @@ describe('Mock', () => { const jsSchema = buildSchemaFromTypeDefinitions(shorthand); const mockMap = { RootQuery: () => ({ - returnListOfIntArg: (o: any, a: { [key: string]: any }) => + returnListOfIntArg: (_o: any, a: { [key: string]: any }) => new MockList(a['l']), }), Int: () => 12, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ l3: returnListOfIntArg(l: 3) l5: returnListOfIntArg(l: 5) }`; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data['l3'].length).to.equal(3); expect(res.data['l5'].length).to.equal(5); }); @@ -1174,22 +1179,23 @@ describe('Mock', () => { }), Int: () => 12, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnListOfInt }`; const expected = { returnListOfInt: [33, 33], }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); it('throws an error if the second argument to MockList is not a function', () => { - expect(() => new (MockList)(5, 'abc')).to.throw( - 'Second argument to MockList must be a function or undefined', - ); + expect( + () => + new MockList(5, ('abc' as unknown) as GraphQLFieldResolver), + ).to.throw('Second argument to MockList must be a function or undefined'); }); it('lets you nest MockList in MockList', () => { @@ -1200,14 +1206,17 @@ describe('Mock', () => { }), Int: () => 12, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnListOfListOfInt }`; const expected = { - returnListOfListOfInt: [[12, 12, 12], [12, 12, 12]], + returnListOfListOfInt: [ + [12, 12, 12], + [12, 12, 12], + ], }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1219,19 +1228,19 @@ describe('Mock', () => { returnListOfListOfIntArg: () => new MockList( 2, - (o: any, a: { [key: string]: any }) => new MockList(a['l']), + (_o: any, a: { [key: string]: any }) => new MockList(a['l']), ), }), Int: () => 12, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `{ returnListOfListOfIntArg(l: 1) }`; const expected = { returnListOfListOfIntArg: [[12], [12]], }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1273,16 +1282,16 @@ describe('Mock', () => { // unintuitive corner-cases const mockMap = { RootQuery: () => ({ - thread: (o: any, a: { [key: string]: any }) => ({ id: a['id'] }), - threads: (o: any, a: { [key: string]: any }) => + thread: (_o: any, a: { [key: string]: any }) => ({ id: a['id'] }), + threads: (_o: any, a: { [key: string]: any }) => new MockList(ITEMS_PER_PAGE * a['num']), }), Thread: () => ({ name: 'Lorem Ipsum', - posts: (o: any, a: { [key: string]: any }) => + posts: (_o: any, a: { [key: string]: any }) => new MockList( ITEMS_PER_PAGE * a['num'], - (oi: any, ai: { [key: string]: any }) => ({ id: ai['num'] }), + (_oi: any, ai: { [key: string]: any }) => ({ id: ai['num'] }), ), }), Post: () => ({ @@ -1291,7 +1300,7 @@ describe('Mock', () => { }), Int: () => 123, }; - addMockFunctionsToSchema({ schema: jsSchema, mocks: mockMap }); + addMocksToSchema({ schema: jsSchema, mocks: mockMap }); const testQuery = `query abc{ thread(id: "67"){ id @@ -1314,22 +1323,22 @@ describe('Mock', () => { ], }, }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { expect(res.data).to.deep.equal(expected); }); }); it('works for resolvers returning javascript Dates', () => { const typeDefs = ` - scalar Date + scalar Date type DateObject { start: Date! } type Query { - date1: DateObject - date2: Date + date1: DateObject + date2: Date date3: Date } `; @@ -1356,7 +1365,7 @@ describe('Mock', () => { resolvers, }); - addMockFunctionsToSchema({ + addMocksToSchema({ schema, mocks: { Date: () => new Date('2016-05-04'), @@ -1381,7 +1390,7 @@ describe('Mock', () => { date2: '2016-01-01T00:00:00.000Z', date3: '2016-05-04T00:00:00.000Z', }; - return graphql(schema, query).then(res => { + return graphql(schema, query).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -1398,9 +1407,9 @@ describe('Mock', () => { } const typeDefs = ` - interface Node { - id: ID! - } + interface Node { + id: ID! + } type Account implements Node { id: ID! @@ -1418,17 +1427,15 @@ describe('Mock', () => { const resolvers = { Query: { - node: () => { - return new Account(); - }, + node: () => new Account(), }, Node: { __resolveType: (obj: any) => { if (obj instanceof Account) { return 'Account'; - } else { - return null; } + + return null; }, }, }; @@ -1438,7 +1445,7 @@ describe('Mock', () => { resolvers, }); - addMockFunctionsToSchema({ + addMocksToSchema({ schema, preserveResolvers: true, }); @@ -1462,7 +1469,7 @@ describe('Mock', () => { }, }, }; - return graphql(schema, query).then(res => { + return graphql(schema, query).then((res) => { expect(res).to.deep.equal(expected); }); }); diff --git a/src/test/testResolution.ts b/src/test/testResolution.ts index 473b8e4f766..74dc8278f94 100644 --- a/src/test/testResolution.ts +++ b/src/test/testResolution.ts @@ -1,11 +1,12 @@ import { assert } from 'chai'; -import { makeExecutableSchema, addSchemaLevelResolveFunction } from '..'; import { parse, graphql, subscribe, ExecutionResult } from 'graphql'; import { PubSub } from 'graphql-subscriptions'; import { forAwaitEach } from 'iterall'; +import { makeExecutableSchema, addSchemaLevelResolver } from '..'; + describe('Resolve', () => { - describe('addSchemaLevelResolveFunction', () => { + describe('addSchemaLevelResolver', () => { const pubsub = new PubSub(); const typeDefs = ` type RootQuery { @@ -43,14 +44,14 @@ describe('Resolve', () => { }, }; const schema = makeExecutableSchema({ typeDefs, resolvers }); - let schemaLevelResolveFunctionCalls = 0; - addSchemaLevelResolveFunction(schema, root => { - schemaLevelResolveFunctionCalls += 1; + let schemaLevelResolverCalls = 0; + addSchemaLevelResolver(schema, (root) => { + schemaLevelResolverCalls += 1; return root; }); it('should run the schema level resolver once in a same query', () => { - schemaLevelResolveFunctionCalls = 0; + schemaLevelResolverCalls = 0; const root = 'queryRoot'; return graphql( schema, @@ -66,19 +67,19 @@ describe('Resolve', () => { printRoot: root, printRootAgain: root, }); - assert.equal(schemaLevelResolveFunctionCalls, 1); + assert.equal(schemaLevelResolverCalls, 1); }); }); - it('should isolate roots from the different operation types', done => { - schemaLevelResolveFunctionCalls = 0; + it('should isolate roots from the different operation types', (done) => { + schemaLevelResolverCalls = 0; const queryRoot = 'queryRoot'; const mutationRoot = 'mutationRoot'; const subscriptionRoot = 'subscriptionRoot'; const subscriptionRoot2 = 'subscriptionRoot2'; let subsCbkCalls = 0; - const firstSubsTriggered = new Promise(resolveFirst => { + const firstSubsTriggered = new Promise((resolveFirst) => { subscribe( schema, parse(` @@ -87,14 +88,16 @@ describe('Resolve', () => { } `), ) - .then(results => { + .then((results) => { forAwaitEach( results as AsyncIterable, (result: ExecutionResult) => { - if (result.errors) { + if (result.errors != null) { return done( new Error( - `Unexpected errors in GraphQL result: ${result.errors}`, + `Unexpected errors in GraphQL result: ${JSON.stringify( + result.errors, + )}`, ), ); } @@ -103,11 +106,11 @@ describe('Resolve', () => { subsCbkCalls++; try { if (subsCbkCalls === 1) { - assert.equal(schemaLevelResolveFunctionCalls, 1); + assert.equal(schemaLevelResolverCalls, 1); assert.deepEqual(subsData, { printRoot: subscriptionRoot }); return resolveFirst(); } else if (subsCbkCalls === 2) { - assert.equal(schemaLevelResolveFunctionCalls, 4); + assert.equal(schemaLevelResolverCalls, 4); assert.deepEqual(subsData, { printRoot: subscriptionRoot2, }); @@ -120,13 +123,12 @@ describe('Resolve', () => { }, ).catch(done); }) + .then(() => + pubsub.publish('printRootChannel', { printRoot: subscriptionRoot }), + ) .catch(done); }); - setTimeout(() => { - pubsub.publish('printRootChannel', { printRoot: subscriptionRoot }); - }); - firstSubsTriggered .then(() => graphql( @@ -140,7 +142,7 @@ describe('Resolve', () => { ), ) .then(({ data }) => { - assert.equal(schemaLevelResolveFunctionCalls, 2); + assert.equal(schemaLevelResolverCalls, 2); assert.deepEqual(data, { printRoot: queryRoot }); return graphql( schema, @@ -153,9 +155,11 @@ describe('Resolve', () => { ); }) .then(({ data: mutationData }) => { - assert.equal(schemaLevelResolveFunctionCalls, 3); + assert.equal(schemaLevelResolverCalls, 3); assert.deepEqual(mutationData, { printRoot: mutationRoot }); - pubsub.publish('printRootChannel', { printRoot: subscriptionRoot2 }); + return pubsub.publish('printRootChannel', { + printRoot: subscriptionRoot2, + }); }) .catch(done); }); diff --git a/src/test/testSchemaGenerator.ts b/src/test/testSchemaGenerator.ts index f77ac22e374..76d033a9ba8 100644 --- a/src/test/testSchemaGenerator.ts +++ b/src/test/testSchemaGenerator.ts @@ -1,6 +1,7 @@ // TODO: reduce code repetition in this file. // see https://github.com/apollostack/graphql-tools/issues/26 +import { GraphQLJSON } from 'graphql-type-json'; import { assert, expect } from 'chai'; import { graphql, @@ -12,30 +13,38 @@ import { ExecutionResult, GraphQLError, GraphQLEnumType, + execute, + VariableDefinitionNode, + DocumentNode, + GraphQLBoolean, + graphqlSync, + GraphQLSchema, } from 'graphql'; -// import { printSchema } from 'graphql'; -const GraphQLJSON = require('graphql-type-json'); -import { Logger } from '../Logger'; -import TypeA from './circularSchemaA'; + +import { Logger } from '../generate/Logger'; import { makeExecutableSchema, SchemaError, addErrorLoggingToSchema, - addSchemaLevelResolveFunction, + addSchemaLevelResolver, attachConnectorsToContext, attachDirectiveResolvers, chainResolvers, concatenateTypeDefs, -} from '../makeExecutableSchema'; + addResolversToSchema, +} from '../generate/index'; import { IResolverValidationOptions, IResolvers, - IExecutableSchemaDefinition, IDirectiveResolvers, NextResolverFn, + VisitSchemaKind, + ITypeDefinitions, + ILogger, } from '../Interfaces'; -import 'mocha'; -import { VisitSchemaKind, visitSchema } from '../transforms/visitSchema'; +import { visitSchema, graphqlVersion } from '../utils/index'; + +import TypeA from './circularSchemaA'; interface Bird { name: string; @@ -43,10 +52,12 @@ interface Bird { } function expectWarning(fn: () => void, warnMatcher?: string) { - let originalWarn = console.warn; + // eslint-disable-next-line no-console + const originalWarn = console.warn; let warning: string = null; try { + // eslint-disable-next-line no-console console.warn = function warn(message: string) { warning = message; }; @@ -59,6 +70,7 @@ function expectWarning(fn: () => void, warnMatcher?: string) { expect(warning).to.contain(warnMatcher); } } finally { + // eslint-disable-next-line no-console console.warn = originalWarn; } } @@ -78,12 +90,14 @@ const testSchema = ` const testResolvers = { __schema: () => ({ stuff: 'stuff', species: 'ROOT' }), RootQuery: { - usecontext: (r: any, a: { [key: string]: any }, ctx: any) => ctx.usecontext, - useTestConnector: (r: any, a: { [key: string]: any }, ctx: any) => + usecontext: (_r: any, _a: { [key: string]: any }, ctx: any) => + ctx.usecontext, + useTestConnector: (_r: any, _a: { [key: string]: any }, ctx: any) => ctx.connectors.TestConnector.get(), - useContextConnector: (r: any, a: { [key: string]: any }, ctx: any) => + useContextConnector: (_r: any, _a: { [key: string]: any }, ctx: any) => ctx.connectors.ContextConnector.get(), - species: (root: any, { name }: { name: string }) => root.species + name, + species: (root: any, { name }: { name: string }) => + (root.species as string) + name, }, }; class TestConnector { @@ -93,11 +107,12 @@ class TestConnector { } class ContextConnector { - private str: string; + private readonly str: string; constructor(ctx: any) { this.str = ctx.str; } + public get() { return this.str; } @@ -109,47 +124,54 @@ const testConnectors = { describe('generating schema from shorthand', () => { it('throws an error if no schema is provided', () => { - expect(() => (makeExecutableSchema)()).to.throw('undefined'); + expect(() => makeExecutableSchema(undefined)).to.throw('undefined'); }); it('throws an error if typeDefinitionNodes are not provided', () => { expect(() => - (makeExecutableSchema)({ typeDefs: undefined, resolvers: {} }), + makeExecutableSchema({ typeDefs: undefined, resolvers: {} }), ).to.throw('Must provide typeDefs'); }); it('throws an error if no resolveFunctions are provided', () => { expect(() => - (makeExecutableSchema)({ typeDefs: 'blah', resolvers: {} }), + makeExecutableSchema({ typeDefs: 'blah', resolvers: {} }), ).to.throw(GraphQLError); }); it('throws an error if typeDefinitionNodes is neither string nor array nor schema AST', () => { expect(() => - (makeExecutableSchema)({ typeDefs: {}, resolvers: {} }), + makeExecutableSchema({ + typeDefs: ({} as unknown) as ITypeDefinitions, + resolvers: {}, + }), ).to.throw('typeDefs must be a string, array or schema AST, got object'); }); it('throws an error if typeDefinitionNode array contains not only functions and strings', () => { expect(() => - (makeExecutableSchema)({ typeDefs: [17], resolvers: {} }), + makeExecutableSchema({ + typeDefs: ([17] as unknown) as ITypeDefinitions, + resolvers: {}, + }), ).to.throw( 'typeDef array must contain only strings and functions, got number', ); }); it('throws an error if resolverValidationOptions is not an object', () => { - expect(() => - makeExecutableSchema({ - typeDefs: 'blah', - resolvers: {}, - resolverValidationOptions: 'string', - } as IExecutableSchemaDefinition), - ).to.throw('Expected `resolverValidationOptions` to be an object'); + const options = { + typeDefs: 'blah', + resolvers: {}, + resolverValidationOptions: ('string' as unknown) as IResolverValidationOptions, + }; + expect(() => makeExecutableSchema(options)).to.throw( + 'Expected `resolverValidationOptions` to be an object', + ); }); it('can generate a schema', () => { - let shorthand = ` + const shorthand = ` """ A bird species """ @@ -166,14 +188,6 @@ describe('generating schema from shorthand', () => { } `; - const resolve = { - RootQuery: { - species() { - return; - }, - }, - }; - const introspectionQuery = `{ species: __type(name: "BirdSpecies"){ name, @@ -225,7 +239,7 @@ describe('generating schema from shorthand', () => { name: 'name', type: { kind: 'NON_NULL', - name: null, + name: null as string, ofType: { name: 'String', }, @@ -243,13 +257,13 @@ describe('generating schema from shorthand', () => { }, query: { name: 'RootQuery', - description: '', + description: graphqlVersion() >= 15 ? (null as string) : '', fields: [ { name: 'species', type: { kind: 'LIST', - name: null, + name: null as string, ofType: { name: 'BirdSpecies', }, @@ -258,7 +272,7 @@ describe('generating schema from shorthand', () => { { name: 'name', type: { - name: null, + name: null as string, kind: 'NON_NULL', ofType: { name: 'String', @@ -274,10 +288,10 @@ describe('generating schema from shorthand', () => { const jsSchema = makeExecutableSchema({ typeDefs: shorthand, - resolvers: resolve, + resolvers: {}, }); const resultPromise = graphql(jsSchema, introspectionQuery); - return resultPromise.then(result => + return resultPromise.then((result) => assert.deepEqual(result, solution as ExecutionResult), ); }); @@ -374,7 +388,7 @@ describe('generating schema from shorthand', () => { const typeD = { typeDefs: () => ['type TypeD { bar: String }'] }; function combineTypeDefs(...args: Array): any { - return { typeDefs: () => args.map(o => o.typeDefs) }; + return { typeDefs: () => args.map((o) => o.typeDefs) }; } const combinedAandB = combineTypeDefs(typeA, typeB); @@ -443,7 +457,7 @@ describe('generating schema from shorthand', () => { expect(jsSchema.getQueryType().name).to.equal('Query'); }); - it('can generate a schema with resolve functions', () => { + it('can generate a schema with resolvers', () => { const shorthand = ` type BirdSpecies { name: String!, @@ -459,7 +473,7 @@ describe('generating schema from shorthand', () => { const resolveFunctions = { RootQuery: { - species: (root: any, { name }: { name: string }) => [ + species: (_root: any, { name }: { name: string }) => [ { name: `Hello ${name}!`, wingspan: 200, @@ -490,7 +504,7 @@ describe('generating schema from shorthand', () => { resolvers: resolveFunctions, }); const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => + return resultPromise.then((result) => assert.deepEqual(result, solution as ExecutionResult), ); }); @@ -514,7 +528,7 @@ describe('generating schema from shorthand', () => { const resolveFunctions = { RootQuery: { - species: (root: any, { name }: { name: string }) => [ + species: (_root: any, { name }: { name: string }) => [ { name: `Hello ${name}!`, wingspan: 200, @@ -553,7 +567,7 @@ describe('generating schema from shorthand', () => { resolvers: resolveFunctions, }); const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => + return resultPromise.then((result) => assert.deepEqual(result, solution as ExecutionResult), ); }); @@ -580,7 +594,7 @@ describe('generating schema from shorthand', () => { const resolveFunctions = { RootQuery: { search: { - resolve(root: any, { name }: { name: string }) { + resolve(_root: any, { name }: { name: string }) { return [ { name: `Tom ${name}`, @@ -595,14 +609,13 @@ describe('generating schema from shorthand', () => { }, }, Searchable: { - __resolveType(data: any, context: any, info: GraphQLResolveInfo) { + __resolveType(data: any, _context: any, info: GraphQLResolveInfo) { if (data.age) { return info.schema.getType('Person'); } if (data.coordinates) { return info.schema.getType('Location'); } - console.error('no type!'); return null; }, }, @@ -641,7 +654,7 @@ describe('generating schema from shorthand', () => { resolvers: resolveFunctions, }); const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => + return resultPromise.then((result) => assert.deepEqual(result, solution as ExecutionResult), ); }); @@ -664,9 +677,9 @@ describe('generating schema from shorthand', () => { } `; - const resolveFunctions = { + const resolvers = { RootQuery: { - species: (root: any, { name }: { name: string }) => [ + species: (_root: any, { name }: { name: string }) => [ { name: `Hello ${name}!`, wingspan: 200, @@ -676,7 +689,7 @@ describe('generating schema from shorthand', () => { }, }; - const otherResolveFunctions = { + const otherResolvers = { BirdSpecies: { name: (bird: Bird) => bird.name, wingspan: (bird: Bird) => bird.wingspan, @@ -712,10 +725,10 @@ describe('generating schema from shorthand', () => { }; const jsSchema = makeExecutableSchema({ typeDefs: shorthand, - resolvers: [resolveFunctions, otherResolveFunctions], + resolvers: [resolvers, otherResolvers], }); const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => + return resultPromise.then((result) => assert.deepEqual(result, solution as ExecutionResult), ); }); @@ -749,6 +762,70 @@ describe('generating schema from shorthand', () => { expect(jsSchema.getType('JSON')['description']).to.have.length.above(0); }); + it('supports passing a default scalar type', () => { + const shorthand = ` + type Foo { + aField: Boolean + } + + type Query { + foo: Foo + } + `; + const resolveFunctions = { + Boolean: GraphQLBoolean, + }; + const jsSchema = makeExecutableSchema({ + typeDefs: shorthand, + resolvers: resolveFunctions, + }); + expect(jsSchema.getQueryType().name).to.equal('Query'); + expect(jsSchema.getType('Boolean')).to.equal(GraphQLBoolean); + }); + + it('allow overriding default scalar type fields', () => { + const originalSerialize = GraphQLBoolean.serialize; + const shorthand = ` + type Foo { + aField: Boolean + } + + type Query { + foo: Foo + } + `; + const resolveFunctions = { + Boolean: new GraphQLScalarType({ + name: 'Boolean', + serialize: () => false, + }), + Query: { + foo: () => ({ aField: true }), + }, + }; + const jsSchema = makeExecutableSchema({ + typeDefs: shorthand, + resolvers: resolveFunctions, + }); + const testQuery = ` + { + foo { + aField + } + } + `; + const result = graphqlSync(jsSchema, testQuery); + expect(result.data.foo.aField).to.equal(false); + addResolversToSchema({ + schema: jsSchema, + resolvers: { + Boolean: { + serialize: originalSerialize, + }, + }, + }); + }); + it('retains scalars after walking/recreating the schema', () => { const shorthand = ` scalar Test @@ -768,22 +845,22 @@ describe('generating schema from shorthand', () => { description: 'Test resolver', serialize(value) { if (typeof value !== 'string' || value.indexOf('scalar:') !== 0) { - return `scalar:${value}`; + return `scalar:${value as string}`; } return value; }, parseValue(value) { - return `scalar:${value}`; + return `scalar:${value as string}`; }, parseLiteral(ast: any) { switch (ast.kind) { case Kind.STRING: case Kind.INT: - return `scalar:${ast.value}`; + return `scalar:${ast.value as string}`; default: return null; } - } + }, }), Query: { testIn(_: any, { input }: any) { @@ -792,18 +869,23 @@ describe('generating schema from shorthand', () => { }, test() { return 42; - } - } + }, + }, }; - const walkedSchema = visitSchema(makeExecutableSchema({ - typeDefs: shorthand, - resolvers: resolveFunctions, - }), { - [VisitSchemaKind.ENUM_TYPE](type: GraphQLEnumType) { - return type; - } - }); - expect(walkedSchema.getType('Test')).to.be.an.instanceof(GraphQLScalarType); + const walkedSchema = visitSchema( + makeExecutableSchema({ + typeDefs: shorthand, + resolvers: resolveFunctions, + }), + { + [VisitSchemaKind.ENUM_TYPE](type: GraphQLEnumType) { + return type; + }, + }, + ); + expect(walkedSchema.getType('Test')).to.be.an.instanceof( + GraphQLScalarType, + ); expect(walkedSchema.getType('Test')) .to.have.property('description') .that.equals('Test resolver'); @@ -813,10 +895,12 @@ describe('generating schema from shorthand', () => { testIn(input: 1) }`; const resultPromise = graphql(walkedSchema, testQuery); - return resultPromise.then(result => expect(result.data).to.deep.equal({ - test: 'scalar:42', - testIn: 'scalar:1' - })); + return resultPromise.then((result) => + expect(result.data).to.deep.equal({ + test: 'scalar:42', + testIn: 'scalar:1', + }), + ); }); it('should support custom scalar usage on client-side query execution', () => { @@ -868,13 +952,11 @@ describe('generating schema from shorthand', () => { resolvers: resolveFunctions, }); const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => expect(result.errors).to.not.exist); + return resultPromise.then((result) => expect(result.errors).to.not.exist); }); it('should work with an Odd custom scalar type', () => { - const oddValue = (value: number) => { - return value % 2 === 1 ? value : null; - }; + const oddValue = (value: number) => (value % 2 === 1 ? value : null); const OddType = new GraphQLScalarType({ name: 'Odd', @@ -883,7 +965,7 @@ describe('generating schema from shorthand', () => { serialize: oddValue, parseLiteral(ast) { if (ast.kind === Kind.INT) { - const intValue: IntValueNode = ast; + const intValue: IntValueNode = ast; return oddValue(parseInt(intValue.value, 10)); } return null; @@ -923,8 +1005,8 @@ describe('generating schema from shorthand', () => { }; const jsSchema = makeExecutableSchema({ - typeDefs: typeDefs, - resolvers: resolvers, + typeDefs, + resolvers, }); const testQuery = ` { @@ -934,7 +1016,7 @@ describe('generating schema from shorthand', () => { } `; const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => { + return resultPromise.then((result) => { assert.equal(result.data['post'].something, testValue); assert.equal(result.errors, undefined); }); @@ -952,7 +1034,7 @@ describe('generating schema from shorthand', () => { }, parseLiteral(ast) { if (ast.kind === Kind.INT) { - const intValue: IntValueNode = ast; + const intValue: IntValueNode = ast; return parseInt(intValue.value, 10); } return null; @@ -993,8 +1075,8 @@ describe('generating schema from shorthand', () => { }; const jsSchema = makeExecutableSchema({ - typeDefs: typeDefs, - resolvers: resolvers, + typeDefs, + resolvers, }); const testQuery = ` { @@ -1004,7 +1086,7 @@ describe('generating schema from shorthand', () => { } `; const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => { + return resultPromise.then((result) => { assert.equal(result.data['post'].something, testDate.getTime()); assert.equal(result.errors, undefined); }); @@ -1108,7 +1190,7 @@ describe('generating schema from shorthand', () => { }); const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => { + return resultPromise.then((result) => { assert.equal(result.data['redColor'], 'RED'); assert.equal(result.data['blueColor'], 'BLUE'); assert.equal(result.data['numericEnum'], 'TEST'); @@ -1152,10 +1234,10 @@ describe('generating schema from shorthand', () => { TEST: 1, }, Query: { - colorTest(root: any, args: { color: string }) { + colorTest(_root: any, args: { color: string }) { return args.color; }, - numericTest(root: any, args: { num: number }) { + numericTest(_root: any, args: { num: number }) { return args.num; }, }, @@ -1167,7 +1249,7 @@ describe('generating schema from shorthand', () => { }); const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => { + return resultPromise.then((result) => { assert.equal(result.data['red'], resolveFunctions.Color.RED); assert.equal(result.data['blue'], resolveFunctions.Color.BLUE); assert.equal(result.data['num'], resolveFunctions.NumericEnum.TEST); @@ -1176,6 +1258,101 @@ describe('generating schema from shorthand', () => { }); }); + describe('default value support', () => { + it('supports default field values', () => { + const shorthand = ` + enum Color { + RED + } + + schema { + query: Query + } + + type Query { + colorTest(color: Color = RED): String + } + `; + + const testQuery = `{ + red: colorTest + }`; + + const resolveFunctions = { + Color: { + RED: '#EA3232', + }, + Query: { + colorTest(_root: any, args: { color: string }) { + return args.color; + }, + }, + }; + + const jsSchema = makeExecutableSchema({ + typeDefs: shorthand, + resolvers: resolveFunctions, + }); + + const resultPromise = graphql(jsSchema, testQuery); + return resultPromise.then((result) => { + assert.equal(result.data['red'], resolveFunctions.Color.RED); + assert.equal(result.errors, undefined); + }); + }); + + it('supports changing default field values', () => { + const shorthand = ` + enum Color { + RED + } + + schema { + query: Query + } + + type Query { + colorTest(color: Color = RED): String + } + `; + + const testQuery = `{ + red: colorTest + }`; + + const resolveFunctions = { + Color: { + RED: '#EA3232', + }, + Query: { + colorTest(_root: any, args: { color: string }) { + return args.color; + }, + }, + }; + + const jsSchema = makeExecutableSchema({ + typeDefs: shorthand, + resolvers: resolveFunctions, + }); + + addResolversToSchema({ + schema: jsSchema, + resolvers: { + Color: { + RED: 'override', + }, + }, + }); + + const resultPromise = graphql(jsSchema, testQuery); + return resultPromise.then((result) => { + assert.equal(result.data['red'], 'override'); + assert.equal(result.errors, undefined); + }); + }); + }); + it('can set description and deprecation reason', () => { const shorthand = ` type BirdSpecies { @@ -1195,7 +1372,7 @@ describe('generating schema from shorthand', () => { species: { description: 'A species', deprecationReason: 'Just because', - resolve: (root: any, { name }: { name: string }) => [ + resolve: (_root: any, { name }: { name: string }) => [ { name: `Hello ${name}!`, wingspan: 200, @@ -1236,12 +1413,12 @@ describe('generating schema from shorthand', () => { resolvers: resolveFunctions, }); const resultPromise = graphql(jsSchema, testQuery); - return resultPromise.then(result => + return resultPromise.then((result) => assert.deepEqual(result, solution as ExecutionResult), ); }); - it('shows a warning if a field has arguments but no resolve func', () => { + it('shows a warning if a field has arguments but no resolver', () => { const short = ` type Query{ bird(id: ID): String @@ -1260,10 +1437,9 @@ describe('generating schema from shorthand', () => { requireResolversForArgs: true, }, }); - }, 'Resolve function missing for "Query.bird"'); + }, 'Resolver missing for "Query.bird"'); }); - // tslint:disable-next-line: max-line-length it('does not throw an error if `resolverValidationOptions.requireResolversForArgs` is false', () => { const short = ` type Query{ @@ -1275,7 +1451,6 @@ describe('generating schema from shorthand', () => { const rf = { Query: {} }; - // tslint:disable-next-line: max-line-length assert.doesNotThrow( makeExecutableSchema.bind(null, { typeDefs: short, resolvers: rf }), SchemaError, @@ -1297,11 +1472,11 @@ describe('generating schema from shorthand', () => { makeExecutableSchema({ typeDefs: short, resolvers: rf, - } as IExecutableSchemaDefinition), + }), ).to.throw('Resolver Query.bird must be object or function'); }); - it('shows a warning if a field is not scalar, but has no resolve func', () => { + it('shows a warning if a field is not scalar, but has no resolver', () => { const short = ` type Bird{ id: ID @@ -1325,11 +1500,10 @@ describe('generating schema from shorthand', () => { resolvers: rf, resolverValidationOptions, }); - }, 'Resolve function missing for "Query.bird"'); + }, 'Resolver missing for "Query.bird"'); }); - // tslint:disable-next-line: max-line-length - it('allows non-scalar field to use default resolve func if `resolverValidationOptions.requireResolversForNonScalar` = false', () => { + it('allows non-scalar field to use default resolver if `resolverValidationOptions.requireResolversForNonScalar` = false', () => { const short = ` type Bird{ id: ID @@ -1343,7 +1517,6 @@ describe('generating schema from shorthand', () => { const rf = {}; - // tslint:disable-next-line: max-line-length assert.doesNotThrow( makeExecutableSchema.bind(null, { typeDefs: short, @@ -1381,7 +1554,7 @@ describe('generating schema from shorthand', () => { expect(() => makeExecutableSchema({ typeDefs: short, resolvers: rf }), - ).to.throw(`Searchable was defined in resolvers, but it's not an object`); + ).to.throw("Searchable was defined in resolvers, but it's not an object"); expect(() => makeExecutableSchema({ @@ -1416,7 +1589,7 @@ describe('generating schema from shorthand', () => { expect(() => makeExecutableSchema({ typeDefs: short, resolvers: rf }), - ).to.throw(`"Searchable" defined in resolvers, but not in schema`); + ).to.throw('"Searchable" defined in resolvers, but not in schema'); expect(() => makeExecutableSchema({ @@ -1450,8 +1623,8 @@ describe('generating schema from shorthand', () => { expect(() => makeExecutableSchema({ typeDefs: short, resolvers: rf }), ).to.throw( - `"Searchable" defined in resolvers, but has invalid value "undefined". A resolver's value ` + - `must be of type object or function.`, + '"Searchable" defined in resolvers, but has invalid value "undefined". A resolver\'s value ' + + 'must be of type object or function.', ); }); @@ -1477,7 +1650,7 @@ describe('generating schema from shorthand', () => { expect(() => makeExecutableSchema({ typeDefs: short, resolvers: rf }), - ).to.throw(`RootQuery.name defined in resolvers, but not in schema`); + ).to.throw('RootQuery.name defined in resolvers, but not in schema'); expect(() => makeExecutableSchema({ @@ -1523,7 +1696,7 @@ describe('generating schema from shorthand', () => { expect(() => makeExecutableSchema({ typeDefs: short, resolvers: rf }), ).to.throw( - `Color.NO_RESOLVER was defined in resolvers, but enum is not in schema`, + 'Color.NO_RESOLVER was defined in resolvers, but enum is not in schema', ); expect(() => @@ -1553,7 +1726,6 @@ describe('generating schema from shorthand', () => { function assertOptionsError( resolverValidationOptions: IResolverValidationOptions, ) { - // tslint:disable-next-line: max-line-length assert.throws( () => makeExecutableSchema({ @@ -1580,7 +1752,6 @@ describe('generating schema from shorthand', () => { }); }); - // tslint:disable-next-line: max-line-length it('throws for any missing field if `resolverValidationOptions.requireResolversForAllFields` = true', () => { const typeDefs = ` type Bird { @@ -1605,7 +1776,7 @@ describe('generating schema from shorthand', () => { }, errorMatcher); } - assertFieldError('Bird.id', {}); + assertFieldError(graphqlVersion() >= 15 ? 'Query.bird' : 'Bird.id', {}); assertFieldError('Query.bird', { Bird: { id: (bird: { id: string }) => bird.id, @@ -1618,7 +1789,6 @@ describe('generating schema from shorthand', () => { }); }); - // tslint:disable-next-line: max-line-length it('does not throw if all fields are satisfied when `resolverValidationOptions.requireResolversForAllFields` = true', () => { const typeDefs = ` type Bird { @@ -1640,7 +1810,6 @@ describe('generating schema from shorthand', () => { }, }; - // tslint:disable-next-line: max-line-length assert.doesNotThrow(() => makeExecutableSchema({ typeDefs, @@ -1650,7 +1819,7 @@ describe('generating schema from shorthand', () => { ); }); - it('throws an error if a resolve field cannot be used', done => { + it('throws an error if a resolve field cannot be used', (done) => { const shorthand = ` type BirdSpecies { name: String!, @@ -1666,7 +1835,7 @@ describe('generating schema from shorthand', () => { const resolveFunctions = { RootQuery: { - speciez: (root: any, { name }: { name: string }) => [ + speciez: (_root: any, { name }: { name: string }) => [ { name: `Hello ${name}!`, wingspan: 200, @@ -1682,7 +1851,7 @@ describe('generating schema from shorthand', () => { ).to.throw('RootQuery.speciez defined in resolvers, but not in schema'); done(); }); - it('throws an error if a resolve type is not in schema', done => { + it('throws an error if a resolve type is not in schema', (done) => { const shorthand = ` type BirdSpecies { name: String!, @@ -1697,7 +1866,7 @@ describe('generating schema from shorthand', () => { `; const resolveFunctions = { BootQuery: { - species: (root: any, { name }: { name: string }) => [ + species: (_root: any, { name }: { name: string }) => [ { name: `Hello ${name}!`, wingspan: 200, @@ -1715,8 +1884,8 @@ describe('generating schema from shorthand', () => { }); }); -describe('providing useful errors from resolve functions', () => { - it('logs an error if a resolve function fails', () => { +describe('providing useful errors from resolvers', () => { + it('logs an error if a resolver fails', () => { const shorthand = ` type RootQuery { species(name: String): String @@ -1743,7 +1912,7 @@ describe('providing useful errors from resolve functions', () => { }); const testQuery = '{ species }'; const expected = 'Error in resolver RootQuery.species\noops!'; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((_res) => { assert.equal(logger.errors.length, 1); assert.equal(logger.errors[0].message, expected); }); @@ -1774,9 +1943,9 @@ describe('providing useful errors from resolve functions', () => { allowUndefinedInResolve: false, }); const testQuery = '{ species, stuff }'; - const expectedErr = /Resolve function for "RootQuery.species" returned undefined/; - const expectedResData = { species: null, stuff: 'stuff' }; - return graphql(jsSchema, testQuery).then(res => { + const expectedErr = /Resolver for "RootQuery.species" returned undefined/; + const expectedResData = { species: null as string, stuff: 'stuff' }; + return graphql(jsSchema, testQuery).then((res) => { assert.equal(logger.errors.length, 1); assert.match(logger.errors[0].message, expectedErr); assert.deepEqual(res.data, expectedResData); @@ -1797,7 +1966,7 @@ describe('providing useful errors from resolve functions', () => { `; const resolve = { RootQuery: { - thread(root: any, args: { [key: string]: any }) { + thread(_root: any, args: { [key: string]: any }) { return args; }, }, @@ -1818,7 +1987,7 @@ describe('providing useful errors from resolve functions', () => { name: 'SomeThread', }, }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { assert.deepEqual(res.data, expectedResData); }); }); @@ -1837,7 +2006,7 @@ describe('providing useful errors from resolve functions', () => { `; const resolve = { RootQuery: { - thread(root: any, args: { [key: string]: any }) { + thread(_root: any, _args: { [key: string]: any }) { return { name: (): any => undefined }; }, }, @@ -1853,9 +2022,9 @@ describe('providing useful errors from resolve functions', () => { name } }`; - return graphql(jsSchema, testQuery).then(res => { - expect((res.errors[0]).originalError.message).to.equal( - 'Resolve function for "Thread.name" returned undefined', + return graphql(jsSchema, testQuery).then((res) => { + expect(res.errors[0].originalError.message).to.equal( + 'Resolver for "Thread.name" returned undefined', ); }); }); @@ -1874,7 +2043,7 @@ describe('providing useful errors from resolve functions', () => { `; const resolve = { RootQuery: { - thread(root: any, args: { [key: string]: any }) { + thread(_root: any, args: { [key: string]: any }) { return { name: () => args['name'] }; }, }, @@ -1895,12 +2064,12 @@ describe('providing useful errors from resolve functions', () => { name: 'SomeThread', }, }; - return graphql(jsSchema, testQuery).then(res => { + return graphql(jsSchema, testQuery).then((res) => { assert.deepEqual(res.data, expectedResData); }); }); - it('will not throw errors on undefined by default', done => { + it('will not throw errors on undefined by default', () => { const shorthand = ` type RootQuery { species(name: String): String @@ -1924,11 +2093,10 @@ describe('providing useful errors from resolve functions', () => { logger, }); const testQuery = '{ species, stuff }'; - const expectedResData = { species: null, stuff: 'stuff' }; - graphql(jsSchema, testQuery).then(res => { + const expectedResData = { species: null as string, stuff: 'stuff' }; + return graphql(jsSchema, testQuery).then((res) => { assert.equal(logger.errors.length, 0); assert.deepEqual(res.data, expectedResData); - done(); }); }); }); @@ -1936,31 +2104,35 @@ describe('providing useful errors from resolve functions', () => { describe('Add error logging to schema', () => { it('throws an error if no logger is provided', () => { assert.throw( - () => (addErrorLoggingToSchema)({}), + () => addErrorLoggingToSchema(({} as unknown) as GraphQLSchema), 'Must provide a logger', ); }); it('throws an error if logger.log is not a function', () => { assert.throw( - () => (addErrorLoggingToSchema)({}, { log: '1' }), + () => + addErrorLoggingToSchema( + ({} as unknown) as GraphQLSchema, + ({ log: '1' } as unknown) as ILogger, + ), 'Logger.log must be a function', ); }); }); describe('Attaching connectors to schema', () => { - describe('Schema level resolve function', () => { + describe('Schema level resolver', () => { it('actually runs', () => { const jsSchema = makeExecutableSchema({ typeDefs: testSchema, resolvers: testResolvers, }); const rootResolver = () => ({ species: 'ROOT' }); - addSchemaLevelResolveFunction(jsSchema, rootResolver); + addSchemaLevelResolver(jsSchema, rootResolver); const query = `{ species(name: "strix") }`; - return graphql(jsSchema, query).then(res => { + return graphql(jsSchema, query).then((res) => { expect(res.data['species']).to.equal('ROOTstrix'); }); }); @@ -1971,11 +2143,11 @@ describe('Attaching connectors to schema', () => { resolvers: testResolvers, }); const rootResolver = () => ({ stuff: 'stuff' }); - addSchemaLevelResolveFunction(jsSchema, rootResolver); + addSchemaLevelResolver(jsSchema, rootResolver); const query = `{ stuff }`; - return graphql(jsSchema, query).then(res => { + return graphql(jsSchema, query).then((res) => { expect(res.data['stuff']).to.equal('stuff'); }); }); @@ -1983,14 +2155,17 @@ describe('Attaching connectors to schema', () => { it('runs only once per query', () => { const simpleResolvers = { RootQuery: { - usecontext: (r: any, a: { [key: string]: any }, ctx: any) => + usecontext: (_r: any, _a: { [key: string]: any }, ctx: any) => ctx.usecontext, - useTestConnector: (r: any, a: { [key: string]: any }, ctx: any) => + useTestConnector: (_r: any, _a: { [key: string]: any }, ctx: any) => ctx.connectors.TestConnector.get(), - useContextConnector: (r: any, a: { [key: string]: any }, ctx: any) => - ctx.connectors.ContextConnector.get(), + useContextConnector: ( + _r: any, + _a: { [key: string]: any }, + ctx: any, + ) => ctx.connectors.ContextConnector.get(), species: (root: any, { name }: { name: string }) => - root.species + name, + (root.species as string) + name, }, }; const jsSchema = makeExecutableSchema({ @@ -2005,7 +2180,7 @@ describe('Attaching connectors to schema', () => { } return { stuff: 'EEE', species: 'EEE' }; }; - addSchemaLevelResolveFunction(jsSchema, rootResolver); + addSchemaLevelResolver(jsSchema, rootResolver); const query = `{ species(name: "strix") stuff @@ -2014,7 +2189,7 @@ describe('Attaching connectors to schema', () => { species: 'some strix', stuff: 'stuff', }; - return graphql(jsSchema, query).then(res => { + return graphql(jsSchema, query).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2022,14 +2197,17 @@ describe('Attaching connectors to schema', () => { it('runs twice for two queries', () => { const simpleResolvers = { RootQuery: { - usecontext: (r: any, a: { [key: string]: any }, ctx: any) => + usecontext: (_r: any, _a: { [key: string]: any }, ctx: any) => ctx.usecontext, - useTestConnector: (r: any, a: { [key: string]: any }, ctx: any) => + useTestConnector: (_r: any, _a: { [key: string]: any }, ctx: any) => ctx.connectors.TestConnector.get(), - useContextConnector: (r: any, a: { [key: string]: any }, ctx: any) => - ctx.connectors.ContextConnector.get(), + useContextConnector: ( + _r: any, + _a: { [key: string]: any }, + ctx: any, + ) => ctx.connectors.ContextConnector.get(), species: (root: any, { name }: { name: string }) => - root.species + name, + (root.species as string) + name, }, }; const jsSchema = makeExecutableSchema({ @@ -2048,7 +2226,7 @@ describe('Attaching connectors to schema', () => { } return { stuff: 'EEE', species: 'EEE' }; }; - addSchemaLevelResolveFunction(jsSchema, rootResolver); + addSchemaLevelResolver(jsSchema, rootResolver); const query = `{ species(name: "strix") stuff @@ -2061,9 +2239,9 @@ describe('Attaching connectors to schema', () => { species: 'species2 strix', stuff: 'stuff2', }; - return graphql(jsSchema, query).then(res => { + return graphql(jsSchema, query).then((res) => { expect(res.data).to.deep.equal(expected); - return graphql(jsSchema, query).then(res2 => + return graphql(jsSchema, query).then((res2) => expect(res2.data).to.deep.equal(expected2), ); }); @@ -2074,17 +2252,17 @@ describe('Attaching connectors to schema', () => { typeDefs: testSchema, resolvers: testResolvers, }); - const rootResolver = (o: any, a: { [key: string]: any }, ctx: any) => { + const rootResolver = (_o: any, _a: { [key: string]: any }, ctx: any) => { ctx['usecontext'] = 'ABC'; }; - addSchemaLevelResolveFunction(jsSchema, rootResolver); + addSchemaLevelResolver(jsSchema, rootResolver); const query = `{ usecontext }`; const expected = { usecontext: 'ABC', }; - return graphql(jsSchema, query, {}, {}).then(res => { + return graphql(jsSchema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2092,12 +2270,12 @@ describe('Attaching connectors to schema', () => { it('can attach with existing static connectors', () => { const resolvers = { RootQuery: { - testString(root: any, args: { [key: string]: any }, ctx: any) { + testString(_root: any, _args: { [key: string]: any }, ctx: any) { return ctx.connectors.staticString; }, }, }; - const typeDef = ` + const typeDefs = ` type RootQuery { testString: String } @@ -2107,8 +2285,8 @@ describe('Attaching connectors to schema', () => { } `; const jsSchema = makeExecutableSchema({ - typeDefs: typeDef, - resolvers: resolvers, + typeDefs, + resolvers, connectors: testConnectors, }); const query = `{ @@ -2126,7 +2304,7 @@ describe('Attaching connectors to schema', () => { staticString: 'Hi You!', }, }, - ).then(res => { + ).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2144,7 +2322,7 @@ describe('Attaching connectors to schema', () => { const expected = { useTestConnector: 'works', }; - return graphql(jsSchema, query, {}, {}).then(res => { + return graphql(jsSchema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2161,7 +2339,7 @@ describe('Attaching connectors to schema', () => { const expected = { useContextConnector: 'YOYO', }; - return graphql(jsSchema, query, {}, { str: 'YOYO' }).then(res => { + return graphql(jsSchema, query, {}, { str: 'YOYO' }).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2184,12 +2362,12 @@ describe('Attaching connectors to schema', () => { typeDefs: testSchema, resolvers: testResolvers, }); - (attachConnectorsToContext)(jsSchema, { someConnector: {} }); + attachConnectorsToContext(jsSchema, { someConnector: {} }); const query = `{ useTestConnector }`; - return graphql(jsSchema, query, {}, 'notObject').then(res => { - expect((res.errors[0]).originalError.message).to.equal( + return graphql(jsSchema, query, {}, 'notObject').then((res) => { + expect(res.errors[0].originalError.message).to.equal( 'Cannot attach connector because context is not an object: string', ); }); @@ -2200,26 +2378,26 @@ describe('Attaching connectors to schema', () => { typeDefs: testSchema, resolvers: testResolvers, }); - (attachConnectorsToContext)(jsSchema, { testString: 'a' }); + attachConnectorsToContext(jsSchema, { testString: 'a' }); const query = `{ species(name: "strix") stuff useTestConnector }`; - return graphql(jsSchema, query, undefined, {}).then(res => { - expect((res.errors[0]).originalError.message).to.equal( + return graphql(jsSchema, query, undefined, {}).then((res) => { + expect(res.errors[0].originalError.message).to.equal( 'Connector must be a function or an class', ); }); }); - it('does not interfere with schema level resolve function', () => { + it('does not interfere with schema level resolver', () => { const jsSchema = makeExecutableSchema({ typeDefs: testSchema, resolvers: testResolvers, }); const rootResolver = () => ({ stuff: 'stuff', species: 'ROOT' }); - addSchemaLevelResolveFunction(jsSchema, rootResolver); + addSchemaLevelResolver(jsSchema, rootResolver); attachConnectorsToContext(jsSchema, testConnectors); const query = `{ species(name: "strix") @@ -2231,7 +2409,7 @@ describe('Attaching connectors to schema', () => { stuff: 'stuff', useTestConnector: 'works', }; - return graphql(jsSchema, query, {}, {}).then(res => { + return graphql(jsSchema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); // TODO test schemaLevelResolve function with wrong arguments @@ -2239,14 +2417,14 @@ describe('Attaching connectors to schema', () => { // TODO test attachConnectors with wrong arguments it('throws error if no schema is passed', () => { - expect(() => (attachConnectorsToContext)()).to.throw( + expect(() => attachConnectorsToContext()).to.throw( 'schema must be an instance of GraphQLSchema. ' + 'This error could be caused by installing more than one version of GraphQL-JS', ); }); it('throws error if schema is not an instance of GraphQLSchema', () => { - expect(() => (attachConnectorsToContext)({})).to.throw( + expect(() => attachConnectorsToContext({})).to.throw( 'schema must be an instance of GraphQLSchema. ' + 'This error could be caused by installing more than one version of GraphQL-JS', ); @@ -2257,7 +2435,7 @@ describe('Attaching connectors to schema', () => { typeDefs: testSchema, resolvers: testResolvers, }); - expect(() => (attachConnectorsToContext)(jsSchema, [1])).to.throw( + expect(() => attachConnectorsToContext(jsSchema, [1])).to.throw( 'Expected connectors to be of type object, got Array', ); }); @@ -2277,9 +2455,9 @@ describe('Attaching connectors to schema', () => { typeDefs: testSchema, resolvers: testResolvers, }); - return expect(() => - (attachConnectorsToContext)(jsSchema, 'a'), - ).to.throw('Expected connectors to be of type object, got string'); + return expect(() => attachConnectorsToContext(jsSchema, 'a')).to.throw( + 'Expected connectors to be of type object, got string', + ); }); }); @@ -2302,7 +2480,7 @@ describe('Generating a full graphQL schema with resolvers and connectors', () => useTestConnector: 'works', usecontext: 'ABC', }; - return graphql(schema, query, {}, { usecontext: 'ABC' }).then(res => { + return graphql(schema, query, {}, { usecontext: 'ABC' }).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2318,17 +2496,16 @@ describe('chainResolvers', () => { }); it('uses default resolver when a resolver is undefined', () => { - const r1 = (root: any, { name }: { name: string }) => ({ + const r1 = (_root: any, { name }: { name: string }) => ({ person: { name }, }); const r3 = (root: any) => root['name']; const rChained = chainResolvers([r1, undefined, r3]); // faking the resolve info here. - expect( - rChained(0, { name: 'tony' }, null, { - fieldName: 'person', - } as GraphQLResolveInfo), - ).to.equals('tony'); + const info: GraphQLResolveInfo = ({ + fieldName: 'person', + } as unknown) as GraphQLResolveInfo; + expect(rChained(0, { name: 'tony' }, null, info)).to.equals('tony'); }); }); @@ -2363,7 +2540,7 @@ describe('attachDirectiveResolvers on field', () => { RootQuery: { hello: () => 'giau. tran minh', object: () => testObject, - asyncResolver: async () => 'giau. tran minh', + asyncResolver: async () => Promise.resolve('giau. tran minh'), multiDirectives: () => 'Giau. Tran Minh', throwError: () => { throw new Error('This error for testing'); @@ -2371,14 +2548,14 @@ describe('attachDirectiveResolvers on field', () => { }, }; - const directiveResolvers: IDirectiveResolvers = { + const directiveResolvers: IDirectiveResolvers = { lower( next: NextResolverFn, - src: any, - args: { [argName: string]: any }, - context: any, + _src: any, + _args: { [argName: string]: any }, + _context: any, ) { - return next().then(str => { + return next().then((str) => { if (typeof str === 'string') { return str.toLowerCase(); } @@ -2387,11 +2564,11 @@ describe('attachDirectiveResolvers on field', () => { }, upper( next: NextResolverFn, - src: any, - args: { [argName: string]: any }, - context: any, + _src: any, + _args: { [argName: string]: any }, + _context: any, ) { - return next().then(str => { + return next().then((str) => { if (typeof str === 'string') { return str.toUpperCase(); } @@ -2400,11 +2577,11 @@ describe('attachDirectiveResolvers on field', () => { }, default( next: NextResolverFn, - src: any, + _src: any, args: { [argName: string]: any }, - context: any, + _context: any, ) { - return next().then(res => { + return next().then((res) => { if (undefined === res) { return args.value; } @@ -2413,13 +2590,11 @@ describe('attachDirectiveResolvers on field', () => { }, catchError( next: NextResolverFn, - src: any, - args: { [argName: string]: any }, - context: any, + _src: any, + _args: { [argName: string]: any }, + _context: any, ) { - return next().catch(error => { - return error.message; - }); + return next().catch((error) => error.message); }, }; @@ -2428,9 +2603,11 @@ describe('attachDirectiveResolvers on field', () => { typeDefs: testSchema, resolvers: testResolvers, }); - expect(() => (attachDirectiveResolvers)(jsSchema, [1])).to.throw( - 'Expected directiveResolvers to be of type object, got Array', - ); + expect(() => + attachDirectiveResolvers(jsSchema, ([ + 1, + ] as unknown) as IDirectiveResolvers), + ).to.throw('Expected directiveResolvers to be of type object, got Array'); }); it('throws error if directiveResolvers argument is not an object', () => { @@ -2439,7 +2616,10 @@ describe('attachDirectiveResolvers on field', () => { resolvers: testResolvers, }); return expect(() => - (attachDirectiveResolvers)(jsSchema, 'a'), + attachDirectiveResolvers( + jsSchema, + ('a' as unknown) as IDirectiveResolvers, + ), ).to.throw('Expected directiveResolvers to be of type object, got string'); }); @@ -2447,7 +2627,7 @@ describe('attachDirectiveResolvers on field', () => { const schema = makeExecutableSchema({ typeDefs: testSchemaWithDirectives, resolvers: testResolversDirectives, - directiveResolvers: directiveResolvers, + directiveResolvers, }); const query = `{ hello @@ -2455,7 +2635,7 @@ describe('attachDirectiveResolvers on field', () => { const expected = { hello: 'GIAU. TRAN MINH', }; - return graphql(schema, query, {}, {}).then(res => { + return graphql(schema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2464,7 +2644,7 @@ describe('attachDirectiveResolvers on field', () => { const schema = makeExecutableSchema({ typeDefs: testSchemaWithDirectives, resolvers: testResolversDirectives, - directiveResolvers: directiveResolvers, + directiveResolvers, }); const query = `{ object { @@ -2476,7 +2656,7 @@ describe('attachDirectiveResolvers on field', () => { hello: 'GIAU. TRAN MINH', }, }; - return graphql(schema, query, {}, {}).then(res => { + return graphql(schema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2485,7 +2665,7 @@ describe('attachDirectiveResolvers on field', () => { const schema = makeExecutableSchema({ typeDefs: testSchemaWithDirectives, resolvers: testResolversDirectives, - directiveResolvers: directiveResolvers, + directiveResolvers, }); const query = `{ withDefault @@ -2493,7 +2673,7 @@ describe('attachDirectiveResolvers on field', () => { const expected = { withDefault: 'some default_value', }; - return graphql(schema, query, {}, {}).then(res => { + return graphql(schema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2510,7 +2690,7 @@ describe('attachDirectiveResolvers on field', () => { const expected = { hello: 'giau. tran minh', }; - return graphql(schema, query, {}, {}).then(res => { + return graphql(schema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2519,7 +2699,7 @@ describe('attachDirectiveResolvers on field', () => { const schema = makeExecutableSchema({ typeDefs: testSchemaWithDirectives, resolvers: testResolversDirectives, - directiveResolvers: directiveResolvers, + directiveResolvers, }); const query = `{ asyncResolver @@ -2527,7 +2707,7 @@ describe('attachDirectiveResolvers on field', () => { const expected = { asyncResolver: 'GIAU. TRAN MINH', }; - return graphql(schema, query, {}, {}).then(res => { + return graphql(schema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2536,7 +2716,7 @@ describe('attachDirectiveResolvers on field', () => { const schema = makeExecutableSchema({ typeDefs: testSchemaWithDirectives, resolvers: testResolversDirectives, - directiveResolvers: directiveResolvers, + directiveResolvers, }); const query = `{ multiDirectives @@ -2544,7 +2724,7 @@ describe('attachDirectiveResolvers on field', () => { const expected = { multiDirectives: 'giau. tran minh', }; - return graphql(schema, query, {}, {}).then(res => { + return graphql(schema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2553,7 +2733,7 @@ describe('attachDirectiveResolvers on field', () => { const schema = makeExecutableSchema({ typeDefs: testSchemaWithDirectives, resolvers: testResolversDirectives, - directiveResolvers: directiveResolvers, + directiveResolvers, }); const query = `{ throwError @@ -2561,7 +2741,7 @@ describe('attachDirectiveResolvers on field', () => { const expected = { throwError: 'This error for testing', }; - return graphql(schema, query, {}, {}).then(res => { + return graphql(schema, query, {}, {}).then((res) => { expect(res.data).to.deep.equal(expected); }); }); @@ -2587,32 +2767,16 @@ describe('can specify lexical parser options', () => { expect(schema.astNode.loc).to.equal(undefined); }); - xit("can specify 'experimentalFragmentVariables' option", () => { + it("can specify 'experimentalFragmentVariables' option", () => { const typeDefs = ` - type Hello { - world(phrase: String): String - } - - fragment hello($phrase: String = "world") on Hello { - world(phrase: $phrase) - } - - type RootQuery { - hello: Hello - } - - schema { - query: RootQuery + type Query { + version: Int } `; const resolvers = { - RootQuery: { - hello() { - return { - world: (phrase: string) => `hello ${phrase}`, - }; - }, + Query: { + version: () => 1, }, }; @@ -2626,6 +2790,67 @@ describe('can specify lexical parser options', () => { }); }).to.not.throw(); }); + + // Note that the experimentalFragmentVariables option requires a client side transform + // to hoist the parsed variables into queries, see https://github.com/graphql/graphql-js/pull/1141 + // and so this really has nothing to do with schema creation or execution. + it("can use 'experimentalFragmentVariables' option", async () => { + const typeDefs = ` + type Query { + hello(phrase: String): String + } + `; + + const resolvers = { + Query: { + hello: (_root: any, args: any) => `hello ${args.phrase as string}`, + }, + }; + + const jsSchema = makeExecutableSchema({ + typeDefs, + resolvers, + }); + + const query = ` + fragment Hello($phrase: String = "world") on Query { + hello(phrase: $phrase) + } + query { + ...Hello + } + `; + + const parsedQuery = parse(query, { experimentalFragmentVariables: true }); + + const hoist = (document: DocumentNode) => { + let variableDefs: Array = []; + + document.definitions.forEach((def) => { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + variableDefs = variableDefs.concat(def.variableDefinitions); + } + }); + + return { + kind: Kind.DOCUMENT, + definitions: parsedQuery.definitions.map((def) => ({ + ...def, + variableDefinitions: variableDefs, + })), + }; + }; + + const hoistedQuery = hoist(parsedQuery); + + const result = await execute(jsSchema, hoistedQuery); + expect(result.data).to.deep.equal({ hello: 'hello world' }); + + const result2 = await execute(jsSchema, hoistedQuery, null, null, { + phrase: 'world again!', + }); + expect(result2.data).to.deep.equal({ hello: 'hello world again!' }); + }); }); describe('interfaces', () => { @@ -2655,7 +2880,7 @@ describe('interfaces', () => { user { id name } }`; - it('throws if there is no interface resolveType resolver', async () => { + it('throws if there is no interface resolveType resolver', () => { const resolvers = { Query: queryResolver, }; @@ -2668,7 +2893,7 @@ describe('interfaces', () => { } catch (error) { assert.equal( error.message, - 'Type "Node" is missing a "resolveType" resolver', + 'Type "Node" is missing a "__resolveType" resolver. Pass false into "resolverValidationOptions.requireResolversForResolveType" to disable this error.', ); return; } @@ -2678,7 +2903,7 @@ describe('interfaces', () => { const resolvers = { Query: queryResolver, Node: { - __resolveType: ({ type }: { type: String }) => type, + __resolveType: ({ type }: { type: string }) => type, }, }; const schema = makeExecutableSchema({ @@ -2689,7 +2914,7 @@ describe('interfaces', () => { const response = await graphql(schema, query); assert.isUndefined(response.errors); }); - it('does not warn if requireResolversForResolveType is disabled and there are missing resolvers', async () => { + it('does not warn if requireResolversForResolveType is disabled and there are missing resolvers', () => { const resolvers = { Query: queryResolver, }; @@ -2722,7 +2947,7 @@ describe('interface resolver inheritance', () => { const resolvers = { Node: { __resolveType: ({ type }: { type: string }) => type, - id: ({ id }: { id: number }) => `Node:${id}`, + id: ({ id }: { id: number }) => `Node:${id.toString()}`, }, User: { name: ({ name }: { name: string }) => `User:${name}`, @@ -2740,13 +2965,13 @@ describe('interface resolver inheritance', () => { requireResolversForResolveType: true, }, }); - const query = `{ user { id name } }`; + const query = '{ user { id name } }'; const response = await graphql(schema, query); assert.deepEqual(response, { data: { user: { - id: `Node:1`, - name: `User:Ada`, + id: 'Node:1', + name: 'User:Ada', }, }, }); @@ -2782,11 +3007,11 @@ describe('interface resolver inheritance', () => { const resolvers = { Node: { __resolveType: ({ type }: { type: string }) => type, - id: ({ id }: { id: number }) => `Node:${id}`, + id: ({ id }: { id: number }) => `Node:${id.toString()}`, }, Person: { __resolveType: ({ type }: { type: string }) => type, - id: ({ id }: { id: number }) => `Person:${id}`, + id: ({ id }: { id: number }) => `Person:${id.toString()}`, name: ({ name }: { name: string }) => `Person:${name}`, }, Query: { @@ -2804,17 +3029,17 @@ describe('interface resolver inheritance', () => { requireResolversForResolveType: true, }, }); - const query = `{ cyborg { id name } replicant { id name }}`; + const query = '{ cyborg { id name } replicant { id name }}'; const response = await graphql(schema, query); assert.deepEqual(response, { data: { cyborg: { - id: `Node:1`, - name: `Person:Alex Murphy`, + id: 'Node:1', + name: 'Person:Alex Murphy', }, replicant: { - id: `Person:2`, - name: `Person:Rachael Tyrell`, + id: 'Person:2', + name: 'Person:Rachael Tyrell', }, }, }); @@ -2855,7 +3080,7 @@ describe('unions', () => { } }`; - it('throws if there is no union resolveType resolver', async () => { + it('throws if there is no union resolveType resolver', () => { const resolvers = { Query: queryResolver, }; @@ -2868,7 +3093,7 @@ describe('unions', () => { } catch (error) { assert.equal( error.message, - 'Type "Displayable" is missing a "resolveType" resolver', + 'Type "Displayable" is missing a "__resolveType" resolver. Pass false into "resolverValidationOptions.requireResolversForResolveType" to disable this error.', ); return; } @@ -2878,7 +3103,7 @@ describe('unions', () => { const resolvers = { Query: queryResolver, Displayable: { - __resolveType: ({ type }: { type: String }) => type, + __resolveType: ({ type }: { type: string }) => type, }, }; const schema = makeExecutableSchema({ @@ -2889,7 +3114,7 @@ describe('unions', () => { const response = await graphql(schema, query); assert.isUndefined(response.errors); }); - it('does not warn if requireResolversForResolveType is disabled', async () => { + it('does not warn if requireResolversForResolveType is disabled', () => { const resolvers = { Query: queryResolver, }; diff --git a/src/test/testStitchingFromSubschemas.ts b/src/test/testStitchingFromSubschemas.ts new file mode 100644 index 00000000000..df2ddd71885 --- /dev/null +++ b/src/test/testStitchingFromSubschemas.ts @@ -0,0 +1,144 @@ +// The below is meant to be an alternative canonical schema stitching example +// which intermingles local (mocked) resolvers and stitched schemas and does +// not require use of the fragment field, because it follows best practices of +// always returning the necessary object fields: +// https://medium.com/paypal-engineering/graphql-resolvers-best-practices-cd36fdbcef55 + +// This is achieved at the considerable cost of moving all of the delegation +// logic from the gateway to each subschema so that each subschema imports all +// the required types and performs all delegation. + +// The fragment field is still necessary when working with a remote schema +// where this is not possible. + +import { expect } from 'chai'; +import { graphql } from 'graphql'; + +import { delegateToSchema, mergeSchemas, addMocksToSchema } from '../index'; + +const chirpTypeDefs = ` + type Chirp { + id: ID! + text: String + authorId: ID! + author: User + } +`; + +const authorTypeDefs = ` + type User { + id: ID! + email: String + chirps: [Chirp] + } +`; + +const schemas = {}; +const getSchema = (name: string) => schemas[name]; + +const chirpSchema = mergeSchemas({ + schemas: [ + chirpTypeDefs, + authorTypeDefs, + ` + type Query { + chirpById(id: ID!): Chirp + chirpsByAuthorId(authorId: ID!): [Chirp] + } + `, + ], + resolvers: { + Chirp: { + author: (chirp, _args, context, info) => + delegateToSchema({ + schema: getSchema('authorSchema'), + operation: 'query', + fieldName: 'userById', + args: { + id: chirp.authorId, + }, + context, + info, + }), + }, + }, +}); + +addMocksToSchema({ + schema: chirpSchema, + mocks: { + Chirp: () => ({ + authorId: '1', + }), + }, + preserveResolvers: true, +}); + +const authorSchema = mergeSchemas({ + schemas: [ + chirpTypeDefs, + authorTypeDefs, + ` + type Query { + userById(id: ID!): User + } + `, + ], + resolvers: { + User: { + chirps: (user, _args, context, info) => + delegateToSchema({ + schema: getSchema('chirpSchema'), + operation: 'query', + fieldName: 'chirpsByAuthorId', + args: { + authorId: user.id, + }, + context, + info, + }), + }, + }, +}); + +addMocksToSchema({ + schema: authorSchema, + mocks: { + User: () => ({ + id: '1', + }), + }, + preserveResolvers: true, +}); + +schemas['chirpSchema'] = chirpSchema; +schemas['authorSchema'] = authorSchema; + +const mergedSchema = mergeSchemas({ + schemas: Object.keys(schemas).map((schemaName) => schemas[schemaName]), +}); + +describe('merging without specifying fragments', () => { + it('works', async () => { + const query = ` + query { + userById(id: 5) { + chirps { + id + textAlias: text + author { + email + } + } + } + } + `; + + const result = await graphql(mergedSchema, query); + + expect(result.errors).to.equal(undefined); + expect(result.data.userById.chirps[1].id).to.not.equal(null); + expect(result.data.userById.chirps[1].text).to.not.equal(null); + expect(result.data.userById.chirps[1].author.email).to.not.equal(null); + }); +}); diff --git a/src/test/testTransforms.ts b/src/test/testTransforms.ts index 5dc7c6d3ef4..21d50c3d60a 100644 --- a/src/test/testTransforms.ts +++ b/src/test/testTransforms.ts @@ -1,29 +1,129 @@ -/* tslint:disable:no-unused-expression */ - import { expect } from 'chai'; import { GraphQLSchema, GraphQLNamedType, + GraphQLScalarType, graphql, Kind, SelectionSetNode, print, parse, } from 'graphql'; -import { makeExecutableSchema } from '../makeExecutableSchema'; -import { propertySchema, bookingSchema } from './testingSchemas'; -import delegateToSchema from '../stitching/delegateToSchema'; + +import { delegateToSchema } from '../delegate/index'; +import { makeExecutableSchema } from '../generate/index'; +import { defaultMergedResolver, mergeSchemas } from '../stitch/index'; import { transformSchema, RenameTypes, + RenameRootTypes, FilterTypes, WrapQuery, ExtractField, ReplaceFieldWithFragment, FilterToSchema, -} from '../transforms'; + TransformQuery, + AddReplacementFragments, +} from '../wrap/index'; +import { + concatInlineFragments, + parseFragmentToInlineFragment, +} from '../utils/index'; +import { addMocksToSchema } from '../mock/index'; + +import { propertySchema, bookingSchema } from './testingSchemas'; describe('transforms', () => { + describe('base transform function', () => { + const scalarTest = ` + scalar TestScalar + type TestingScalar { + value: TestScalar + } + + type Query { + testingScalar(input: TestScalar): TestingScalar + } + `; + + const scalarSchema = makeExecutableSchema({ + typeDefs: scalarTest, + resolvers: { + TestScalar: new GraphQLScalarType({ + name: 'TestScalar', + description: undefined, + serialize: (value) => (value as string).slice(1), + parseValue: (value) => `_${value as string}`, + parseLiteral: (ast: any) => `_${ast.value as string}`, + }), + Query: { + testingScalar(_parent, args) { + return { + value: args.input[0] === '_' ? args.input : null, + }; + }, + }, + }, + }); + + it('should work', async () => { + const schema = transformSchema(scalarSchema, []); + const result = await graphql( + schema, + ` + query($input: TestScalar) { + testingScalar(input: $input) { + value + } + } + `, + {}, + {}, + { + input: 'test', + }, + ); + + expect(result).to.deep.equal({ + data: { + testingScalar: { + value: 'test', + }, + }, + }); + }); + + it('should work when specified as a schema configuration object', async () => { + const schema = transformSchema( + { schema: scalarSchema, transforms: [] }, + [], + ); + const result = await graphql( + schema, + ` + query($input: TestScalar) { + testingScalar(input: $input) { + value + } + } + `, + {}, + {}, + { + input: 'test', + }, + ); + + expect(result).to.deep.equal({ + data: { + testingScalar: { + value: 'test', + }, + }, + }); + }); + }); + describe('rename type', () => { let schema: GraphQLSchema; before(() => { @@ -86,11 +186,148 @@ describe('transforms', () => { }); }); + describe('rename root type', () => { + it('should work', async () => { + const subschema = makeExecutableSchema({ + typeDefs: ` + schema { + query: QueryRoot + mutation: MutationRoot + } + + type QueryRoot { + foo: String! + } + + type MutationRoot { + doSomething: DoSomethingPayload! + } + + type DoSomethingPayload { + query: QueryRoot! + } + `, + }); + + addMocksToSchema({ schema: subschema }); + + const schema = transformSchema(subschema, [ + new RenameRootTypes((name) => (name === 'QueryRoot' ? 'Query' : name)), + ]); + + const result = await graphql( + schema, + ` + mutation { + doSomething { + query { + foo + } + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + doSomething: { + query: { + foo: 'Hello World', + }, + }, + }, + }); + }); + + it('works with mergeSchemas', async () => { + const schemaWithCustomRootTypeNames = makeExecutableSchema({ + typeDefs: ` + schema { + query: QueryRoot + mutation: MutationRoot + } + + type QueryRoot { + foo: String! + } + + type MutationRoot { + doSomething: DoSomethingPayload! + } + + type DoSomethingPayload { + somethingChanged: Boolean! + query: QueryRoot! + } + `, + }); + + addMocksToSchema({ schema: schemaWithCustomRootTypeNames }); + + const schemaWithDefaultRootTypeNames = makeExecutableSchema({ + typeDefs: ` + type Query { + bar: String! + } + + type Mutation { + doSomethingElse: DoSomethingElsePayload! + } + + type DoSomethingElsePayload { + somethingElseChanged: Boolean! + query: Query! + } + `, + }); + + addMocksToSchema({ schema: schemaWithDefaultRootTypeNames }); + + const mergedSchema = mergeSchemas({ + subschemas: [ + schemaWithCustomRootTypeNames, + { + schema: schemaWithDefaultRootTypeNames, + transforms: [new RenameRootTypes((name) => `${name}Root`)], + }, + ], + queryTypeName: 'QueryRoot', + mutationTypeName: 'MutationRoot', + }); + + const result = await graphql( + mergedSchema, + ` + mutation { + doSomething { + query { + foo + bar + } + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + doSomething: { + query: { + foo: 'Hello World', + bar: 'Hello World', + }, + }, + }, + }); + }); + }); + describe('namespace', () => { let schema: GraphQLSchema; before(() => { const transforms = [ - new RenameTypes((name: string) => `Property_${name}`), + new RenameTypes((name: string) => `_${name}`), + new RenameTypes((name: string) => `Property${name}`), ]; schema = transformSchema(propertySchema, transforms); }); @@ -153,7 +390,7 @@ describe('transforms', () => { filter = new FilterToSchema(bookingSchema); }); - it('should remove empty selection sets on objects', async () => { + it('should remove empty selection sets on objects', () => { const query = parse(` query customerQuery($id: ID!) { customerById(id: $id) { @@ -168,8 +405,8 @@ describe('transforms', () => { const filteredQuery = filter.transformRequest({ document: query, variables: { - id: 'c1' - } + id: 'c1', + }, }); const expected = parse(` @@ -183,7 +420,7 @@ describe('transforms', () => { expect(print(filteredQuery.document)).to.equal(print(expected)); }); - it('should also remove variables when removing empty selection sets', async () => { + it('should also remove variables when removing empty selection sets', () => { const query = parse(` query customerQuery($id: ID!, $limit: Int) { customerById(id: $id) { @@ -199,8 +436,8 @@ describe('transforms', () => { document: query, variables: { id: 'c1', - limit: 10 - } + limit: 10, + }, }); const expected = parse(` @@ -214,7 +451,7 @@ describe('transforms', () => { expect(print(filteredQuery.document)).to.equal(print(expected)); }); - it('should remove empty selection sets on wrapped objects (non-nullable/lists)', async () => { + it('should remove empty selection sets on wrapped objects (non-nullable/lists)', () => { const query = parse(` query bookingQuery($id: ID!) { bookingById(id: $id) { @@ -229,8 +466,8 @@ describe('transforms', () => { const filteredQuery = filter.transformRequest({ document: query, variables: { - id: 'b1' - } + id: 'b1', + }, }); const expected = parse(` @@ -301,17 +538,17 @@ describe('transforms', () => { } `, ); - expect(result.errors).not.to.be.empty; + expect(result.errors).to.not.equal(undefined); expect(result.errors.length).to.equal(1); expect(result.errors[0].message).to.equal( - 'Cannot query field "customer" on type "Booking".' + 'Cannot query field "customer" on type "Booking".', ); }); }); describe('tree operations', () => { let data: any; - let subSchema: GraphQLSchema; + let subschema: GraphQLSchema; let schema: GraphQLSchema; before(() => { data = { @@ -332,7 +569,7 @@ describe('transforms', () => { }, }, }; - subSchema = makeExecutableSchema({ + subschema = makeExecutableSchema({ typeDefs: ` type User { id: ID! @@ -367,12 +604,12 @@ describe('transforms', () => { `, resolvers: { Query: { - userById(parent, { id }) { + userById(_parent, { id }) { return data[id]; }, }, Mutation: { - setUser(parent, { input }) { + setUser(_parent, { input }) { if (data[input.id]) { return { ...data[input.id], @@ -380,7 +617,7 @@ describe('transforms', () => { }; } }, - setAddress(parent, { input }) { + setAddress(_parent, { input }) { if (data[input.id]) { return { ...data[input.id].address, @@ -421,9 +658,9 @@ describe('transforms', () => { `, resolvers: { Query: { - addressByUser(parent, { id }, context, info) { + addressByUser(_parent, { id }, context, info) { return delegateToSchema({ - schema: subSchema, + schema: subschema, operation: 'query', fieldName: 'userById', args: { id }, @@ -446,16 +683,16 @@ describe('transforms', () => { selectionSet: subtree, }), // how to process the data result at path - result => result && result.address, + (result) => result?.address, ), ], }); }, }, Mutation: { - async setUserAndAddress(parent, { input }, context, info) { + async setUserAndAddress(_parent, { input }, context, info) { const addressResult = await delegateToSchema({ - schema: subSchema, + schema: subschema, operation: 'mutation', fieldName: 'setAddress', args: { @@ -477,7 +714,7 @@ describe('transforms', () => { ], }); const userResult = await delegateToSchema({ - schema: subSchema, + schema: subschema, operation: 'mutation', fieldName: 'setUser', args: { @@ -573,17 +810,17 @@ describe('transforms', () => { }); describe('WrapQuery', () => { let data: any; - let subSchema: GraphQLSchema; + let subschema: GraphQLSchema; let schema: GraphQLSchema; before(() => { data = { u1: { id: 'user1', addressStreetAddress: 'Windy Shore 21 A 7', - addressZip: '12345' - } + addressZip: '12345', + }, }; - subSchema = makeExecutableSchema({ + subschema = makeExecutableSchema({ typeDefs: ` type User { id: ID! @@ -597,10 +834,10 @@ describe('transforms', () => { `, resolvers: { Query: { - userById(parent, { id }) { + userById(_parent, { id }) { return data[id]; }, - } + }, }, }); schema = makeExecutableSchema({ @@ -621,9 +858,9 @@ describe('transforms', () => { `, resolvers: { Query: { - addressByUser(parent, { id }, context, info) { + addressByUser(_parent, { id }, context, info) { return delegateToSchema({ - schema: subSchema, + schema: subschema, operation: 'query', fieldName: 'userById', args: { id }, @@ -637,11 +874,13 @@ describe('transforms', () => { (subtree: SelectionSetNode) => { const newSelectionSet = { kind: Kind.SELECTION_SET, - selections: subtree.selections.map(selection => { + selections: subtree.selections.map((selection) => { // just append fragments, not interesting for this // test - if (selection.kind === Kind.INLINE_FRAGMENT || - selection.kind === Kind.FRAGMENT_SPREAD) { + if ( + selection.kind === Kind.INLINE_FRAGMENT || + selection.kind === Kind.FRAGMENT_SPREAD + ) { return selection; } // prepend `address` to name and camelCase @@ -650,27 +889,28 @@ describe('transforms', () => { kind: Kind.FIELD, name: { kind: Kind.NAME, - value: 'address' + + value: + 'address' + oldFieldName.charAt(0).toUpperCase() + - oldFieldName.slice(1) - } + oldFieldName.slice(1), + }, }; - }) + }), }; return newSelectionSet; }, // how to process the data result at path - result => ({ + (result) => ({ streetAddress: result.addressStreetAddress, - zip: result.addressZip - }) + zip: result.addressZip, + }), ), // Wrap a second level field new WrapQuery( ['userById', 'zip'], (subtree: SelectionSetNode) => subtree, - result => result - ) + (result) => result, + ), ], }); }, @@ -703,11 +943,249 @@ describe('transforms', () => { }); }); + describe('TransformQuery', () => { + let data: any; + let subschema: GraphQLSchema; + let schema: GraphQLSchema; + before(() => { + data = { + u1: { + id: 'u1', + username: 'alice', + address: { + streetAddress: 'Windy Shore 21 A 7', + zip: '12345', + }, + }, + u2: { + id: 'u2', + username: 'bob', + address: { + streetAddress: 'Snowy Mountain 5 B 77', + zip: '54321', + }, + }, + }; + subschema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + username: String + address: Address + errorTest: Address + } + + type Address { + streetAddress: String + zip: String + errorTest: String + } + + type Query { + userById(id: ID!): User + } + `, + resolvers: { + User: { + errorTest: () => { + throw new Error('Test Error!'); + }, + }, + Address: { + errorTest: () => { + throw new Error('Test Error!'); + }, + }, + Query: { + userById(_parent, { id }) { + return data[id]; + }, + }, + }, + }); + schema = makeExecutableSchema({ + typeDefs: ` + type Address { + streetAddress: String + zip: String + errorTest: String + } + + type Query { + addressByUser(id: ID!): Address + errorTest(id: ID!): Address + } + `, + resolvers: { + Query: { + addressByUser(_parent, { id }, context, info) { + return delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'userById', + args: { id }, + context, + info, + transforms: [ + // Wrap document takes a subtree as an AST node + new TransformQuery({ + // path at which to apply wrapping and extracting + path: ['userById'], + queryTransformer: (subtree: SelectionSetNode) => ({ + kind: Kind.SELECTION_SET, + selections: [ + { + // we create a wrapping AST Field + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + // that field is `address` + value: 'address', + }, + // Inside the field selection + selectionSet: subtree, + }, + ], + }), + // how to process the data result at path + resultTransformer: (result) => result?.address, + errorPathTransformer: (path) => path.slice(1), + }), + ], + }); + }, + errorTest(_parent, { id }, context, info) { + return delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'userById', + args: { id }, + context, + info, + transforms: [ + new TransformQuery({ + path: ['userById'], + queryTransformer: (subtree: SelectionSetNode) => ({ + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'errorTest', + }, + selectionSet: subtree, + }, + ], + }), + resultTransformer: (result) => result?.address, + errorPathTransformer: (path) => path.slice(1), + }), + ], + }); + }, + }, + }, + }); + }); + + it('wrapping delegation', async () => { + const result = await graphql( + schema, + ` + query { + addressByUser(id: "u1") { + streetAddress + zip + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + addressByUser: { + streetAddress: 'Windy Shore 21 A 7', + zip: '12345', + }, + }, + }); + }); + + it('preserves errors from underlying fields', async () => { + const result = await graphql( + schema, + ` + query { + addressByUser(id: "u1") { + errorTest + } + } + `, + {}, + {}, + {}, + undefined, + defaultMergedResolver, + ); + + expect(result).to.deep.equal({ + data: { + addressByUser: { + errorTest: null, + }, + }, + errors: [ + { + locations: [ + { + column: 15, + line: 4, + }, + ], + message: 'Test Error!', + path: ['addressByUser', 'errorTest'], + }, + ], + }); + }); + + it('preserves errors from the wrapping field', async () => { + const result = await graphql( + schema, + ` + query { + errorTest(id: "u1") { + errorTest + } + } + `, + {}, + {}, + {}, + undefined, + defaultMergedResolver, + ); + + expect(result).to.deep.equal({ + data: { + errorTest: null, + }, + errors: [ + { + locations: [], + message: 'Test Error!', + path: ['errorTest'], + }, + ], + }); + }); + }); describe('replaces field with fragments', () => { let data: any; let schema: GraphQLSchema; - let subSchema: GraphQLSchema; + let subschema: GraphQLSchema; before(() => { data = { u1: { @@ -717,7 +1195,7 @@ describe('transforms', () => { }, }; - subSchema = makeExecutableSchema({ + subschema = makeExecutableSchema({ typeDefs: ` type User { id: ID! @@ -731,7 +1209,7 @@ describe('transforms', () => { `, resolvers: { Query: { - userById(parent, { id }) { + userById(_parent, { id }) { return data[id]; }, }, @@ -753,23 +1231,23 @@ describe('transforms', () => { `, resolvers: { Query: { - userById(parent, { id }, context, info) { + userById(_parent, { id }, context, info) { return delegateToSchema({ - schema: subSchema, + schema: subschema, operation: 'query', fieldName: 'userById', args: { id }, context, info, transforms: [ - new ReplaceFieldWithFragment(subSchema, [ + new ReplaceFieldWithFragment(subschema, [ { - field: `fullname`, - fragment: `fragment UserName on User { name }`, + field: 'fullname', + fragment: 'fragment UserName on User { name }', }, { - field: `fullname`, - fragment: `fragment UserSurname on User { surname }`, + field: 'fullname', + fragment: 'fragment UserSurname on User { surname }', }, ]), ], @@ -777,8 +1255,8 @@ describe('transforms', () => { }, }, User: { - fullname(parent, args, context, info) { - return `${parent.name} ${parent.surname}`; + fullname(parent, _args, _context, _info) { + return `${parent.name as string} ${parent.surname as string}`; }, }, }, @@ -808,3 +1286,109 @@ describe('transforms', () => { }); }); }); + +describe('replaces field with processed fragment node', () => { + let data: any; + let schema: GraphQLSchema; + let subschema: GraphQLSchema; + before(() => { + data = { + u1: { + id: 'u1', + name: 'joh', + surname: 'gats', + }, + }; + + subschema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + name: String! + surname: String! + } + + type Query { + userById(id: ID!): User + } + `, + resolvers: { + Query: { + userById(_parent, { id }) { + return data[id]; + }, + }, + }, + }); + + schema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + name: String! + surname: String! + fullname: String! + } + + type Query { + userById(id: ID!): User + } + `, + resolvers: { + Query: { + userById(_parent, { id }, context, info) { + return delegateToSchema({ + schema: subschema, + operation: 'query', + fieldName: 'userById', + args: { id }, + context, + info, + transforms: [ + new AddReplacementFragments(subschema, { + User: { + fullname: concatInlineFragments('User', [ + parseFragmentToInlineFragment( + 'fragment UserName on User { name }', + ), + parseFragmentToInlineFragment( + 'fragment UserSurname on User { surname }', + ), + ]), + }, + }), + ], + }); + }, + }, + User: { + fullname(parent, _args, _context, _info) { + return `${parent.name as string} ${parent.surname as string}`; + }, + }, + }, + }); + }); + it('should work', async () => { + const result = await graphql( + schema, + ` + query { + userById(id: "u1") { + id + fullname + } + } + `, + ); + + expect(result).to.deep.equal({ + data: { + userById: { + id: 'u1', + fullname: 'joh gats', + }, + }, + }); + }); +}); diff --git a/src/test/testTypeMerging.ts b/src/test/testTypeMerging.ts new file mode 100644 index 00000000000..594878dcfaa --- /dev/null +++ b/src/test/testTypeMerging.ts @@ -0,0 +1,92 @@ +// The below is meant to be an alternative canonical schema stitching example +// which relies on type merging. + +import { expect } from 'chai'; +import { graphql } from 'graphql'; + +import { mergeSchemas, addMocksToSchema, makeExecutableSchema } from '../index'; + +const chirpSchema = makeExecutableSchema({ + typeDefs: ` + type Chirp { + id: ID! + text: String + author: User + } + + type User { + id: ID! + chirps: [Chirp] + } + type Query { + userById(id: ID!): User + } + `, +}); + +addMocksToSchema({ schema: chirpSchema }); + +const authorSchema = makeExecutableSchema({ + typeDefs: ` + type User { + id: ID! + email: String + } + type Query { + userById(id: ID!): User + } + `, +}); + +addMocksToSchema({ schema: authorSchema }); + +const mergedSchema = mergeSchemas({ + subschemas: [ + { + schema: chirpSchema, + merge: { + User: { + fieldName: 'userById', + args: (originalResult) => ({ id: originalResult.id }), + selectionSet: '{ id }', + }, + }, + }, + { + schema: authorSchema, + merge: { + User: { + fieldName: 'userById', + args: (originalResult) => ({ id: originalResult.id }), + selectionSet: '{ id }', + }, + }, + }, + ], + mergeTypes: true, +}); + +describe('merging using type merging', () => { + it('works', async () => { + const query = ` + query { + userById(id: 5) { + chirps { + id + textAlias: text + author { + email + } + } + } + } + `; + + const result = await graphql(mergedSchema, query); + + expect(result.errors).to.equal(undefined); + expect(result.data.userById.chirps[1].id).to.not.equal(null); + expect(result.data.userById.chirps[1].text).to.not.equal(null); + expect(result.data.userById.chirps[1].author.email).to.not.equal(null); + }); +}); diff --git a/src/test/testUpload.ts b/src/test/testUpload.ts new file mode 100644 index 00000000000..6ba6c0ce479 --- /dev/null +++ b/src/test/testUpload.ts @@ -0,0 +1,138 @@ +import { Server } from 'http'; +import { AddressInfo } from 'net'; +import { Readable } from 'stream'; + +import { expect } from 'chai'; +import express, { Express } from 'express'; +import graphqlHTTP from 'express-graphql'; +import { GraphQLUpload, graphqlUploadExpress } from 'graphql-upload'; +import FormData from 'form-data'; +import fetch from 'node-fetch'; +import { buildSchema } from 'graphql'; + +import { mergeSchemas } from '../stitch/index'; +import { makeExecutableSchema } from '../generate/index'; +import { createServerHttpLink } from '../links/index'; +import { GraphQLUpload as ServerGraphQLUpload } from '../scalars/index'; +import { SubschemaConfig } from '../Interfaces'; + +function streamToString(stream: Readable) { + const chunks: Array = []; + return new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + }); +} + +function startServer(e: Express): Promise { + return new Promise((resolve, reject) => { + e.listen(undefined, 'localhost', function (error) { + if (error) { + reject(error); + } else { + resolve(this); + } + }); + }); +} + +function testGraphqlMultipartRequest(query: string, port: number) { + const body = new FormData(); + + body.append( + 'operations', + JSON.stringify({ + query, + variables: { + file: null, + }, + }), + ); + body.append('map', '{ "1": ["variables.file"] }'); + body.append('1', 'abc', { filename: __filename }); + + return fetch(`http://localhost:${port.toString()}`, { method: 'POST', body }); +} + +describe('graphql upload', () => { + it('should return a file after uploading one', async () => { + const remoteSchema = makeExecutableSchema({ + typeDefs: ` + scalar Upload + type Query { + version: String + } + type Mutation { + upload(file: Upload): String + } + `, + resolvers: { + Mutation: { + upload: async (_root, { file }) => { + const { createReadStream } = await file; + const stream = createReadStream(); + const s = await streamToString(stream); + return s; + }, + }, + Upload: GraphQLUpload, + }, + }); + + const remoteApp = express().use( + graphqlUploadExpress(), + graphqlHTTP({ schema: remoteSchema }), + ); + + const remoteServer = await startServer(remoteApp); + const remotePort = (remoteServer.address() as AddressInfo).port; + + const nonExecutableSchema = buildSchema(` + scalar Upload + type Query { + version: String + } + type Mutation { + upload(file: Upload): String + } + `); + + const subschema: SubschemaConfig = { + schema: nonExecutableSchema, + link: createServerHttpLink({ + uri: `http://localhost:${remotePort.toString()}`, + }), + }; + + const gatewaySchema = mergeSchemas({ + schemas: [subschema], + resolvers: { + Upload: ServerGraphQLUpload, + }, + }); + + const gatewayApp = express().use( + graphqlUploadExpress(), + graphqlHTTP({ schema: gatewaySchema }), + ); + + const gatewayServer = await startServer(gatewayApp); + const gatewayPort = (gatewayServer.address() as AddressInfo).port; + const query = ` + mutation upload($file: Upload!) { + upload(file: $file) + } + `; + const res = await testGraphqlMultipartRequest(query, gatewayPort); + + expect(await res.json()).to.deep.equal({ + data: { + upload: 'abc', + }, + }); + + remoteServer.close(); + gatewayServer.close(); + }); +}); diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts new file mode 100644 index 00000000000..27d36a84aac --- /dev/null +++ b/src/test/testUtils.ts @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import { GraphQLObjectType } from 'graphql'; + +import { healSchema } from '../utils/index'; +import { toConfig } from '../polyfills/index'; +import { makeExecutableSchema } from '../generate/index'; + +describe('heal', () => { + it('should prune empty types', () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type WillBeEmptyObject { + willBeRemoved: String + } + + type Query { + someQuery: WillBeEmptyObject + } + `, + }); + const originalTypeMap = schema.getTypeMap(); + + const config = toConfig(originalTypeMap['WillBeEmptyObject']); + originalTypeMap['WillBeEmptyObject'] = new GraphQLObjectType({ + ...config, + fields: {}, + }); + + healSchema(schema); + + const healedTypeMap = schema.getTypeMap(); + expect(healedTypeMap).not.to.haveOwnProperty('WillBeEmptyObject'); + }); +}); diff --git a/src/test/testingSchemas.ts b/src/test/testingSchemas.ts index 5e428cb6784..e161de0248f 100644 --- a/src/test/testingSchemas.ts +++ b/src/test/testingSchemas.ts @@ -1,3 +1,9 @@ +import { PubSub } from 'graphql-subscriptions'; +import { + ApolloLink, + Observable, + ExecutionResult as LinkExecutionResult, +} from 'apollo-link'; import { GraphQLSchema, graphql, @@ -7,21 +13,15 @@ import { GraphQLScalarType, ValueNode, ExecutionResult, - DocumentNode, + Source, + GraphQLResolveInfo, } from 'graphql'; -import { ExecutionResultDataDefault } from 'graphql/execution/execute'; -import { - ApolloLink, - Observable, - ExecutionResult as LinkExecutionResult, -} from 'apollo-link'; -import { makeExecutableSchema } from '../makeExecutableSchema'; -import { IResolvers } from '../Interfaces'; -import makeRemoteExecutableSchema, { - Fetcher, -} from '../stitching/makeRemoteExecutableSchema'; -import introspectSchema from '../stitching/introspectSchema'; -import { PubSub } from 'graphql-subscriptions'; +import { forAwaitEach } from 'iterall'; + +import introspectSchema from '../stitch/introspectSchema'; +import { IResolvers, Fetcher, SubschemaConfig } from '../Interfaces'; +import { makeExecutableSchema } from '../generate/index'; +import { graphqlVersion } from '../utils/index'; export type Location = { name: string; @@ -88,7 +88,7 @@ export const sampleData: { name: 'Super great hotel', location: { name: 'Helsinki', - coordinates: '60.1698° N, 24.9383° E' + coordinates: '60.1698° N, 24.9383° E', }, }, p2: { @@ -96,7 +96,7 @@ export const sampleData: { name: 'Another great hotel', location: { name: 'San Francisco', - coordinates: '37.7749° N, 122.4194° W' + coordinates: '37.7749° N, 122.4194° W', }, }, p3: { @@ -104,7 +104,7 @@ export const sampleData: { name: 'BedBugs - The Affordable Hostel', location: { name: 'Helsinki', - coordinates: '60.1699° N, 24.9384° E' + coordinates: '60.1699° N, 24.9384° E', }, }, }, @@ -172,8 +172,8 @@ export const sampleData: { }, }; -function values(o: { [s: string]: T }): T[] { - return Object.keys(o).map(k => o[k]); +function values(o: { [s: string]: T }): Array { + return Object.keys(o).map((k) => o[k]); } function coerceString(value: any): string { @@ -209,7 +209,7 @@ function parseLiteral(ast: ValueNode): any { return parseFloat(ast.value); case Kind.OBJECT: { const value = Object.create(null); - ast.fields.forEach(field => { + ast.fields.forEach((field) => { value[field.name.value] = parseLiteral(field.value); }); @@ -273,10 +273,25 @@ const propertyRootTypeDefs = ` foo: String } - type TestImpl2 implements TestInterface { + ${ + graphqlVersion() >= 15 + ? `interface TestNestedInterface implements TestInterface { + kind: TestInterfaceKind + testString: String + } + + type TestImpl2 implements TestNestedInterface${ + graphqlVersion() >= 13 ? ' &' : ', ' + } TestInterface { kind: TestInterfaceKind testString: String bar: String + }` + : `type TestImpl2 implements TestInterface { + kind: TestInterfaceKind + testString: String + bar: String + }` } type UnionImpl { @@ -315,20 +330,16 @@ const propertyAddressTypeDefs = ` const propertyResolvers: IResolvers = { Query: { - propertyById(root, { id }) { + propertyById(_root, { id }) { return sampleData.Property[id]; }, - properties(root, { limit }) { + properties(_root, { limit }) { const list = values(sampleData.Property); - if (limit) { - return list.slice(0, limit); - } else { - return list; - } + return limit ? list.slice(0, limit) : list; }, - contextTest(root, args, context) { + contextTest(_root, args, context) { return JSON.stringify(context[args.key]); }, @@ -336,38 +347,34 @@ const propertyResolvers: IResolvers = { return '1987-09-25T12:00:00'; }, - jsonTest(root, { input }) { + jsonTest(_root, { input }) { return input; }, - interfaceTest(root, { kind }) { - if (kind === 'ONE') { - return { - kind: 'ONE', - testString: 'test', - foo: 'foo', - }; - } else { - return { - kind: 'TWO', - testString: 'test', - bar: 'bar', - }; - } - }, - - unionTest(root, { output }) { - if (output === 'Interface') { - return { - kind: 'ONE', - testString: 'test', - foo: 'foo', - }; - } else { - return { - someField: 'Bar', - }; - } + interfaceTest(_root, { kind }) { + return kind === 'ONE' + ? { + kind: 'ONE', + testString: 'test', + foo: 'foo', + } + : { + kind: 'TWO', + testString: 'test', + bar: 'bar', + }; + }, + + unionTest(_root, { output }) { + return output === 'Interface' + ? { + kind: 'ONE', + testString: 'test', + foo: 'foo', + } + : { + someField: 'Bar', + }; }, errorTest() { @@ -378,7 +385,7 @@ const propertyResolvers: IResolvers = { throw new Error('Sample error non-null!'); }, - defaultInputTest(parent, { input }) { + defaultInputTest(_parent, { input }) { return input.test; }, }, @@ -387,39 +394,39 @@ const propertyResolvers: IResolvers = { TestInterface: { __resolveType(obj: any) { - if (obj.kind === 'ONE') { - return 'TestImpl1'; - } else { - return 'TestImpl2'; - } + return obj.kind === 'ONE' ? 'TestImpl1' : 'TestImpl2'; }, }, TestUnion: { __resolveType(obj: any) { - if (obj.kind === 'ONE') { - return 'TestImpl1'; - } else { - return 'UnionImpl'; - } + return obj.kind === 'ONE' ? 'TestImpl1' : 'UnionImpl'; }, }, Property: { error() { - throw new Error('Property.error error'); + const error = new Error('Property.error error'); + (error as any).extensions = { + code: 'SOME_CUSTOM_CODE', + }; + throw error; }, }, }; -let DownloadableProduct = ` - type DownloadableProduct implements Product & Downloadable { +const DownloadableProduct = ` + type DownloadableProduct implements Product${ + graphqlVersion() >= 13 ? ' &' : ', ' + } Downloadable { id: ID! url: String! } `; -let SimpleProduct = `type SimpleProduct implements Product & Sellable { +const SimpleProduct = `type SimpleProduct implements Product${ + graphqlVersion() >= 13 ? ' &' : ', ' +} Sellable { id: ID! price: Int! } @@ -448,7 +455,7 @@ const productTypeDefs = ` const productResolvers: IResolvers = { Query: { - products(root) { + products(_root) { const list = values(sampleData.Product); return list; }, @@ -456,11 +463,7 @@ const productResolvers: IResolvers = { Product: { __resolveType(obj: any) { - if (obj.type === 'simple') { - return 'SimpleProduct'; - } else { - return 'DownloadableProduct'; - } + return obj.type === 'simple' ? 'SimpleProduct' : 'DownloadableProduct'; }, }, }; @@ -535,43 +538,31 @@ const bookingAddressTypeDefs = ` const bookingResolvers: IResolvers = { Query: { - bookingById(parent, { id }) { + bookingById(_parent, { id }) { return sampleData.Booking[id]; }, - bookingsByPropertyId(parent, { propertyId, limit }) { + bookingsByPropertyId(_parent, { propertyId, limit }) { const list = values(sampleData.Booking).filter( (booking: Booking) => booking.propertyId === propertyId, ); - if (limit) { - return list.slice(0, limit); - } else { - return list; - } + return limit ? list.slice(0, limit) : list; }, - customerById(parent, { id }) { + customerById(_parent, { id }) { return sampleData.Customer[id]; }, - bookings(parent, { limit }) { + bookings(_parent, { limit }) { const list = values(sampleData.Booking); - if (limit) { - return list.slice(0, limit); - } else { - return list; - } + return limit ? list.slice(0, limit) : list; }, - customers(parent, { limit }) { + customers(_parent, { limit }) { const list = values(sampleData.Customer); - if (limit) { - return list.slice(0, limit); - } else { - return list; - } + return limit ? list.slice(0, limit) : list; }, }, Mutation: { addBooking( - parent, + _parent, { input: { propertyId, customerId, startTime, endTime } }, ) { return { @@ -585,7 +576,7 @@ const bookingResolvers: IResolvers = { }, Booking: { - __isTypeOf(source: any, context: any, info: any) { + __isTypeOf(source: Source, _context: any, _info: GraphQLResolveInfo) { return Object.prototype.hasOwnProperty.call(source, 'id'); }, customer(parent: Booking) { @@ -604,11 +595,7 @@ const bookingResolvers: IResolvers = { const list = values(sampleData.Booking).filter( (booking: Booking) => booking.customerId === parent.id, ); - if (limit) { - return list.slice(0, limit); - } else { - return list; - } + return limit ? list.slice(0, limit) : list; }, vehicle(parent: Customer) { return sampleData.Vehicle[parent.vehicleId]; @@ -624,9 +611,9 @@ const bookingResolvers: IResolvers = { return 'Car'; } else if (parent.bikeType) { return 'Bike'; - } else { - throw new Error('Could not resolve Vehicle type'); } + + throw new Error('Could not resolve Vehicle type'); }, }, @@ -653,7 +640,7 @@ export const subscriptionPubSubTrigger = 'pubSubTrigger'; const subscriptionResolvers: IResolvers = { Query: { - notifications: (root: any) => ({ text: 'Hello world' }), + notifications: (_root: any) => ({ text: 'Hello world' }), }, Subscription: { notifications: { @@ -664,8 +651,8 @@ const subscriptionResolvers: IResolvers = { Notification: { throwError: () => { throw new Error('subscription field error'); - } - } + }, + }, }; export const propertySchema: GraphQLSchema = makeExecutableSchema({ @@ -689,7 +676,7 @@ export const subscriptionSchema: GraphQLSchema = makeExecutableSchema({ }); const hasSubscriptionOperation = ({ query }: { query: any }): boolean => { - for (let definition of query.definitions) { + for (const definition of query.definitions) { if (definition.kind === 'OperationDefinition') { const operation = definition.operation; if (operation === 'subscription') { @@ -700,89 +687,101 @@ const hasSubscriptionOperation = ({ query }: { query: any }): boolean => { return false; }; -// Pretend this schema is remote -export async function makeSchemaRemoteFromLink(schema: GraphQLSchema) { - const link = new ApolloLink(operation => { - return new Observable(observer => { - (async () => { +function makeLinkFromSchema(schema: GraphQLSchema) { + return new ApolloLink( + (operation) => + new Observable((observer) => { const { query, operationName, variables } = operation; const { graphqlContext } = operation.getContext(); - try { - if (!hasSubscriptionOperation(operation)) { - const result: ExecutionResultDataDefault = await graphql( - schema, - print(query), - null, - graphqlContext, - variables, - operationName, - ); - observer.next(result as LinkExecutionResult); - observer.complete(); - } else { - const result = await subscribe( - schema, - query as DocumentNode, - null, - graphqlContext, - variables, - operationName, - ); - if ( - typeof (>result).next === - 'function' - ) { - while (true) { - const next = await (>( - result - )).next(); - observer.next(next.value as LinkExecutionResult); - if (next.done) { - observer.complete(); - break; - } - } - } else { - observer.next(result as LinkExecutionResult); + if (!hasSubscriptionOperation(operation)) { + graphql( + schema, + print(query), + null, + graphqlContext, + variables, + operationName, + ) + .then((result) => { + observer.next(result); observer.complete(); - } - } - } catch (error) { - observer.error.bind(observer); + }) + .catch((err) => { + observer.error(err); + }); + } else { + subscribe( + schema, + query, + null, + graphqlContext, + variables, + operationName, + ) + .then((results) => { + if ( + typeof (results as AsyncIterator).next === + 'function' + ) { + forAwaitEach( + results as AsyncIterable, + (result) => observer.next(result), + ) + .then(() => observer.complete()) + .catch((err) => observer.error(err)); + } else { + observer.next(results as LinkExecutionResult); + observer.complete(); + } + }) + .catch((err) => { + observer.error(err); + }); } - })(); - }); - }); + }), + ); +} +export async function makeSchemaRemoteFromLink( + schema: GraphQLSchema, +): Promise { + const link = makeLinkFromSchema(schema); const clientSchema = await introspectSchema(link); - return makeRemoteExecutableSchema({ + return { schema: clientSchema, link, - }); + }; } -// ensure fetcher support exists from the 2.0 api -async function makeExecutableSchemaFromFetcher(schema: GraphQLSchema) { - const fetcher: Fetcher = ({ query, operationName, variables, context }) => { - return graphql( - schema, - print(query), - null, - context, - variables, - operationName, - ); +export async function makeSchemaRemoteFromDispatchedLink( + schema: GraphQLSchema, +): Promise { + const link = makeLinkFromSchema(schema); + const clientSchema = await introspectSchema(link); + return { + schema: clientSchema, + dispatcher: () => link, }; +} + +// ensure fetcher support exists from the 2.0 api +async function makeExecutableSchemaFromDispatchedFetcher( + schema: GraphQLSchema, +): Promise { + const fetcher: Fetcher = ({ query, operationName, variables, context }) => + graphql(schema, print(query), null, context, variables, operationName); const clientSchema = await introspectSchema(fetcher); - return makeRemoteExecutableSchema({ + return { schema: clientSchema, fetcher, - }); + }; } export const remotePropertySchema = makeSchemaRemoteFromLink(propertySchema); -export const remoteProductSchema = makeSchemaRemoteFromLink(productSchema); -export const remoteBookingSchema = makeExecutableSchemaFromFetcher( +export const remoteProductSchema = makeSchemaRemoteFromDispatchedLink( + productSchema, +); +export const remoteBookingSchema = makeExecutableSchemaFromDispatchedFetcher( bookingSchema, ); diff --git a/src/test/tests.ts b/src/test/tests.ts deleted file mode 100755 index 7da887e0719..00000000000 --- a/src/test/tests.ts +++ /dev/null @@ -1,15 +0,0 @@ -require('source-map-support').install(); - -import './testAlternateMergeSchemas'; -import './testDelegateToSchema'; -import './testDirectives'; -import './testErrors'; -import './testFragmentsAreNotDuplicated'; -import './testLogger'; -import './testMakeRemoteExecutableSchema'; -import './testMergeSchemas'; -import './testMocking'; -import './testResolution'; -import './testSchemaGenerator'; -import './testTransforms'; -import './testExtensionExtraction'; diff --git a/src/transforms/AddArgumentsAsVariables.ts b/src/transforms/AddArgumentsAsVariables.ts deleted file mode 100644 index b2378ff8b64..00000000000 --- a/src/transforms/AddArgumentsAsVariables.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - ArgumentNode, - DocumentNode, - FragmentDefinitionNode, - GraphQLArgument, - GraphQLInputType, - GraphQLList, - GraphQLField, - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - Kind, - OperationDefinitionNode, - SelectionNode, - TypeNode, - VariableDefinitionNode, -} from 'graphql'; -import { Request } from '../Interfaces'; -import { Transform } from './transforms'; - -export default class AddArgumentsAsVariablesTransform implements Transform { - private schema: GraphQLSchema; - private args: { [key: string]: any }; - - constructor(schema: GraphQLSchema, args: { [key: string]: any }) { - this.schema = schema; - this.args = args; - } - - public transformRequest(originalRequest: Request): Request { - const { document, newVariables } = addVariablesToRootField( - this.schema, - originalRequest.document, - this.args, - ); - const variables = { - ...originalRequest.variables, - ...newVariables, - }; - return { - document, - variables, - }; - } -} - -function addVariablesToRootField( - targetSchema: GraphQLSchema, - document: DocumentNode, - args: { [key: string]: any }, -): { - document: DocumentNode; - newVariables: { [key: string]: any }; -} { - const operations: Array< - OperationDefinitionNode - > = document.definitions.filter( - def => def.kind === Kind.OPERATION_DEFINITION, - ) as Array; - const fragments: Array = document.definitions.filter( - def => def.kind === Kind.FRAGMENT_DEFINITION, - ) as Array; - - const variableNames = {}; - - const newOperations = operations.map((operation: OperationDefinitionNode) => { - let existingVariables = operation.variableDefinitions.map( - (variableDefinition: VariableDefinitionNode) => - variableDefinition.variable.name.value, - ); - - let variableCounter = 0; - const variables = {}; - - const generateVariableName = (argName: string) => { - let varName; - do { - varName = `_v${variableCounter}_${argName}`; - variableCounter++; - } while (existingVariables.indexOf(varName) !== -1); - return varName; - }; - - let type: GraphQLObjectType; - if (operation.operation === 'subscription') { - type = targetSchema.getSubscriptionType(); - } else if (operation.operation === 'mutation') { - type = targetSchema.getMutationType(); - } else { - type = targetSchema.getQueryType(); - } - - const newSelectionSet: Array = []; - - operation.selectionSet.selections.forEach((selection: SelectionNode) => { - if (selection.kind === Kind.FIELD) { - let newArgs: { [name: string]: ArgumentNode } = {}; - selection.arguments.forEach((argument: ArgumentNode) => { - newArgs[argument.name.value] = argument; - }); - const name: string = selection.name.value; - const field: GraphQLField = type.getFields()[name]; - field.args.forEach((argument: GraphQLArgument) => { - if (argument.name in args) { - const variableName = generateVariableName(argument.name); - variableNames[argument.name] = variableName; - newArgs[argument.name] = { - kind: Kind.ARGUMENT, - name: { - kind: Kind.NAME, - value: argument.name, - }, - value: { - kind: Kind.VARIABLE, - name: { - kind: Kind.NAME, - value: variableName, - }, - }, - }; - existingVariables.push(variableName); - variables[variableName] = { - kind: Kind.VARIABLE_DEFINITION, - variable: { - kind: Kind.VARIABLE, - name: { - kind: Kind.NAME, - value: variableName, - }, - }, - type: typeToAst(argument.type), - }; - } - }); - - newSelectionSet.push({ - ...selection, - arguments: Object.keys(newArgs).map(argName => newArgs[argName]), - }); - } else { - newSelectionSet.push(selection); - } - }); - - return { - ...operation, - variableDefinitions: operation.variableDefinitions.concat( - Object.keys(variables).map(varName => variables[varName]), - ), - selectionSet: { - kind: Kind.SELECTION_SET, - selections: newSelectionSet, - }, - }; - }); - - const newVariables = {}; - Object.keys(variableNames).forEach(name => { - newVariables[variableNames[name]] = args[name]; - }); - - return { - document: { - ...document, - definitions: [...newOperations, ...fragments], - }, - newVariables, - }; -} - -function typeToAst(type: GraphQLInputType): TypeNode { - if (type instanceof GraphQLNonNull) { - const innerType = typeToAst(type.ofType); - if ( - innerType.kind === Kind.LIST_TYPE || - innerType.kind === Kind.NAMED_TYPE - ) { - return { - kind: Kind.NON_NULL_TYPE, - type: innerType, - }; - } else { - throw new Error('Incorrent inner non-null type'); - } - } else if (type instanceof GraphQLList) { - return { - kind: Kind.LIST_TYPE, - type: typeToAst(type.ofType), - }; - } else { - return { - kind: Kind.NAMED_TYPE, - name: { - kind: Kind.NAME, - value: type.toString(), - }, - }; - } -} diff --git a/src/transforms/AddTypenameToAbstract.ts b/src/transforms/AddTypenameToAbstract.ts deleted file mode 100644 index ef790062219..00000000000 --- a/src/transforms/AddTypenameToAbstract.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - DocumentNode, - FieldNode, - GraphQLInterfaceType, - GraphQLSchema, - GraphQLType, - GraphQLUnionType, - Kind, - SelectionSetNode, - TypeInfo, - visit, - visitWithTypeInfo, -} from 'graphql'; -import { Request } from '../Interfaces'; -import { Transform } from './transforms'; - -export default class AddTypenameToAbstract implements Transform { - private targetSchema: GraphQLSchema; - - constructor(targetSchema: GraphQLSchema) { - this.targetSchema = targetSchema; - } - - public transformRequest(originalRequest: Request): Request { - const document = addTypenameToAbstract( - this.targetSchema, - originalRequest.document, - ); - return { - ...originalRequest, - document, - }; - } -} - -function addTypenameToAbstract( - targetSchema: GraphQLSchema, - document: DocumentNode, -): DocumentNode { - const typeInfo = new TypeInfo(targetSchema); - return visit( - document, - visitWithTypeInfo(typeInfo, { - [Kind.SELECTION_SET]( - node: SelectionSetNode, - ): SelectionSetNode | null | undefined { - const parentType: GraphQLType = typeInfo.getParentType(); - let selections = node.selections; - if ( - parentType && - (parentType instanceof GraphQLInterfaceType || - parentType instanceof GraphQLUnionType) && - !selections.find( - _ => - (_ as FieldNode).kind === Kind.FIELD && - (_ as FieldNode).name.value === '__typename', - ) - ) { - selections = selections.concat({ - kind: Kind.FIELD, - name: { - kind: Kind.NAME, - value: '__typename', - }, - }); - } - - if (selections !== node.selections) { - return { - ...node, - selections, - }; - } - }, - }), - ); -} diff --git a/src/transforms/CheckResultAndHandleErrors.ts b/src/transforms/CheckResultAndHandleErrors.ts deleted file mode 100644 index 73532674825..00000000000 --- a/src/transforms/CheckResultAndHandleErrors.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { GraphQLResolveInfo } from 'graphql'; -import { checkResultAndHandleErrors } from '../stitching/errors'; -import { Transform } from './transforms'; - -export default class CheckResultAndHandleErrors implements Transform { - private info: GraphQLResolveInfo; - private fieldName?: string; - - constructor(info: GraphQLResolveInfo, fieldName?: string) { - this.info = info; - this.fieldName = fieldName; - } - - public transformResult(result: any): any { - return checkResultAndHandleErrors(result, this.info, this.fieldName); - } -} diff --git a/src/transforms/ConvertEnumResponse.ts b/src/transforms/ConvertEnumResponse.ts deleted file mode 100644 index 55975ae5933..00000000000 --- a/src/transforms/ConvertEnumResponse.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Transform } from './transforms'; -import { GraphQLEnumType } from 'graphql'; - -export default class ConvertEnumResponse implements Transform { - private enumNode: GraphQLEnumType; - - constructor(enumNode: GraphQLEnumType) { - this.enumNode = enumNode; - } - - public transformResult(result: any) { - const value = this.enumNode.getValue(result); - if (value) { - return value.value; - } - return result; - } -} diff --git a/src/transforms/ConvertEnumValues.ts b/src/transforms/ConvertEnumValues.ts deleted file mode 100644 index e923477d268..00000000000 --- a/src/transforms/ConvertEnumValues.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { GraphQLSchema, GraphQLEnumType } from 'graphql'; -import { Transform } from '../transforms/transforms'; -import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; - -// Transformation used to modifiy `GraphQLEnumType` values in a schema. -export default class ConvertEnumValues implements Transform { - // Maps current enum values to their new values. - // e.g. { Color: { 'RED': '#EA3232' } } - private enumValueMap: object; - - constructor(enumValueMap: object) { - this.enumValueMap = enumValueMap; - } - - // Walk a schema looking for `GraphQLEnumType` types. If found, and - // matching types have been identified in `this.enumValueMap`, create new - // `GraphQLEnumType` types using the `this.enumValueMap` specified new - // values, and return them in the new schema. - public transformSchema(schema: GraphQLSchema): GraphQLSchema { - const { enumValueMap } = this; - if (!enumValueMap || Object.keys(enumValueMap).length === 0) { - return schema; - } - - const transformedSchema = visitSchema(schema, { - [VisitSchemaKind.ENUM_TYPE](enumType: GraphQLEnumType) { - const externalToInternalValueMap = enumValueMap[enumType.name]; - - if (externalToInternalValueMap) { - const values = enumType.getValues(); - const newValues = {}; - values.forEach(value => { - const newValue = Object.keys(externalToInternalValueMap).includes( - value.name, - ) - ? externalToInternalValueMap[value.name] - : value.name; - newValues[value.name] = { - value: newValue, - deprecationReason: value.deprecationReason, - description: value.description, - astNode: value.astNode, - }; - }); - - return new GraphQLEnumType({ - name: enumType.name, - description: enumType.description, - astNode: enumType.astNode, - values: newValues, - }); - } - - return enumType; - }, - }); - - return transformedSchema; - } -} diff --git a/src/transforms/RenameTypes.ts b/src/transforms/RenameTypes.ts deleted file mode 100644 index 84b1d1dbfd6..00000000000 --- a/src/transforms/RenameTypes.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - visit, - GraphQLSchema, - NamedTypeNode, - Kind, - GraphQLNamedType, - GraphQLScalarType, -} from 'graphql'; -import isSpecifiedScalarType from '../isSpecifiedScalarType'; -import { Request, Result } from '../Interfaces'; -import { Transform } from '../transforms/transforms'; -import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; - -export type RenameOptions = { - renameBuiltins: boolean; - renameScalars: boolean; -}; - -export default class RenameTypes implements Transform { - private renamer: (name: string) => string | undefined; - private reverseMap: { [key: string]: string }; - private renameBuiltins: boolean; - private renameScalars: boolean; - - constructor( - renamer: (name: string) => string | undefined, - options?: RenameOptions, - ) { - this.renamer = renamer; - this.reverseMap = {}; - const { renameBuiltins = false, renameScalars = true } = options || {}; - this.renameBuiltins = renameBuiltins; - this.renameScalars = renameScalars; - } - - public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { - return visitSchema(originalSchema, { - [VisitSchemaKind.TYPE]: (type: GraphQLNamedType) => { - if (isSpecifiedScalarType(type) && !this.renameBuiltins) { - return undefined; - } - if (type instanceof GraphQLScalarType && !this.renameScalars) { - return undefined; - } - const newName = this.renamer(type.name); - if (newName && newName !== type.name) { - this.reverseMap[newName] = type.name; - const newType = Object.assign(Object.create(type), type); - newType.name = newName; - return newType; - } - }, - - [VisitSchemaKind.ROOT_OBJECT](type: GraphQLNamedType) { - return undefined; - }, - }); - } - - public transformRequest(originalRequest: Request): Request { - const newDocument = visit(originalRequest.document, { - [Kind.NAMED_TYPE]: (node: NamedTypeNode) => { - const name = node.name.value; - if (name in this.reverseMap) { - return { - ...node, - name: { - kind: Kind.NAME, - value: this.reverseMap[name], - }, - }; - } - }, - }); - return { - document: newDocument, - variables: originalRequest.variables, - }; - } - - public transformResult(result: Result): Result { - if (result.data) { - const data = this.renameTypes(result.data, 'data'); - if (data !== result.data) { - return { ...result, data }; - } - } - - return result; - } - - private renameTypes(value: any, name?: string) { - if (name === '__typename') { - return this.renamer(value); - } - - if (value && typeof value === 'object') { - const newValue = Array.isArray(value) ? [] - // Create a new object with the same prototype. - : Object.create(Object.getPrototypeOf(value)); - - let returnNewValue = false; - - Object.keys(value).forEach(key => { - const oldChild = value[key]; - const newChild = this.renameTypes(oldChild, key); - newValue[key] = newChild; - if (newChild !== oldChild) { - returnNewValue = true; - } - }); - - if (returnNewValue) { - return newValue; - } - } - - return value; - } -} diff --git a/src/transforms/TransformRootFields.ts b/src/transforms/TransformRootFields.ts deleted file mode 100644 index cc47ef3adb4..00000000000 --- a/src/transforms/TransformRootFields.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - GraphQLObjectType, - GraphQLSchema, - GraphQLNamedType, - GraphQLField, - GraphQLFieldConfig, -} from 'graphql'; -import isEmptyObject from '../isEmptyObject'; -import { Transform } from './transforms'; -import { visitSchema, VisitSchemaKind } from './visitSchema'; -import { - createResolveType, - fieldToFieldConfig, -} from '../stitching/schemaRecreation'; - -export type RootTransformer = ( - operation: 'Query' | 'Mutation' | 'Subscription', - fieldName: string, - field: GraphQLField, -) => - | GraphQLFieldConfig - | { name: string; field: GraphQLFieldConfig } - | null - | undefined; - -export default class TransformRootFields implements Transform { - private transform: RootTransformer; - - constructor(transform: RootTransformer) { - this.transform = transform; - } - - public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { - return visitSchema(originalSchema, { - [VisitSchemaKind.QUERY]: (type: GraphQLObjectType) => { - return transformFields( - type, - (fieldName: string, field: GraphQLField) => - this.transform('Query', fieldName, field), - ); - }, - [VisitSchemaKind.MUTATION]: (type: GraphQLObjectType) => { - return transformFields( - type, - (fieldName: string, field: GraphQLField) => - this.transform('Mutation', fieldName, field), - ); - }, - [VisitSchemaKind.SUBSCRIPTION]: (type: GraphQLObjectType) => { - return transformFields( - type, - (fieldName: string, field: GraphQLField) => - this.transform('Subscription', fieldName, field), - ); - }, - }); - } -} - -function transformFields( - type: GraphQLObjectType, - transformer: ( - fieldName: string, - field: GraphQLField, - ) => - | GraphQLFieldConfig - | { name: string; field: GraphQLFieldConfig } - | null - | undefined, -): GraphQLObjectType { - const resolveType = createResolveType( - (name: string, originalType: GraphQLNamedType): GraphQLNamedType => - originalType, - ); - const fields = type.getFields(); - const newFields = {}; - Object.keys(fields).forEach(fieldName => { - const field = fields[fieldName]; - const newField = transformer(fieldName, field); - if (typeof newField === 'undefined') { - newFields[fieldName] = fieldToFieldConfig(field, resolveType, true); - } else if (newField !== null) { - if ( - (<{ name: string; field: GraphQLFieldConfig }>newField).name - ) { - newFields[ - (<{ name: string; field: GraphQLFieldConfig }>newField).name - ] = (<{ - name: string; - field: GraphQLFieldConfig; - }>newField).field; - } else { - newFields[fieldName] = newField; - } - } - }); - if (isEmptyObject(newFields)) { - return null; - } else { - return new GraphQLObjectType({ - name: type.name, - description: type.description, - astNode: type.astNode, - fields: newFields, - }); - } -} diff --git a/src/transforms/index.ts b/src/transforms/index.ts deleted file mode 100644 index db76efbc221..00000000000 --- a/src/transforms/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Transform } from './transforms'; -export { Transform }; - -export { default as transformSchema } from './transformSchema'; - -export { default as AddArgumentsAsVariables } from './AddArgumentsAsVariables'; -export { - default as CheckResultAndHandleErrors, -} from './CheckResultAndHandleErrors'; -export { - default as ReplaceFieldWithFragment, -} from './ReplaceFieldWithFragment'; -export { default as AddTypenameToAbstract } from './AddTypenameToAbstract'; -export { default as FilterToSchema } from './FilterToSchema'; -export { default as RenameTypes } from './RenameTypes'; -export { default as FilterTypes } from './FilterTypes'; -export { default as TransformRootFields } from './TransformRootFields'; -export { default as RenameRootFields } from './RenameRootFields'; -export { default as FilterRootFields } from './FilterRootFields'; -export { default as ExpandAbstractTypes } from './ExpandAbstractTypes'; -export { default as ExtractField } from './ExtractField'; -export { default as WrapQuery } from './WrapQuery'; diff --git a/src/transforms/transformSchema.ts b/src/transforms/transformSchema.ts deleted file mode 100644 index 3d9f8c7eaa8..00000000000 --- a/src/transforms/transformSchema.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { GraphQLSchema } from 'graphql'; -import { addResolveFunctionsToSchema } from '../makeExecutableSchema'; - -import { visitSchema } from '../transforms/visitSchema'; -import { Transform, applySchemaTransforms } from '../transforms/transforms'; -import { - generateProxyingResolvers, - generateSimpleMapping, -} from '../stitching/resolvers'; - -export default function transformSchema( - targetSchema: GraphQLSchema, - transforms: Array, -): GraphQLSchema & { transforms: Array } { - let schema = visitSchema(targetSchema, {}, true); - const mapping = generateSimpleMapping(targetSchema); - const resolvers = generateProxyingResolvers( - targetSchema, - transforms, - mapping, - ); - schema = addResolveFunctionsToSchema({ - schema, - resolvers, - resolverValidationOptions: { - allowResolversNotInSchema: true, - }, - }); - schema = applySchemaTransforms(schema, transforms); - (schema as any).transforms = transforms; - return schema as GraphQLSchema & { transforms: Array }; -} diff --git a/src/transforms/transforms.ts b/src/transforms/transforms.ts deleted file mode 100644 index 7a84cb1bfe1..00000000000 --- a/src/transforms/transforms.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { GraphQLSchema } from 'graphql'; -import { Request, Result, Transform } from '../Interfaces'; - -export { Transform }; - -export function applySchemaTransforms( - originalSchema: GraphQLSchema, - transforms: Array, -): GraphQLSchema { - return transforms.reduce( - (schema: GraphQLSchema, transform: Transform) => - transform.transformSchema ? transform.transformSchema(schema) : schema, - originalSchema, - ); -} - -export function applyRequestTransforms( - originalRequest: Request, - transforms: Array, -): Request { - return transforms.reduce( - (request: Request, transform: Transform) => - transform.transformRequest - ? transform.transformRequest(request) - : request, - - originalRequest, - ); -} - -export function applyResultTransforms( - originalResult: any, - transforms: Array, -): any { - return transforms.reduce( - (result: any, transform: Transform) => - transform.transformResult ? transform.transformResult(result) : result, - originalResult, - ); -} - -export function composeTransforms(...transforms: Array): Transform { - const reverseTransforms = [...transforms].reverse(); - return { - transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { - return applySchemaTransforms(originalSchema, transforms); - }, - transformRequest(originalRequest: Request): Request { - return applyRequestTransforms(originalRequest, reverseTransforms); - }, - transformResult(result: Result): Result { - return applyResultTransforms(result, reverseTransforms); - }, - }; -} diff --git a/src/transforms/visitSchema.ts b/src/transforms/visitSchema.ts deleted file mode 100644 index 8d454fd25c2..00000000000 --- a/src/transforms/visitSchema.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - GraphQLEnumType, - GraphQLInputObjectType, - GraphQLInterfaceType, - GraphQLObjectType, - GraphQLScalarType, - GraphQLSchema, - GraphQLType, - GraphQLUnionType, - GraphQLNamedType, - isNamedType, - getNamedType, -} from 'graphql'; -import { recreateType, createResolveType } from '../stitching/schemaRecreation'; - -export enum VisitSchemaKind { - TYPE = 'VisitSchemaKind.TYPE', - SCALAR_TYPE = 'VisitSchemaKind.SCALAR_TYPE', - ENUM_TYPE = 'VisitSchemaKind.ENUM_TYPE', - COMPOSITE_TYPE = 'VisitSchemaKind.COMPOSITE_TYPE', - OBJECT_TYPE = 'VisitSchemaKind.OBJECT_TYPE', - INPUT_OBJECT_TYPE = 'VisitSchemaKind.INPUT_OBJECT_TYPE', - ABSTRACT_TYPE = 'VisitSchemaKind.ABSTRACT_TYPE', - UNION_TYPE = 'VisitSchemaKind.UNION_TYPE', - INTERFACE_TYPE = 'VisitSchemaKind.INTERFACE_TYPE', - ROOT_OBJECT = 'VisitSchemaKind.ROOT_OBJECT', - QUERY = 'VisitSchemaKind.QUERY', - MUTATION = 'VisitSchemaKind.MUTATION', - SUBSCRIPTION = 'VisitSchemaKind.SUBSCRIPTION', -} -// I couldn't make keys to be forced to be enum values -export type SchemaVisitor = { [key: string]: TypeVisitor }; -export type TypeVisitor = ( - type: GraphQLType, - schema: GraphQLSchema, -) => GraphQLNamedType; - -export function visitSchema( - schema: GraphQLSchema, - visitor: SchemaVisitor, - stripResolvers?: boolean, -) { - const types = {}; - const resolveType = createResolveType(name => { - if (typeof types[name] === 'undefined') { - throw new Error(`Can't find type ${name}.`); - } - return types[name]; - }); - const queryType = schema.getQueryType(); - const mutationType = schema.getMutationType(); - const subscriptionType = schema.getSubscriptionType(); - const typeMap = schema.getTypeMap(); - Object.keys(typeMap).map((typeName: string) => { - const type = typeMap[typeName]; - if (isNamedType(type) && getNamedType(type).name.slice(0, 2) !== '__') { - const specifiers = getTypeSpecifiers(type, schema); - const typeVisitor = getVisitor(visitor, specifiers); - if (typeVisitor) { - const result: GraphQLNamedType | null | undefined = typeVisitor( - type, - schema, - ); - if (typeof result === 'undefined') { - types[typeName] = recreateType(type, resolveType, !stripResolvers); - } else if (result === null) { - types[typeName] = null; - } else { - types[typeName] = recreateType(result, resolveType, !stripResolvers); - } - } else { - types[typeName] = recreateType(type, resolveType, !stripResolvers); - } - } - }); - return new GraphQLSchema({ - query: queryType ? (types[queryType.name] as GraphQLObjectType) : null, - mutation: mutationType - ? (types[mutationType.name] as GraphQLObjectType) - : null, - subscription: subscriptionType - ? (types[subscriptionType.name] as GraphQLObjectType) - : null, - types: Object.keys(types).map(name => types[name]), - }); -} - -function getTypeSpecifiers( - type: GraphQLType, - schema: GraphQLSchema, -): Array { - const specifiers = [VisitSchemaKind.TYPE]; - if (type instanceof GraphQLObjectType) { - specifiers.unshift( - VisitSchemaKind.COMPOSITE_TYPE, - VisitSchemaKind.OBJECT_TYPE, - ); - const query = schema.getQueryType(); - const mutation = schema.getMutationType(); - const subscription = schema.getSubscriptionType(); - if (type === query) { - specifiers.push(VisitSchemaKind.ROOT_OBJECT, VisitSchemaKind.QUERY); - } else if (type === mutation) { - specifiers.push(VisitSchemaKind.ROOT_OBJECT, VisitSchemaKind.MUTATION); - } else if (type === subscription) { - specifiers.push( - VisitSchemaKind.ROOT_OBJECT, - VisitSchemaKind.SUBSCRIPTION, - ); - } - } else if (type instanceof GraphQLInputObjectType) { - specifiers.push(VisitSchemaKind.INPUT_OBJECT_TYPE); - } else if (type instanceof GraphQLInterfaceType) { - specifiers.push( - VisitSchemaKind.COMPOSITE_TYPE, - VisitSchemaKind.ABSTRACT_TYPE, - VisitSchemaKind.INTERFACE_TYPE, - ); - } else if (type instanceof GraphQLUnionType) { - specifiers.push( - VisitSchemaKind.COMPOSITE_TYPE, - VisitSchemaKind.ABSTRACT_TYPE, - VisitSchemaKind.UNION_TYPE, - ); - } else if (type instanceof GraphQLEnumType) { - specifiers.push(VisitSchemaKind.ENUM_TYPE); - } else if (type instanceof GraphQLScalarType) { - specifiers.push(VisitSchemaKind.SCALAR_TYPE); - } - - return specifiers; -} - -function getVisitor( - visitor: SchemaVisitor, - specifiers: Array, -): TypeVisitor | null { - let typeVisitor = null; - const stack = [...specifiers]; - while (!typeVisitor && stack.length > 0) { - const next = stack.pop(); - typeVisitor = visitor[next]; - } - - return typeVisitor; -} diff --git a/src/utils/SchemaDirectiveVisitor.ts b/src/utils/SchemaDirectiveVisitor.ts new file mode 100644 index 00000000000..e744ce652c9 --- /dev/null +++ b/src/utils/SchemaDirectiveVisitor.ts @@ -0,0 +1,302 @@ +import { + GraphQLDirective, + GraphQLSchema, + DirectiveLocationEnum, + TypeSystemExtensionNode, +} from 'graphql'; +import { getArgumentValues } from 'graphql/execution/values'; + +import { VisitableSchemaType } from '../Interfaces'; + +import each from './each'; +import valueFromASTUntyped from './valueFromASTUntyped'; +import { SchemaVisitor } from './SchemaVisitor'; +import { visitSchema } from './visitSchema'; + +const hasOwn = Object.prototype.hasOwnProperty; + +// This class represents a reusable implementation of a @directive that may +// appear in a GraphQL schema written in Schema Definition Language. +// +// By overriding one or more visit{Object,Union,...} methods, a subclass +// registers interest in certain schema types, such as GraphQLObjectType, +// GraphQLUnionType, etc. When SchemaDirectiveVisitor.visitSchemaDirectives is +// called with a GraphQLSchema object and a map of visitor subclasses, the +// overidden methods of those subclasses allow the visitors to obtain +// references to any type objects that have @directives attached to them, +// enabling visitors to inspect or modify the schema as appropriate. +// +// For example, if a directive called @rest(url: "...") appears after a field +// definition, a SchemaDirectiveVisitor subclass could provide meaning to that +// directive by overriding the visitFieldDefinition method (which receives a +// GraphQLField parameter), and then the body of that visitor method could +// manipulate the field's resolver function to fetch data from a REST endpoint +// described by the url argument passed to the @rest directive: +// +// const typeDefs = ` +// type Query { +// people: [Person] @rest(url: "/api/v1/people") +// }`; +// +// const schema = makeExecutableSchema({ typeDefs }); +// +// SchemaDirectiveVisitor.visitSchemaDirectives(schema, { +// rest: class extends SchemaDirectiveVisitor { +// public visitFieldDefinition(field: GraphQLField) { +// const { url } = this.args; +// field.resolve = () => fetch(url); +// } +// } +// }); +// +// The subclass in this example is defined as an anonymous class expression, +// for brevity. A truly reusable SchemaDirectiveVisitor would most likely be +// defined in a library using a named class declaration, and then exported for +// consumption by other modules and packages. +// +// See below for a complete list of overridable visitor methods, their +// parameter types, and more details about the properties exposed by instances +// of the SchemaDirectiveVisitor class. + +export class SchemaDirectiveVisitor extends SchemaVisitor { + // The name of the directive this visitor is allowed to visit (that is, the + // identifier that appears after the @ character in the schema). Note that + // this property is per-instance rather than static because subclasses of + // SchemaDirectiveVisitor can be instantiated multiple times to visit + // directives of different names. In other words, SchemaDirectiveVisitor + // implementations are effectively anonymous, and it's up to the caller of + // SchemaDirectiveVisitor.visitSchemaDirectives to assign names to them. + public name: string; + + // A map from parameter names to argument values, as obtained from a + // specific occurrence of a @directive(arg1: value1, arg2: value2, ...) in + // the schema. Visitor methods may refer to this object via this.args. + public args: { [name: string]: any }; + + // A reference to the type object that this visitor was created to visit. + public visitedType: VisitableSchemaType; + + // A shared object that will be available to all visitor instances via + // this.context. Callers of visitSchemaDirectives can provide their own + // object, or just use the default empty object. + public context: { [key: string]: any }; + + // Override this method to return a custom GraphQLDirective (or modify one + // already present in the schema) to enforce argument types, provide default + // argument values, or specify schema locations where this @directive may + // appear. By default, any declaration found in the schema will be returned. + public static getDirectiveDeclaration( + directiveName: string, + schema: GraphQLSchema, + ): GraphQLDirective | null | undefined { + return schema.getDirective(directiveName); + } + + // Call SchemaDirectiveVisitor.visitSchemaDirectives to visit every + // @directive in the schema and create an appropriate SchemaDirectiveVisitor + // instance to visit the object decorated by the @directive. + public static visitSchemaDirectives( + schema: GraphQLSchema, + directiveVisitors: { + // The keys of this object correspond to directive names as they appear + // in the schema, and the values should be subclasses (not instances!) + // of the SchemaDirectiveVisitor class. This distinction is important + // because a new SchemaDirectiveVisitor instance will be created each + // time a matching directive is found in the schema AST, with arguments + // and other metadata specific to that occurrence. To help prevent the + // mistake of passing instances, the SchemaDirectiveVisitor constructor + // method is marked as protected. + [directiveName: string]: typeof SchemaDirectiveVisitor; + }, + // Optional context object that will be available to all visitor instances + // via this.context. Defaults to an empty null-prototype object. + context: { + [key: string]: any; + } = Object.create(null), + ): { + // The visitSchemaDirectives method returns a map from directive names to + // lists of SchemaDirectiveVisitors created while visiting the schema. + [directiveName: string]: Array; + } { + // If the schema declares any directives for public consumption, record + // them here so that we can properly coerce arguments when/if we encounter + // an occurrence of the directive while walking the schema below. + const declaredDirectives = this.getDeclaredDirectives( + schema, + directiveVisitors, + ); + + // Map from directive names to lists of SchemaDirectiveVisitor instances + // created while visiting the schema. + const createdVisitors: { + [directiveName: string]: Array; + } = Object.create(null); + Object.keys(directiveVisitors).forEach((directiveName) => { + createdVisitors[directiveName] = []; + }); + + function visitorSelector( + type: VisitableSchemaType, + methodName: string, + ): Array { + let directiveNodes = type.astNode != null ? type.astNode.directives : []; + + const extensionASTNodes: ReadonlyArray = (type as { + extensionASTNodes?: Array; + }).extensionASTNodes; + + if (extensionASTNodes != null) { + extensionASTNodes.forEach((extensionASTNode) => { + directiveNodes = directiveNodes.concat(extensionASTNode.directives); + }); + } + + const visitors: Array = []; + directiveNodes.forEach((directiveNode) => { + const directiveName = directiveNode.name.value; + if (!hasOwn.call(directiveVisitors, directiveName)) { + return; + } + + const visitorClass = directiveVisitors[directiveName]; + + // Avoid creating visitor objects if visitorClass does not override + // the visitor method named by methodName. + if (!visitorClass.implementsVisitorMethod(methodName)) { + return; + } + + const decl = declaredDirectives[directiveName]; + let args: { [key: string]: any }; + + if (decl != null) { + // If this directive was explicitly declared, use the declared + // argument types (and any default values) to check, coerce, and/or + // supply default values for the given arguments. + args = getArgumentValues(decl, directiveNode); + } else { + // If this directive was not explicitly declared, just convert the + // argument nodes to their corresponding JavaScript values. + args = Object.create(null); + if (directiveNode.arguments != null) { + directiveNode.arguments.forEach((arg) => { + args[arg.name.value] = valueFromASTUntyped(arg.value); + }); + } + } + + // As foretold in comments near the top of the visitSchemaDirectives + // method, this is where instances of the SchemaDirectiveVisitor class + // get created and assigned names. While subclasses could override the + // constructor method, the constructor is marked as protected, so + // these are the only arguments that will ever be passed. + visitors.push( + new visitorClass({ + name: directiveName, + args, + visitedType: type, + schema, + context, + }), + ); + }); + + if (visitors.length > 0) { + visitors.forEach((visitor) => { + createdVisitors[visitor.name].push(visitor); + }); + } + + return visitors; + } + + visitSchema(schema, visitorSelector); + + return createdVisitors; + } + + protected static getDeclaredDirectives( + schema: GraphQLSchema, + directiveVisitors: { + [directiveName: string]: typeof SchemaDirectiveVisitor; + }, + ) { + const declaredDirectives: { + [directiveName: string]: GraphQLDirective; + } = Object.create(null); + + each(schema.getDirectives(), (decl: GraphQLDirective) => { + declaredDirectives[decl.name] = decl; + }); + + // If the visitor subclass overrides getDirectiveDeclaration, and it + // returns a non-null GraphQLDirective, use that instead of any directive + // declared in the schema itself. Reasoning: if a SchemaDirectiveVisitor + // goes to the trouble of implementing getDirectiveDeclaration, it should + // be able to rely on that implementation. + each(directiveVisitors, (visitorClass, directiveName) => { + const decl = visitorClass.getDirectiveDeclaration(directiveName, schema); + if (decl != null) { + declaredDirectives[directiveName] = decl; + } + }); + + each(declaredDirectives, (decl, name) => { + if (!hasOwn.call(directiveVisitors, name)) { + // SchemaDirectiveVisitors.visitSchemaDirectives might be called + // multiple times with partial directiveVisitors maps, so it's not + // necessarily an error for directiveVisitors to be missing an + // implementation of a directive that was declared in the schema. + return; + } + const visitorClass = directiveVisitors[name]; + + each(decl.locations, (loc) => { + const visitorMethodName = directiveLocationToVisitorMethodName(loc); + if ( + SchemaVisitor.implementsVisitorMethod(visitorMethodName) && + !visitorClass.implementsVisitorMethod(visitorMethodName) + ) { + // While visitor subclasses may implement extra visitor methods, + // it's definitely a mistake if the GraphQLDirective declares itself + // applicable to certain schema locations, and the visitor subclass + // does not implement all the corresponding methods. + throw new Error( + `SchemaDirectiveVisitor for @${name} must implement ${visitorMethodName} method`, + ); + } + }); + }); + + return declaredDirectives; + } + + // Mark the constructor protected to enforce passing SchemaDirectiveVisitor + // subclasses (not instances) to visitSchemaDirectives. + protected constructor(config: { + name: string; + args: { [name: string]: any }; + visitedType: VisitableSchemaType; + schema: GraphQLSchema; + context: { [key: string]: any }; + }) { + super(); + this.name = config.name; + this.args = config.args; + this.visitedType = config.visitedType; + this.schema = config.schema; + this.context = config.context; + } +} + +// Convert a string like "FIELD_DEFINITION" to "visitFieldDefinition". +function directiveLocationToVisitorMethodName(loc: DirectiveLocationEnum) { + return ( + 'visit' + + loc.replace( + /([^_]*)_?/g, + (_wholeMatch, part: string) => + part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(), + ) + ); +} diff --git a/src/utils/SchemaVisitor.ts b/src/utils/SchemaVisitor.ts new file mode 100644 index 00000000000..84808616fb3 --- /dev/null +++ b/src/utils/SchemaVisitor.ts @@ -0,0 +1,119 @@ +import { + GraphQLArgument, + GraphQLEnumType, + GraphQLEnumValue, + GraphQLField, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLScalarType, + GraphQLSchema, + GraphQLUnionType, +} from 'graphql'; + +// Abstract base class of any visitor implementation, defining the available +// visitor methods along with their parameter types, and providing a static +// helper function for determining whether a subclass implements a given +// visitor method, as opposed to inheriting one of the stubs defined here. +export abstract class SchemaVisitor { + // All SchemaVisitor instances are created while visiting a specific + // GraphQLSchema object, so this property holds a reference to that object, + // in case a visitor method needs to refer to this.schema. + public schema!: GraphQLSchema; + + // Determine if this SchemaVisitor (sub)class implements a particular + // visitor method. + public static implementsVisitorMethod(methodName: string) { + if (!methodName.startsWith('visit')) { + return false; + } + + const method = this.prototype[methodName]; + if (typeof method !== 'function') { + return false; + } + + if (this === SchemaVisitor) { + // The SchemaVisitor class implements every visitor method. + return true; + } + + const stub = SchemaVisitor.prototype[methodName]; + if (method === stub) { + // If this.prototype[methodName] was just inherited from SchemaVisitor, + // then this class does not really implement the method. + return false; + } + + return true; + } + + // Concrete subclasses of SchemaVisitor should override one or more of these + // visitor methods, in order to express their interest in handling certain + // schema types/locations. Each method may return null to remove the given + // type from the schema, a non-null value of the same type to update the + // type in the schema, or nothing to leave the type as it was. + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public visitSchema(_schema: GraphQLSchema): void {} + + public visitScalar( + _scalar: GraphQLScalarType, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): GraphQLScalarType | void | null {} + + public visitObject( + _object: GraphQLObjectType, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): GraphQLObjectType | void | null {} + + public visitFieldDefinition( + _field: GraphQLField, + _details: { + objectType: GraphQLObjectType | GraphQLInterfaceType; + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): GraphQLField | void | null {} + + public visitArgumentDefinition( + _argument: GraphQLArgument, + _details: { + field: GraphQLField; + objectType: GraphQLObjectType | GraphQLInterfaceType; + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): GraphQLArgument | void | null {} + + public visitInterface( + _iface: GraphQLInterfaceType, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): GraphQLInterfaceType | void | null {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public visitUnion(_union: GraphQLUnionType): GraphQLUnionType | void | null {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public visitEnum(_type: GraphQLEnumType): GraphQLEnumType | void | null {} + + public visitEnumValue( + _value: GraphQLEnumValue, + _details: { + enumType: GraphQLEnumType; + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): GraphQLEnumValue | void | null {} + + public visitInputObject( + _object: GraphQLInputObjectType, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): GraphQLInputObjectType | void | null {} + + public visitInputFieldDefinition( + _field: GraphQLInputField, + _details: { + objectType: GraphQLInputObjectType; + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + ): GraphQLInputField | void | null {} +} diff --git a/src/utils/astFromType.ts b/src/utils/astFromType.ts new file mode 100644 index 00000000000..9b4cebaf5a7 --- /dev/null +++ b/src/utils/astFromType.ts @@ -0,0 +1,37 @@ +import { + isNonNullType, + Kind, + GraphQLType, + TypeNode, + isListType, +} from 'graphql'; + +export function astFromType(type: GraphQLType): TypeNode { + if (isNonNullType(type)) { + const innerType = astFromType(type.ofType); + if (innerType.kind === Kind.NON_NULL_TYPE) { + throw new Error( + `Invalid type node ${JSON.stringify( + type, + )}. Inner type of non-null type cannot be a non-null type.`, + ); + } + return { + kind: Kind.NON_NULL_TYPE, + type: innerType, + }; + } else if (isListType(type)) { + return { + kind: Kind.LIST_TYPE, + type: astFromType(type.ofType), + }; + } + + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: type.name, + }, + }; +} diff --git a/src/utils/clone.ts b/src/utils/clone.ts new file mode 100644 index 00000000000..3154d692e6b --- /dev/null +++ b/src/utils/clone.ts @@ -0,0 +1,76 @@ +import { + GraphQLDirective, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLObjectTypeConfig, + GraphQLNamedType, + GraphQLScalarType, + GraphQLSchema, + GraphQLUnionType, + isObjectType, + isInterfaceType, + isUnionType, + isInputObjectType, + isEnumType, + isScalarType, +} from 'graphql'; + +import { isSpecifiedScalarType, toConfig } from '../polyfills/index'; + +import { graphqlVersion } from './graphqlVersion'; +import { mapSchema } from './map'; + +export function cloneDirective(directive: GraphQLDirective): GraphQLDirective { + return new GraphQLDirective(toConfig(directive)); +} + +export function cloneType(type: GraphQLNamedType): GraphQLNamedType { + if (isObjectType(type)) { + const config = toConfig(type); + return new GraphQLObjectType({ + ...config, + interfaces: + typeof config.interfaces === 'function' + ? config.interfaces + : config.interfaces.slice(), + }); + } else if (isInterfaceType(type)) { + const config = toConfig(type); + const newConfig = { + ...config, + interfaces: + graphqlVersion() >= 15 + ? typeof ((config as unknown) as GraphQLObjectTypeConfig) + .interfaces === 'function' + ? ((config as unknown) as GraphQLObjectTypeConfig) + .interfaces + : ((config as unknown) as { + interfaces: Array; + }).interfaces.slice() + : undefined, + }; + return new GraphQLInterfaceType(newConfig); + } else if (isUnionType(type)) { + const config = toConfig(type); + return new GraphQLUnionType({ + ...config, + types: config.types.slice(), + }); + } else if (isInputObjectType(type)) { + return new GraphQLInputObjectType(toConfig(type)); + } else if (isEnumType(type)) { + return new GraphQLEnumType(toConfig(type)); + } else if (isScalarType(type)) { + return isSpecifiedScalarType(type) + ? type + : new GraphQLScalarType(toConfig(type)); + } + + throw new Error(`Invalid type ${type as string}`); +} + +export function cloneSchema(schema: GraphQLSchema): GraphQLSchema { + return mapSchema(schema); +} diff --git a/src/utils/each.ts b/src/utils/each.ts new file mode 100644 index 00000000000..97a55c8131e --- /dev/null +++ b/src/utils/each.ts @@ -0,0 +1,10 @@ +import { IndexedObject } from '../Interfaces'; + +export default function each( + arrayOrObject: IndexedObject, + callback: (value: V, key: string) => void, +) { + Object.keys(arrayOrObject).forEach((key) => { + callback(arrayOrObject[key], key); + }); +} diff --git a/src/utils/fieldNodes.ts b/src/utils/fieldNodes.ts new file mode 100644 index 00000000000..c81b70e93a5 --- /dev/null +++ b/src/utils/fieldNodes.ts @@ -0,0 +1,156 @@ +import { + FieldNode, + Kind, + FragmentDefinitionNode, + SelectionSetNode, +} from 'graphql'; + +export function renameFieldNode(fieldNode: FieldNode, name: string): FieldNode { + return { + ...fieldNode, + alias: { + kind: Kind.NAME, + value: + fieldNode.alias != null ? fieldNode.alias.value : fieldNode.name.value, + }, + name: { + kind: Kind.NAME, + value: name, + }, + }; +} + +export function preAliasFieldNode( + fieldNode: FieldNode, + str: string, +): FieldNode { + return { + ...fieldNode, + alias: { + kind: Kind.NAME, + value: `${str}${ + fieldNode.alias != null ? fieldNode.alias.value : fieldNode.name.value + }`, + }, + }; +} + +export function wrapFieldNode( + fieldNode: FieldNode, + path: Array, +): FieldNode { + let newFieldNode = fieldNode; + path.forEach((fieldName) => { + newFieldNode = { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: fieldName, + }, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [fieldNode], + }, + }; + }); + + return newFieldNode; +} + +export function collectFields( + selectionSet: SelectionSetNode | undefined, + fragments: Record, + fields: Array = [], + visitedFragmentNames = {}, +): Array { + if (selectionSet != null) { + selectionSet.selections.forEach((selection) => { + switch (selection.kind) { + case Kind.FIELD: + fields.push(selection); + break; + case Kind.INLINE_FRAGMENT: + collectFields( + selection.selectionSet, + fragments, + fields, + visitedFragmentNames, + ); + break; + case Kind.FRAGMENT_SPREAD: { + const fragmentName = selection.name.value; + if (!visitedFragmentNames[fragmentName]) { + visitedFragmentNames[fragmentName] = true; + collectFields( + fragments[fragmentName].selectionSet, + fragments, + fields, + visitedFragmentNames, + ); + } + break; + } + default: + // unreachable + break; + } + }); + } + + return fields; +} + +export function hoistFieldNodes({ + fieldNode, + fieldNames, + path = [], + delimeter = '__gqltf__', + fragments, +}: { + fieldNode: FieldNode; + fieldNames?: Array; + path?: Array; + delimeter?: string; + fragments: Record; +}): Array { + const alias = + fieldNode.alias != null ? fieldNode.alias.value : fieldNode.name.value; + + let newFieldNodes: Array = []; + + if (path.length) { + const remainingPathSegments = path.slice(); + const initialPathSegment = remainingPathSegments.shift(); + + collectFields(fieldNode.selectionSet, fragments).forEach( + (possibleFieldNode: FieldNode) => { + if (possibleFieldNode.name.value === initialPathSegment) { + newFieldNodes = newFieldNodes.concat( + hoistFieldNodes({ + fieldNode: preAliasFieldNode( + possibleFieldNode, + `${alias}${delimeter}`, + ), + fieldNames, + path: remainingPathSegments, + delimeter, + fragments, + }), + ); + } + }, + ); + } else { + collectFields(fieldNode.selectionSet, fragments).forEach( + (possibleFieldNode: FieldNode) => { + if (!fieldNames || fieldNames.includes(possibleFieldNode.name.value)) { + newFieldNodes.push( + preAliasFieldNode(possibleFieldNode, `${alias}${delimeter}`), + ); + } + }, + ); + } + + return newFieldNodes; +} diff --git a/src/utils/fields.ts b/src/utils/fields.ts new file mode 100644 index 00000000000..12114f46bbe --- /dev/null +++ b/src/utils/fields.ts @@ -0,0 +1,63 @@ +import { + GraphQLFieldConfigMap, + GraphQLObjectType, + GraphQLFieldConfig, +} from 'graphql'; +import { TypeMap } from 'graphql/type/schema'; + +import { toConfig } from '../polyfills/index'; + +export function appendFields( + typeMap: TypeMap, + typeName: string, + fields: GraphQLFieldConfigMap, +): void { + let type = typeMap[typeName]; + if (type != null) { + const typeConfig = toConfig(type); + const originalFields = typeConfig.fields; + const newFields = {}; + Object.keys(originalFields).forEach((fieldName) => { + newFields[fieldName] = originalFields[fieldName]; + }); + Object.keys(fields).forEach((fieldName) => { + newFields[fieldName] = fields[fieldName]; + }); + type = new GraphQLObjectType({ + ...typeConfig, + fields: newFields, + }); + } else { + type = new GraphQLObjectType({ + name: typeName, + fields, + }); + } + typeMap[typeName] = type; +} + +export function removeFields( + typeMap: TypeMap, + typeName: string, + testFn: (fieldName: string, field: GraphQLFieldConfig) => boolean, +): GraphQLFieldConfigMap { + let type = typeMap[typeName]; + const typeConfig = toConfig(type); + const originalFields = typeConfig.fields; + const newFields = {}; + const removedFields = {}; + Object.keys(originalFields).forEach((fieldName) => { + if (testFn(fieldName, originalFields[fieldName])) { + removedFields[fieldName] = originalFields[fieldName]; + } else { + newFields[fieldName] = originalFields[fieldName]; + } + }); + type = new GraphQLObjectType({ + ...typeConfig, + fields: newFields, + }); + typeMap[typeName] = type; + + return removedFields; +} diff --git a/src/utils/filterSchema.ts b/src/utils/filterSchema.ts new file mode 100644 index 00000000000..060e7009387 --- /dev/null +++ b/src/utils/filterSchema.ts @@ -0,0 +1,85 @@ +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLScalarType, + GraphQLUnionType, + GraphQLType, +} from 'graphql'; + +import { + GraphQLSchemaWithTransforms, + MapperKind, + FieldFilter, + RootFieldFilter, +} from '../Interfaces'; +import { toConfig } from '../polyfills/index'; + +import { mapSchema } from './map'; + +export default function filterSchema({ + schema, + rootFieldFilter = () => true, + typeFilter = () => true, + fieldFilter = () => true, +}: { + schema: GraphQLSchemaWithTransforms; + rootFieldFilter?: RootFieldFilter; + typeFilter?: (typeName: string, type: GraphQLType) => boolean; + fieldFilter?: (typeName: string, fieldName: string) => boolean; +}): GraphQLSchemaWithTransforms { + const filteredSchema: GraphQLSchemaWithTransforms = mapSchema(schema, { + [MapperKind.QUERY]: (type: GraphQLObjectType) => + filterRootFields(type, 'Query', rootFieldFilter), + [MapperKind.MUTATION]: (type: GraphQLObjectType) => + filterRootFields(type, 'Mutation', rootFieldFilter), + [MapperKind.SUBSCRIPTION]: (type: GraphQLObjectType) => + filterRootFields(type, 'Subscription', rootFieldFilter), + [MapperKind.OBJECT_TYPE]: (type: GraphQLObjectType) => + typeFilter(type.name, type) + ? filterObjectFields(type, fieldFilter) + : null, + [MapperKind.INTERFACE_TYPE]: (type: GraphQLInterfaceType) => + typeFilter(type.name, type) ? undefined : null, + [MapperKind.UNION_TYPE]: (type: GraphQLUnionType) => + typeFilter(type.name, type) ? undefined : null, + [MapperKind.INPUT_OBJECT_TYPE]: (type: GraphQLInputObjectType) => + typeFilter(type.name, type) ? undefined : null, + [MapperKind.ENUM_TYPE]: (type: GraphQLEnumType) => + typeFilter(type.name, type) ? undefined : null, + [MapperKind.SCALAR_TYPE]: (type: GraphQLScalarType) => + typeFilter(type.name, type) ? undefined : null, + }); + + filteredSchema.transforms = schema.transforms; + + return filteredSchema; +} + +function filterRootFields( + type: GraphQLObjectType, + operation: 'Query' | 'Mutation' | 'Subscription', + rootFieldFilter: RootFieldFilter, +): GraphQLObjectType { + const config = toConfig(type); + Object.keys(config.fields).forEach((fieldName) => { + if (!rootFieldFilter(operation, fieldName, config.fields[fieldName])) { + delete config.fields[fieldName]; + } + }); + return new GraphQLObjectType(config); +} + +function filterObjectFields( + type: GraphQLObjectType, + fieldFilter: FieldFilter, +): GraphQLObjectType { + const config = toConfig(type); + Object.keys(config.fields).forEach((fieldName) => { + if (!fieldFilter(type.name, fieldName, config.fields[fieldName])) { + delete config.fields[fieldName]; + } + }); + return new GraphQLObjectType(config); +} diff --git a/src/utils/forEachDefaultValue.ts b/src/utils/forEachDefaultValue.ts new file mode 100644 index 00000000000..e6cf8692ed7 --- /dev/null +++ b/src/utils/forEachDefaultValue.ts @@ -0,0 +1,37 @@ +import { + getNamedType, + GraphQLSchema, + isObjectType, + isInputObjectType, +} from 'graphql'; + +import { IDefaultValueIteratorFn } from '../Interfaces'; + +export function forEachDefaultValue( + schema: GraphQLSchema, + fn: IDefaultValueIteratorFn, +): void { + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).forEach((typeName) => { + const type = typeMap[typeName]; + + if (!getNamedType(type).name.startsWith('__')) { + if (isObjectType(type)) { + const fields = type.getFields(); + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + + field.args.forEach((arg) => { + arg.defaultValue = fn(arg.type, arg.defaultValue); + }); + }); + } else if (isInputObjectType(type)) { + const fields = type.getFields(); + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + field.defaultValue = fn(field.type, field.defaultValue); + }); + } + } + }); +} diff --git a/src/utils/forEachField.ts b/src/utils/forEachField.ts new file mode 100644 index 00000000000..9b71b72d4d2 --- /dev/null +++ b/src/utils/forEachField.ts @@ -0,0 +1,22 @@ +import { getNamedType, GraphQLSchema, isObjectType } from 'graphql'; + +import { IFieldIteratorFn } from '../Interfaces'; + +export function forEachField( + schema: GraphQLSchema, + fn: IFieldIteratorFn, +): void { + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).forEach((typeName) => { + const type = typeMap[typeName]; + + // TODO: maybe have an option to include these? + if (!getNamedType(type).name.startsWith('__') && isObjectType(type)) { + const fields = type.getFields(); + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + fn(field, typeName, fieldName); + }); + } + }); +} diff --git a/src/utils/fragments.ts b/src/utils/fragments.ts new file mode 100644 index 00000000000..84bc9434d79 --- /dev/null +++ b/src/utils/fragments.ts @@ -0,0 +1,137 @@ +import { + InlineFragmentNode, + SelectionNode, + Kind, + parse, + OperationDefinitionNode, +} from 'graphql'; + +export function concatInlineFragments( + type: string, + fragments: Array, +): InlineFragmentNode { + const fragmentSelections: Array = fragments.reduce( + (selections, fragment) => + selections.concat(fragment.selectionSet.selections), + [], + ); + + const deduplicatedFragmentSelection: Array = deduplicateSelection( + fragmentSelections, + ); + + return { + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: type, + }, + }, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: deduplicatedFragmentSelection, + }, + }; +} + +const hasOwn = Object.prototype.hasOwnProperty; + +function deduplicateSelection( + nodes: Array, +): Array { + const selectionMap = nodes.reduce<{ [key: string]: SelectionNode }>( + (map, node) => { + switch (node.kind) { + case 'Field': { + if (node.alias != null) { + if (hasOwn.call(map, node.alias.value)) { + return map; + } + + return { + ...map, + [node.alias.value]: node, + }; + } + + if (hasOwn.call(map, node.name.value)) { + return map; + } + + return { + ...map, + [node.name.value]: node, + }; + } + case 'FragmentSpread': { + if (hasOwn.call(map, node.name.value)) { + return map; + } + + return { + ...map, + [node.name.value]: node, + }; + } + case 'InlineFragment': { + if (map.__fragment != null) { + const fragment = map.__fragment as InlineFragmentNode; + + return { + ...map, + __fragment: concatInlineFragments( + fragment.typeCondition.name.value, + [fragment, node], + ), + }; + } + + return { + ...map, + __fragment: node, + }; + } + default: { + return map; + } + } + }, + {}, + ); + + const selection = Object.keys(selectionMap).reduce( + (selectionList, node) => selectionList.concat(selectionMap[node]), + [], + ); + + return selection; +} + +export function parseFragmentToInlineFragment( + definitions: string, +): InlineFragmentNode { + if (definitions.trim().startsWith('fragment')) { + const document = parse(definitions); + for (const definition of document.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + return { + kind: Kind.INLINE_FRAGMENT, + typeCondition: definition.typeCondition, + selectionSet: definition.selectionSet, + }; + } + } + } + + const query = parse(`{${definitions}}`) + .definitions[0] as OperationDefinitionNode; + for (const selection of query.selectionSet.selections) { + if (selection.kind === Kind.INLINE_FRAGMENT) { + return selection; + } + } + + throw new Error('Could not parse fragment'); +} diff --git a/src/utils/getResolversFromSchema.ts b/src/utils/getResolversFromSchema.ts new file mode 100644 index 00000000000..05383782ca9 --- /dev/null +++ b/src/utils/getResolversFromSchema.ts @@ -0,0 +1,66 @@ +import { + GraphQLSchema, + isScalarType, + isEnumType, + isInterfaceType, + isUnionType, + isObjectType, +} from 'graphql'; + +import { IResolvers } from '../Interfaces'; +import { isSpecifiedScalarType } from '../polyfills/index'; + +import { cloneType } from './clone'; + +export function getResolversFromSchema(schema: GraphQLSchema): IResolvers { + const resolvers = Object.create({}); + + const typeMap = schema.getTypeMap(); + + Object.keys(typeMap).forEach((typeName) => { + const type = typeMap[typeName]; + + if (isScalarType(type)) { + if (!isSpecifiedScalarType(type)) { + resolvers[typeName] = cloneType(type); + } + } else if (isEnumType(type)) { + resolvers[typeName] = {}; + + const values = type.getValues(); + values.forEach((value) => { + resolvers[typeName][value.name] = value.value; + }); + } else if (isInterfaceType(type)) { + if (type.resolveType != null) { + resolvers[typeName] = { + __resolveType: type.resolveType, + }; + } + } else if (isUnionType(type)) { + if (type.resolveType != null) { + resolvers[typeName] = { + __resolveType: type.resolveType, + }; + } + } else if (isObjectType(type)) { + resolvers[typeName] = {}; + + if (type.isTypeOf != null) { + resolvers[typeName].__isTypeOf = type.isTypeOf; + } + + const fields = type.getFields(); + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + + resolvers[typeName][fieldName] = { + resolve: field.resolve, + subscribe: field.subscribe, + }; + }); + } + }); + + return resolvers; +} diff --git a/src/utils/graphqlVersion.ts b/src/utils/graphqlVersion.ts new file mode 100644 index 00000000000..857b27643e7 --- /dev/null +++ b/src/utils/graphqlVersion.ts @@ -0,0 +1,24 @@ +import { + versionInfo, + getOperationRootType, + lexicographicSortSchema, + printError, +} from 'graphql'; + +let version: number; + +if (versionInfo != null && versionInfo.major >= 15) { + version = 15; +} else if (getOperationRootType != null) { + version = 14; +} else if (lexicographicSortSchema != null) { + version = 13; +} else if (printError != null) { + version = 12; +} else { + version = 11; +} + +export function graphqlVersion() { + return version; +} diff --git a/src/utils/heal.ts b/src/utils/heal.ts new file mode 100644 index 00000000000..53fd8e01367 --- /dev/null +++ b/src/utils/heal.ts @@ -0,0 +1,311 @@ +import { + GraphQLDirective, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLList, + GraphQLObjectType, + GraphQLNamedType, + GraphQLNonNull, + GraphQLType, + GraphQLUnionType, + isNamedType, + GraphQLSchema, + GraphQLInputType, + GraphQLOutputType, + isObjectType, + isInterfaceType, + isUnionType, + isInputObjectType, + isLeafType, + isListType, + isNonNullType, +} from 'graphql'; + +import { toConfig } from '../polyfills/index'; + +import each from './each'; +import updateEachKey from './updateEachKey'; +import { isStub, getBuiltInForStub } from './stub'; +import { graphqlVersion } from './graphqlVersion'; + +type NamedTypeMap = { + [key: string]: GraphQLNamedType; +}; + +const hasOwn = Object.prototype.hasOwnProperty; + +// Update any references to named schema types that disagree with the named +// types found in schema.getTypeMap(). +export function healSchema(schema: GraphQLSchema): GraphQLSchema { + const typeMap = schema.getTypeMap(); + const directives = schema.getDirectives(); + + const queryType = schema.getQueryType(); + const mutationType = schema.getMutationType(); + const subscriptionType = schema.getSubscriptionType(); + + const newQueryTypeName = + queryType != null + ? typeMap[queryType.name] != null + ? typeMap[queryType.name].name + : undefined + : undefined; + const newMutationTypeName = + mutationType != null + ? typeMap[mutationType.name] != null + ? typeMap[mutationType.name].name + : undefined + : undefined; + const newSubscriptionTypeName = + subscriptionType != null + ? typeMap[subscriptionType.name] != null + ? typeMap[subscriptionType.name].name + : undefined + : undefined; + + healTypes(typeMap, directives); + + const filteredTypeMap = {}; + + Object.keys(typeMap).forEach((typeName) => { + if (!typeName.startsWith('__')) { + filteredTypeMap[typeName] = typeMap[typeName]; + } + }); + + const healedSchema = new GraphQLSchema({ + ...toConfig(schema), + query: newQueryTypeName ? filteredTypeMap[newQueryTypeName] : undefined, + mutation: newMutationTypeName + ? filteredTypeMap[newMutationTypeName] + : undefined, + subscription: newSubscriptionTypeName + ? filteredTypeMap[newSubscriptionTypeName] + : undefined, + types: Object.keys(filteredTypeMap).map( + (typeName) => filteredTypeMap[typeName], + ), + directives: directives.slice(), + }); + + // Reconstruct the schema to reinitialize private variables + // e.g. the stored implementation map and the proper root types. + Object.assign(schema, healedSchema); + + return schema; +} + +export function healTypes( + originalTypeMap: Record, + directives: ReadonlyArray, + config: { + skipPruning: boolean; + } = { + skipPruning: false, + }, +) { + const actualNamedTypeMap: NamedTypeMap = Object.create(null); + + // If any of the .name properties of the GraphQLNamedType objects in + // schema.getTypeMap() have changed, the keys of the type map need to + // be updated accordingly. + + each(originalTypeMap, (namedType, typeName) => { + if (namedType == null || typeName.startsWith('__')) { + return; + } + + const actualName = namedType.name; + if (actualName.startsWith('__')) { + return; + } + + if (hasOwn.call(actualNamedTypeMap, actualName)) { + throw new Error(`Duplicate schema type name ${actualName}`); + } + + actualNamedTypeMap[actualName] = namedType; + + // Note: we are deliberately leaving namedType in the schema by its + // original name (which might be different from actualName), so that + // references by that name can be healed. + }); + + // Now add back every named type by its actual name. + each(actualNamedTypeMap, (namedType, typeName) => { + originalTypeMap[typeName] = namedType; + }); + + // Directive declaration argument types can refer to named types. + each(directives, (decl: GraphQLDirective) => { + updateEachKey(decl.args, (arg) => { + arg.type = healType(arg.type) as GraphQLInputType; + return arg.type === null ? null : arg; + }); + }); + + each(originalTypeMap, (namedType, typeName) => { + // Heal all named types, except for dangling references, kept only to redirect. + if ( + !typeName.startsWith('__') && + hasOwn.call(actualNamedTypeMap, typeName) + ) { + if (namedType != null) { + healNamedType(namedType); + } + } + }); + + updateEachKey(originalTypeMap, (_namedType, typeName) => { + // Dangling references to renamed types should remain in the schema + // during healing, but must be removed now, so that the following + // invariant holds for all names: schema.getType(name).name === name + if ( + !typeName.startsWith('__') && + !hasOwn.call(actualNamedTypeMap, typeName) + ) { + return null; + } + }); + + if (!config.skipPruning) { + pruneTypes(originalTypeMap, directives); + } + + function healNamedType(type: GraphQLNamedType) { + if (isObjectType(type)) { + healFields(type); + healInterfaces(type); + return; + } else if (isInterfaceType(type)) { + healFields(type); + if (graphqlVersion() >= 15) { + healInterfaces(type); + } + return; + } else if (isUnionType(type)) { + healUnderlyingTypes(type); + return; + } else if (isInputObjectType(type)) { + healInputFields(type); + return; + } else if (isLeafType(type)) { + return; + } + + throw new Error(`Unexpected schema type: ${type as string}`); + } + + function healFields(type: GraphQLObjectType | GraphQLInterfaceType) { + updateEachKey(type.getFields(), (field) => { + updateEachKey(field.args, (arg) => { + arg.type = healType(arg.type) as GraphQLInputType; + return arg.type === null ? null : arg; + }); + field.type = healType(field.type) as GraphQLOutputType; + return field.type === null ? null : field; + }); + } + + function healInterfaces(type: GraphQLObjectType | GraphQLInterfaceType) { + updateEachKey((type as GraphQLObjectType).getInterfaces(), (iface) => { + const healedType = healType(iface) as GraphQLInterfaceType; + return healedType; + }); + } + + function healInputFields(type: GraphQLInputObjectType) { + updateEachKey(type.getFields(), (field) => { + field.type = healType(field.type) as GraphQLInputType; + return field.type === null ? null : field; + }); + } + + function healUnderlyingTypes(type: GraphQLUnionType) { + updateEachKey(type.getTypes(), (t: GraphQLOutputType) => { + const healedType = healType(t) as GraphQLOutputType; + return healedType; + }); + } + + function healType(type: T): GraphQLType | null { + // Unwrap the two known wrapper types + if (isListType(type)) { + const healedType = healType(type.ofType); + return healedType != null ? new GraphQLList(healedType) : null; + } else if (isNonNullType(type)) { + const healedType = healType(type.ofType); + return healedType != null ? new GraphQLNonNull(healedType) : null; + } else if (isNamedType(type)) { + // If a type annotation on a field or an argument or a union member is + // any `GraphQLNamedType` with a `name`, then it must end up identical + // to `schema.getType(name)`, since `schema.getTypeMap()` is the source + // of truth for all named schema types. + // Note that new types can still be simply added by adding a field, as + // the official type will be undefined, not null. + let officialType = originalTypeMap[type.name]; + if (officialType === undefined) { + if (isStub(type)) { + officialType = getBuiltInForStub(type); + } else { + officialType = type; + } + originalTypeMap[type.name] = officialType; + } + return officialType; + } + + return null; + } +} + +function pruneTypes( + typeMap: Record, + directives: ReadonlyArray, +) { + const implementedInterfaces = {}; + each(typeMap, (namedType) => { + if ( + isObjectType(namedType) || + (graphqlVersion() >= 15 && isInterfaceType(namedType)) + ) { + each((namedType as GraphQLObjectType).getInterfaces(), (iface) => { + implementedInterfaces[iface.name] = true; + }); + } + }); + + let prunedTypeMap = false; + const typeNames = Object.keys(typeMap); + for (let i = 0; i < typeNames.length; i++) { + const typeName = typeNames[i]; + const type = typeMap[typeName]; + if (isObjectType(type) || isInputObjectType(type)) { + // prune types with no fields + if (!Object.keys(type.getFields()).length) { + typeMap[typeName] = null; + prunedTypeMap = true; + } + } else if (isUnionType(type)) { + // prune unions without underlying types + if (!type.getTypes().length) { + typeMap[typeName] = null; + prunedTypeMap = true; + } + } else if (isInterfaceType(type)) { + // prune interfaces without fields or without implementations + if ( + !Object.keys(type.getFields()).length || + !implementedInterfaces[type.name] + ) { + typeMap[typeName] = null; + prunedTypeMap = true; + } + } + } + + // every prune requires another round of healing + if (prunedTypeMap) { + healTypes(typeMap, directives); + } +} diff --git a/src/implementsAbstractType.ts b/src/utils/implementsAbstractType.ts similarity index 92% rename from src/implementsAbstractType.ts rename to src/utils/implementsAbstractType.ts index b54b51db073..fa7b50a9bdc 100644 --- a/src/implementsAbstractType.ts +++ b/src/utils/implementsAbstractType.ts @@ -14,7 +14,7 @@ export default function implementsAbstractType( return true; } else if (isCompositeType(typeA) && isCompositeType(typeB)) { return doTypesOverlap(schema, typeA, typeB); - } else { - return false; } + + return false; } diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000000..c7d38cba3da --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,31 @@ +export { default as filterSchema } from './filterSchema'; +export { cloneSchema, cloneDirective, cloneType } from './clone'; +export { healSchema, healTypes } from './heal'; +export { SchemaVisitor } from './SchemaVisitor'; +export { SchemaDirectiveVisitor } from './SchemaDirectiveVisitor'; +export { visitSchema } from './visitSchema'; +export { getResolversFromSchema } from './getResolversFromSchema'; +export { forEachField } from './forEachField'; +export { forEachDefaultValue } from './forEachDefaultValue'; +export { + transformInputValue, + parseInputValue, + parseInputValueLiteral, + serializeInputValue, +} from './transformInputValue'; +export { + concatInlineFragments, + parseFragmentToInlineFragment, +} from './fragments'; +export { parseSelectionSet, typeContainsSelectionSet } from './selectionSets'; +export { mergeDeep } from './mergeDeep'; +export { + collectFields, + wrapFieldNode, + renameFieldNode, + hoistFieldNodes, +} from './fieldNodes'; +export { appendFields, removeFields } from './fields'; +export { createNamedStub } from './stub'; +export { graphqlVersion } from './graphqlVersion'; +export { mapSchema } from './map'; diff --git a/src/isEmptyObject.ts b/src/utils/isEmptyObject.ts similarity index 60% rename from src/isEmptyObject.ts rename to src/utils/isEmptyObject.ts index 4533d20ef86..9630d01b574 100644 --- a/src/isEmptyObject.ts +++ b/src/utils/isEmptyObject.ts @@ -1,5 +1,5 @@ -export default function isEmptyObject(obj: Object): boolean { - if (!obj) { +export default function isEmptyObject(obj: Record): boolean { + if (obj == null) { return true; } diff --git a/src/utils/map.ts b/src/utils/map.ts new file mode 100644 index 00000000000..6e4c18b09ed --- /dev/null +++ b/src/utils/map.ts @@ -0,0 +1,425 @@ +import { + GraphQLDirective, + GraphQLEnumType, + GraphQLFieldConfigArgumentMap, + GraphQLFieldConfigMap, + GraphQLInputFieldConfigMap, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLList, + GraphQLObjectType, + GraphQLNamedType, + GraphQLNonNull, + GraphQLScalarType, + GraphQLSchema, + GraphQLType, + GraphQLUnionType, + isDirective, + isInterfaceType, + isEnumType, + isInputType, + isInputObjectType, + isListType, + isNamedType, + isNonNullType, + isObjectType, + isScalarType, + isUnionType, + GraphQLObjectTypeConfig, +} from 'graphql'; + +import { toConfig, isSpecifiedScalarType } from '../polyfills/index'; +import { graphqlVersion } from '../utils/index'; +import { + SchemaMapper, + MapperKind, + NamedTypeMapper, + DirectiveMapper, +} from '../Interfaces'; + +export function mapSchema( + schema: GraphQLSchema, + schemaMapper: SchemaMapper = {}, +): GraphQLSchema { + const originalTypeMap = schema.getTypeMap(); + const newTypeMap = {}; + Object.keys(originalTypeMap).forEach((typeName) => { + if (!typeName.startsWith('__')) { + const typeMapper = getMapper( + schema, + schemaMapper, + originalTypeMap[typeName], + ); + + if (typeMapper != null) { + const newType = typeMapper(originalTypeMap[typeName], schema); + newTypeMap[typeName] = + newType !== undefined ? newType : originalTypeMap[typeName]; + } else { + newTypeMap[typeName] = originalTypeMap[typeName]; + } + } + }); + + const queryType = schema.getQueryType(); + const mutationType = schema.getMutationType(); + const subscriptionType = schema.getSubscriptionType(); + + const newQueryTypeName = + queryType != null + ? newTypeMap[queryType.name] != null + ? newTypeMap[queryType.name].name + : undefined + : undefined; + const newMutationTypeName = + mutationType != null + ? newTypeMap[mutationType.name] != null + ? newTypeMap[mutationType.name].name + : undefined + : undefined; + const newSubscriptionTypeName = + subscriptionType != null + ? newTypeMap[subscriptionType.name] != null + ? newTypeMap[subscriptionType.name].name + : undefined + : undefined; + + const originalDirectives = schema.getDirectives(); + const newDirectives: Array = []; + originalDirectives.forEach((directive) => { + const directiveMapper = getMapper(schema, schemaMapper, directive); + if (directiveMapper != null) { + const newDirective = directiveMapper(directive, schema); + if (newDirective != null) { + newDirectives.push(newDirective); + } + } else { + newDirectives.push(directive); + } + }); + + const { typeMap, directives } = rewireTypes(newTypeMap, newDirectives); + + return new GraphQLSchema({ + ...toConfig(schema), + query: newQueryTypeName + ? (typeMap[newQueryTypeName] as GraphQLObjectType) + : undefined, + mutation: newMutationTypeName + ? (typeMap[newMutationTypeName] as GraphQLObjectType) + : undefined, + subscription: + newSubscriptionTypeName != null + ? (typeMap[newSubscriptionTypeName] as GraphQLObjectType) + : undefined, + types: Object.keys(typeMap).map((typeName) => typeMap[typeName]), + directives, + }); +} + +function getTypeSpecifiers( + type: GraphQLType, + schema: GraphQLSchema, +): Array { + const specifiers = [MapperKind.TYPE]; + if (isObjectType(type)) { + specifiers.push(MapperKind.COMPOSITE_TYPE, MapperKind.OBJECT_TYPE); + const query = schema.getQueryType(); + const mutation = schema.getMutationType(); + const subscription = schema.getSubscriptionType(); + if (type === query) { + specifiers.push(MapperKind.ROOT_OBJECT, MapperKind.QUERY); + } else if (type === mutation) { + specifiers.push(MapperKind.ROOT_OBJECT, MapperKind.MUTATION); + } else if (type === subscription) { + specifiers.push(MapperKind.ROOT_OBJECT, MapperKind.SUBSCRIPTION); + } + } else if (isInputType(type)) { + specifiers.push(MapperKind.INPUT_OBJECT_TYPE); + } else if (isInterfaceType(type)) { + specifiers.push( + MapperKind.COMPOSITE_TYPE, + MapperKind.ABSTRACT_TYPE, + MapperKind.INTERFACE_TYPE, + ); + } else if (isUnionType(type)) { + specifiers.push( + MapperKind.COMPOSITE_TYPE, + MapperKind.ABSTRACT_TYPE, + MapperKind.UNION_TYPE, + ); + } else if (isEnumType(type)) { + specifiers.push(MapperKind.ENUM_TYPE); + } else if (isScalarType(type)) { + specifiers.push(MapperKind.SCALAR_TYPE); + } + + return specifiers; +} + +function getMapper( + schema: GraphQLSchema, + schemaMapper: SchemaMapper, + typeOrDirective: GraphQLNamedType, +): NamedTypeMapper | null; +function getMapper( + schema: GraphQLSchema, + schemaMapper: SchemaMapper, + typeOrDirective: GraphQLDirective, +): DirectiveMapper | null; +function getMapper( + schema: GraphQLSchema, + schemaMapper: SchemaMapper, + typeOrDirective: any, +): any { + if (isNamedType(typeOrDirective)) { + const specifiers = getTypeSpecifiers(typeOrDirective, schema); + let typeMapper: NamedTypeMapper | undefined; + const stack = [...specifiers]; + while (!typeMapper && stack.length > 0) { + const next = stack.pop(); + typeMapper = schemaMapper[next] as NamedTypeMapper; + } + + return typeMapper != null ? typeMapper : null; + } else if (isDirective(typeOrDirective)) { + const directiveMapper = schemaMapper[MapperKind.DIRECTIVE]; + return directiveMapper != null ? directiveMapper : null; + } +} + +export function rewireTypes( + originalTypeMap: Record, + directives: ReadonlyArray, +): { + typeMap: Record; + directives: Array; +} { + const newTypeMap: Record = Object.create(null); + + Object.keys(originalTypeMap).forEach((typeName) => { + const namedType = originalTypeMap[typeName]; + + if (namedType == null || typeName.startsWith('__')) { + return; + } + + const newName = namedType.name; + if (newName.startsWith('__')) { + return; + } + + if (newTypeMap[newName] != null) { + throw new Error(`Duplicate schema type name ${newName}`); + } + + newTypeMap[newName] = namedType; + }); + + Object.keys(newTypeMap).forEach((typeName) => { + newTypeMap[typeName] = rewireNamedType(newTypeMap[typeName]); + }); + + const newDirectives = directives.map((directive) => + rewireDirective(directive), + ); + + return pruneTypes(newTypeMap, newDirectives); + + function rewireDirective(directive: GraphQLDirective): GraphQLDirective { + const directiveConfig = toConfig(directive); + directiveConfig.args = rewireArgs(directiveConfig.args); + return new GraphQLDirective(directiveConfig); + } + + function rewireArgs( + args: GraphQLFieldConfigArgumentMap, + ): GraphQLFieldConfigArgumentMap { + const rewiredArgs = {}; + Object.keys(args).forEach((argName) => { + const arg = args[argName]; + const rewiredArgType = rewireType(arg.type); + if (rewiredArgType != null) { + arg.type = rewiredArgType; + rewiredArgs[argName] = arg; + } + }); + return rewiredArgs; + } + + function rewireNamedType(type: T) { + if (isObjectType(type)) { + const config = toConfig(type); + const newConfig = { + ...config, + fields: () => rewireFields(config.fields), + interfaces: () => rewireNamedTypes(config.interfaces), + }; + return new GraphQLObjectType(newConfig); + } else if (isInterfaceType(type)) { + const config = toConfig(type); + const newConfig = { + ...config, + fields: () => rewireFields(config.fields), + }; + if (graphqlVersion() >= 15) { + ((newConfig as unknown) as GraphQLObjectTypeConfig< + any, + any + >).interfaces = () => + rewireNamedTypes( + ((config as unknown) as { interfaces: Array }) + .interfaces, + ); + } + return new GraphQLInterfaceType(newConfig); + } else if (isUnionType(type)) { + const config = toConfig(type); + const newConfig = { + ...config, + types: () => rewireNamedTypes(config.types), + }; + return new GraphQLUnionType(newConfig); + } else if (isInputObjectType(type)) { + const config = toConfig(type); + const newConfig = { + ...config, + fields: () => rewireInputFields(config.fields), + }; + return new GraphQLInputObjectType(newConfig); + } else if (isEnumType(type)) { + const enumConfig = toConfig(type); + return new GraphQLEnumType(enumConfig); + } else if (isScalarType(type)) { + if (isSpecifiedScalarType(type)) { + return type; + } + const scalarConfig = toConfig(type); + return new GraphQLScalarType(scalarConfig); + } + + throw new Error(`Unexpected schema type: ${(type as unknown) as string}`); + } + + function rewireFields( + fields: GraphQLFieldConfigMap, + ): GraphQLFieldConfigMap { + const rewiredFields = {}; + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + const rewiredFieldType = rewireType(field.type); + if (rewiredFieldType != null) { + field.type = rewiredFieldType; + field.args = rewireArgs(field.args); + rewiredFields[fieldName] = field; + } + }); + return rewiredFields; + } + + function rewireInputFields( + fields: GraphQLInputFieldConfigMap, + ): GraphQLInputFieldConfigMap { + const rewiredFields = {}; + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + const rewiredFieldType = rewireType(field.type); + if (rewiredFieldType != null) { + field.type = rewiredFieldType; + rewiredFields[fieldName] = field; + } + }); + return rewiredFields; + } + + function rewireNamedTypes(namedTypes: Array) { + const rewiredTypes: Array = []; + namedTypes.forEach((namedType) => { + const rewiredType = rewireType(namedType); + if (rewiredType != null) { + rewiredTypes.push(rewiredType); + } + }); + return rewiredTypes; + } + + function rewireType(type: T): T | null { + if (isListType(type)) { + const rewiredType = rewireType(type.ofType); + return rewiredType != null ? (new GraphQLList(rewiredType) as T) : null; + } else if (isNonNullType(type)) { + const rewiredType = rewireType(type.ofType); + return rewiredType != null + ? (new GraphQLNonNull(rewiredType) as T) + : null; + } else if (isNamedType(type)) { + const originalType = originalTypeMap[type.name]; + return originalType != null ? (newTypeMap[originalType.name] as T) : null; + } + + return null; + } +} + +function pruneTypes( + typeMap: Record, + directives: Array, +): { + typeMap: Record; + directives: Array; +} { + const newTypeMap = {}; + + const implementedInterfaces = {}; + Object.keys(typeMap).forEach((typeName) => { + const namedType = typeMap[typeName]; + + if ( + isObjectType(namedType) || + (graphqlVersion() >= 15 && isInterfaceType(namedType)) + ) { + (namedType as GraphQLObjectType).getInterfaces().forEach((iface) => { + implementedInterfaces[iface.name] = true; + }); + } + }); + + let prunedTypeMap = false; + const typeNames = Object.keys(typeMap); + for (let i = 0; i < typeNames.length; i++) { + const typeName = typeNames[i]; + const type = typeMap[typeName]; + if (isObjectType(type) || isInputObjectType(type)) { + // prune types with no fields + if (Object.keys(type.getFields()).length) { + newTypeMap[typeName] = type; + } else { + prunedTypeMap = true; + } + } else if (isUnionType(type)) { + // prune unions without underlying types + if (type.getTypes().length) { + newTypeMap[typeName] = type; + } else { + prunedTypeMap = true; + } + } else if (isInterfaceType(type)) { + // prune interfaces without fields or without implementations + if ( + Object.keys(type.getFields()).length && + implementedInterfaces[type.name] + ) { + newTypeMap[typeName] = type; + } else { + prunedTypeMap = true; + } + } else { + newTypeMap[typeName] = type; + } + } + + // every prune requires another round of healing + return prunedTypeMap + ? rewireTypes(newTypeMap, directives) + : { typeMap, directives }; +} diff --git a/src/utils/mergeDeep.ts b/src/utils/mergeDeep.ts new file mode 100644 index 00000000000..0b5cd0abd4c --- /dev/null +++ b/src/utils/mergeDeep.ts @@ -0,0 +1,25 @@ +export function mergeDeep(target: any, ...sources: any): any { + const output = { + ...target, + }; + sources.forEach((source: any) => { + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach((key) => { + if (isObject(source[key])) { + if (!(key in target)) { + Object.assign(output, { [key]: source[key] }); + } else { + output[key] = mergeDeep(target[key], source[key]); + } + } else { + Object.assign(output, { [key]: source[key] }); + } + }); + } + }); + return output; +} + +function isObject(item: any): boolean { + return item && typeof item === 'object' && !Array.isArray(item); +} diff --git a/src/utils/selectionSets.ts b/src/utils/selectionSets.ts new file mode 100644 index 00000000000..665b4440741 --- /dev/null +++ b/src/utils/selectionSets.ts @@ -0,0 +1,47 @@ +import { + OperationDefinitionNode, + SelectionSetNode, + parse, + Kind, + GraphQLObjectType, + getNamedType, +} from 'graphql'; + +export function parseSelectionSet(selectionSet: string): SelectionSetNode { + const query = parse(selectionSet).definitions[0] as OperationDefinitionNode; + return query.selectionSet; +} + +export function typeContainsSelectionSet( + type: GraphQLObjectType, + selectionSet: SelectionSetNode, +): boolean { + const fields = type.getFields(); + + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + const field = fields[selection.name.value]; + + if (field == null) { + return false; + } + + if (selection.selectionSet != null) { + return typeContainsSelectionSet( + getNamedType(field.type) as GraphQLObjectType, + selection.selectionSet, + ); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT) { + const containsSelectionSet = typeContainsSelectionSet( + type, + selection.selectionSet, + ); + if (!containsSelectionSet) { + return false; + } + } + } + + return true; +} diff --git a/src/utils/stub.ts b/src/utils/stub.ts new file mode 100644 index 00000000000..be84a09cf60 --- /dev/null +++ b/src/utils/stub.ts @@ -0,0 +1,64 @@ +import { + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLInputObjectType, + GraphQLString, + GraphQLNamedType, + GraphQLInt, + GraphQLFloat, + GraphQLBoolean, + GraphQLID, + isObjectType, + isInterfaceType, + isInputObjectType, +} from 'graphql'; + +export function createNamedStub( + name: string, + type: 'object' | 'interface' | 'input', +): GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType { + let constructor: any; + if (type === 'object') { + constructor = GraphQLObjectType; + } else if (type === 'interface') { + constructor = GraphQLInterfaceType; + } else { + constructor = GraphQLInputObjectType; + } + + return new constructor({ + name, + fields: { + __fake: { + type: GraphQLString, + }, + }, + }); +} + +export function isStub(type: GraphQLNamedType): boolean { + if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) { + const fields = type.getFields(); + const fieldNames = Object.keys(fields); + return fieldNames.length === 1 && fields[fieldNames[0]].name === '__fake'; + } + + return false; +} + +export function getBuiltInForStub(type: GraphQLNamedType): GraphQLNamedType { + switch (type.name) { + case GraphQLInt.name: + return GraphQLInt; + case GraphQLFloat.name: + return GraphQLFloat; + case GraphQLString.name: + return GraphQLString; + case GraphQLBoolean.name: + return GraphQLBoolean; + case GraphQLID.name: + return GraphQLID; + default: + return type; + } +} diff --git a/src/utils/transformInputValue.ts b/src/utils/transformInputValue.ts new file mode 100644 index 00000000000..6f40ac303d0 --- /dev/null +++ b/src/utils/transformInputValue.ts @@ -0,0 +1,59 @@ +import { + GraphQLEnumType, + GraphQLInputType, + GraphQLScalarType, + getNullableType, + isLeafType, + isListType, + isInputObjectType, +} from 'graphql'; + +type InputValueTransformer = ( + type: GraphQLEnumType | GraphQLScalarType, + originalValue: any, +) => any; + +export function transformInputValue( + type: GraphQLInputType, + value: any, + transformer: InputValueTransformer, +) { + if (value == null) { + return value; + } + + const nullableType = getNullableType(type); + + if (isLeafType(nullableType)) { + return transformer(nullableType, value); + } else if (isListType(nullableType)) { + return value.map((listMember: any) => + transformInputValue(nullableType.ofType, listMember, transformer), + ); + } else if (isInputObjectType(nullableType)) { + const fields = nullableType.getFields(); + const newValue = {}; + Object.keys(value).forEach((key) => { + newValue[key] = transformInputValue( + fields[key].type, + value[key], + transformer, + ); + }); + return newValue; + } + + // unreachable, no other possible return value +} + +export function serializeInputValue(type: GraphQLInputType, value: any) { + return transformInputValue(type, value, (t, v) => t.serialize(v)); +} + +export function parseInputValue(type: GraphQLInputType, value: any) { + return transformInputValue(type, value, (t, v) => t.parseValue(v)); +} + +export function parseInputValueLiteral(type: GraphQLInputType, value: any) { + return transformInputValue(type, value, (t, v) => t.parseLiteral(v, {})); +} diff --git a/src/utils/updateArgument.ts b/src/utils/updateArgument.ts new file mode 100644 index 00000000000..a28acf940da --- /dev/null +++ b/src/utils/updateArgument.ts @@ -0,0 +1,51 @@ +import { + GraphQLInputType, + ArgumentNode, + VariableDefinitionNode, + Kind, +} from 'graphql'; + +import { astFromType } from './astFromType'; + +export function updateArgument( + argName: string, + argType: GraphQLInputType, + argumentNodes: Record, + variableDefinitionsMap: Record, + variableValues: Record, + newArg: any, +): void { + let varName; + let numGeneratedVariables = 0; + do { + varName = `_v${(numGeneratedVariables++).toString()}_${argName}`; + } while (variableDefinitionsMap[varName] != null); + + argumentNodes[argName] = { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: argName, + }, + value: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: varName, + }, + }, + }; + variableDefinitionsMap[varName] = { + kind: Kind.VARIABLE_DEFINITION, + variable: { + kind: Kind.VARIABLE, + name: { + kind: Kind.NAME, + value: varName, + }, + }, + type: astFromType(argType), + }; + + variableValues[varName] = newArg; +} diff --git a/src/utils/updateEachKey.ts b/src/utils/updateEachKey.ts new file mode 100644 index 00000000000..7582573ec99 --- /dev/null +++ b/src/utils/updateEachKey.ts @@ -0,0 +1,35 @@ +import { IndexedObject } from '../Interfaces'; + +// A more powerful version of each that has the ability to replace or remove +// array or object keys. +export default function updateEachKey( + arrayOrObject: IndexedObject, + // The callback can return nothing to leave the key untouched, null to remove + // the key from the array or object, or a non-null V to replace the value. + updater: (value: V, key: string) => void | null | V, +) { + let deletedCount = 0; + + Object.keys(arrayOrObject).forEach((key) => { + const result = updater(arrayOrObject[key], key); + + if (typeof result === 'undefined') { + return; + } + + if (result === null) { + delete arrayOrObject[key]; + deletedCount++; + return; + } + + arrayOrObject[key] = result; + }); + + if (deletedCount > 0 && Array.isArray(arrayOrObject)) { + // Remove any holes from the array due to deleted elements. + arrayOrObject.splice(0).forEach((elem) => { + arrayOrObject.push(elem); + }); + } +} diff --git a/src/utils/valueFromASTUntyped.ts b/src/utils/valueFromASTUntyped.ts new file mode 100644 index 00000000000..eb027962f8a --- /dev/null +++ b/src/utils/valueFromASTUntyped.ts @@ -0,0 +1,30 @@ +import { ValueNode, Kind } from 'graphql'; + +// Similar to the graphql-js function of the same name, slightly simplified: +// https://github.com/graphql/graphql-js/blob/master/src/utilities/valueFromASTUntyped.js +export default function valueFromASTUntyped(valueNode: ValueNode): any { + switch (valueNode.kind) { + case Kind.NULL: + return null; + case Kind.INT: + return parseInt(valueNode.value, 10); + case Kind.FLOAT: + return parseFloat(valueNode.value); + case Kind.STRING: + case Kind.ENUM: + case Kind.BOOLEAN: + return valueNode.value; + case Kind.LIST: + return valueNode.values.map(valueFromASTUntyped); + case Kind.OBJECT: { + const obj = Object.create(null); + valueNode.fields.forEach((field) => { + obj[field.name.value] = valueFromASTUntyped(field.value); + }); + return obj; + } + /* istanbul ignore next */ + default: + throw new Error('Unexpected value kind: ' + valueNode.kind); + } +} diff --git a/src/utils/visitSchema.ts b/src/utils/visitSchema.ts new file mode 100644 index 00000000000..eaa4f4bbb12 --- /dev/null +++ b/src/utils/visitSchema.ts @@ -0,0 +1,315 @@ +import { + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLSchema, + isNamedType, + GraphQLType, + GraphQLNamedType, + GraphQLInputField, + isSchema, + isObjectType, + isInterfaceType, + isInputObjectType, + isScalarType, + isUnionType, + isEnumType, + isInputType, +} from 'graphql'; + +import { + VisitableSchemaType, + VisitorSelector, + VisitSchemaKind, + NamedTypeVisitor, + SchemaVisitorMap, +} from '../Interfaces'; + +import updateEachKey from './updateEachKey'; +import { healSchema } from './heal'; +import { SchemaVisitor } from './SchemaVisitor'; +import each from './each'; + +// Generic function for visiting GraphQLSchema objects. +export function visitSchema( + schema: GraphQLSchema, + // To accommodate as many different visitor patterns as possible, the + // visitSchema function does not simply accept a single instance of the + // SchemaVisitor class, but instead accepts a function that takes the + // current VisitableSchemaType object and the name of a visitor method and + // returns an array of SchemaVisitor instances that implement the visitor + // method and have an interest in handling the given VisitableSchemaType + // object. In the simplest case, this function can always return an array + // containing a single visitor object, without even looking at the type or + // methodName parameters. In other cases, this function might sometimes + // return an empty array to indicate there are no visitors that should be + // applied to the given VisitableSchemaType object. For an example of a + // visitor pattern that benefits from this abstraction, see the + // SchemaDirectiveVisitor class below. + visitorOrVisitorSelector: + | VisitorSelector + | Array + | SchemaVisitor + | SchemaVisitorMap, +): GraphQLSchema { + const visitorSelector = + typeof visitorOrVisitorSelector === 'function' + ? visitorOrVisitorSelector + : () => visitorOrVisitorSelector; + + // Helper function that calls visitorSelector and applies the resulting + // visitors to the given type, with arguments [type, ...args]. + function callMethod( + methodName: string, + type: T, + ...args: Array + ): T | null { + let visitors = visitorSelector(type, methodName); + visitors = Array.isArray(visitors) ? visitors : [visitors]; + + let finalType: T | null = type; + visitors.every((visitorOrVisitorDef) => { + let newType; + if (visitorOrVisitorDef instanceof SchemaVisitor) { + newType = visitorOrVisitorDef[methodName](finalType, ...args); + } else if ( + isNamedType(finalType) && + (methodName === 'visitScalar' || + methodName === 'visitEnum' || + methodName === 'visitObject' || + methodName === 'visitInputObject' || + methodName === 'visitUnion' || + methodName === 'visitInterface') + ) { + const specifiers = getTypeSpecifiers(finalType, schema); + const typeVisitor = getVisitor(visitorOrVisitorDef, specifiers); + newType = + typeVisitor != null ? typeVisitor(finalType, schema) : undefined; + } + + if (typeof newType === 'undefined') { + // Keep going without modifying type. + return true; + } + + if (methodName === 'visitSchema' || isSchema(finalType)) { + throw new Error( + `Method ${methodName} cannot replace schema with ${ + newType as string + }`, + ); + } + + if (newType === null) { + // Stop the loop and return null form callMethod, which will cause + // the type to be removed from the schema. + finalType = null; + return false; + } + + // Update type to the new type returned by the visitor method, so that + // later directives will see the new type, and callMethod will return + // the final type. + finalType = newType; + return true; + }); + + // If there were no directives for this type object, or if all visitor + // methods returned nothing, type will be returned unmodified. + return finalType; + } + + // Recursive helper function that calls any appropriate visitor methods for + // each object in the schema, then traverses the object's children (if any). + function visit(type: T): T | null { + if (isSchema(type)) { + // Unlike the other types, the root GraphQLSchema object cannot be + // replaced by visitor methods, because that would make life very hard + // for SchemaVisitor subclasses that rely on the original schema object. + callMethod('visitSchema', type); + + const typeMap: Record< + string, + GraphQLNamedType | null + > = type.getTypeMap(); + each(typeMap, (namedType, typeName) => { + if (!typeName.startsWith('__') && namedType != null) { + // Call visit recursively to let it determine which concrete + // subclass of GraphQLNamedType we found in the type map. + // We do not use updateEachKey because we want to preserve + // deleted types in the typeMap so that other types that reference + // the deleted types can be healed. + typeMap[typeName] = visit(namedType); + } + }); + + return type; + } + + if (isObjectType(type)) { + // Note that callMethod('visitObject', type) may not actually call any + // methods, if there are no @directive annotations associated with this + // type, or if this SchemaDirectiveVisitor subclass does not override + // the visitObject method. + const newObject = callMethod('visitObject', type); + if (newObject != null) { + visitFields(newObject); + } + return newObject; + } + + if (isInterfaceType(type)) { + const newInterface = callMethod('visitInterface', type); + if (newInterface != null) { + visitFields(newInterface); + } + return newInterface; + } + + if (isInputObjectType(type)) { + const newInputObject = callMethod('visitInputObject', type); + + if (newInputObject != null) { + const fieldMap = newInputObject.getFields() as Record< + string, + GraphQLInputField + >; + updateEachKey(fieldMap, (field) => + callMethod('visitInputFieldDefinition', field, { + // Since we call a different method for input object fields, we + // can't reuse the visitFields function here. + objectType: newInputObject, + }), + ); + } + + return newInputObject; + } + + if (isScalarType(type)) { + return callMethod('visitScalar', type); + } + + if (isUnionType(type)) { + return callMethod('visitUnion', type); + } + + if (isEnumType(type)) { + const newEnum = callMethod('visitEnum', type); + + if (newEnum != null) { + updateEachKey(newEnum.getValues(), (value) => + callMethod('visitEnumValue', value, { + enumType: newEnum, + }), + ); + } + + return newEnum; + } + + throw new Error(`Unexpected schema type: ${(type as unknown) as string}`); + } + + function visitFields(type: GraphQLObjectType | GraphQLInterfaceType) { + updateEachKey(type.getFields(), (field) => { + // It would be nice if we could call visit(field) recursively here, but + // GraphQLField is merely a type, not a value that can be detected using + // an instanceof check, so we have to visit the fields in this lexical + // context, so that TypeScript can validate the call to + // visitFieldDefinition. + const newField = callMethod('visitFieldDefinition', field, { + // While any field visitor needs a reference to the field object, some + // field visitors may also need to know the enclosing (parent) type, + // perhaps to determine if the parent is a GraphQLObjectType or a + // GraphQLInterfaceType. To obtain a reference to the parent, a + // visitor method can have a second parameter, which will be an object + // with an .objectType property referring to the parent. + objectType: type, + }); + + if (newField.args != null) { + updateEachKey(newField.args, (arg) => + callMethod('visitArgumentDefinition', arg, { + // Like visitFieldDefinition, visitArgumentDefinition takes a + // second parameter that provides additional context, namely the + // parent .field and grandparent .objectType. Remember that the + // current GraphQLSchema is always available via this.schema. + field: newField, + objectType: type, + }), + ); + } + + return newField; + }); + } + + visit(schema); + + // Automatically update any references to named schema types replaced + // during the traversal, so implementors don't have to worry about that. + healSchema(schema); + + // Return schema for convenience, even though schema parameter has all updated types. + return schema; +} + +function getTypeSpecifiers( + type: GraphQLType, + schema: GraphQLSchema, +): Array { + const specifiers = [VisitSchemaKind.TYPE]; + if (isObjectType(type)) { + specifiers.push( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.OBJECT_TYPE, + ); + const query = schema.getQueryType(); + const mutation = schema.getMutationType(); + const subscription = schema.getSubscriptionType(); + if (type === query) { + specifiers.push(VisitSchemaKind.ROOT_OBJECT, VisitSchemaKind.QUERY); + } else if (type === mutation) { + specifiers.push(VisitSchemaKind.ROOT_OBJECT, VisitSchemaKind.MUTATION); + } else if (type === subscription) { + specifiers.push( + VisitSchemaKind.ROOT_OBJECT, + VisitSchemaKind.SUBSCRIPTION, + ); + } + } else if (isInputType(type)) { + specifiers.push(VisitSchemaKind.INPUT_OBJECT_TYPE); + } else if (isInterfaceType(type)) { + specifiers.push( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.ABSTRACT_TYPE, + VisitSchemaKind.INTERFACE_TYPE, + ); + } else if (isUnionType(type)) { + specifiers.push( + VisitSchemaKind.COMPOSITE_TYPE, + VisitSchemaKind.ABSTRACT_TYPE, + VisitSchemaKind.UNION_TYPE, + ); + } else if (isEnumType(type)) { + specifiers.push(VisitSchemaKind.ENUM_TYPE); + } else if (isScalarType(type)) { + specifiers.push(VisitSchemaKind.SCALAR_TYPE); + } + + return specifiers; +} + +function getVisitor( + visitorDef: SchemaVisitorMap, + specifiers: Array, +): NamedTypeVisitor | null { + let typeVisitor: NamedTypeVisitor | undefined; + const stack = [...specifiers]; + while (!typeVisitor && stack.length > 0) { + const next = stack.pop(); + typeVisitor = visitorDef[next] as NamedTypeVisitor; + } + + return typeVisitor != null ? typeVisitor : null; +} diff --git a/src/wrap/index.ts b/src/wrap/index.ts new file mode 100644 index 00000000000..4b6b0da6f2f --- /dev/null +++ b/src/wrap/index.ts @@ -0,0 +1,15 @@ +export { + applySchemaTransforms, + applyRequestTransforms, + applyResultTransforms, +} from './transforms'; + +export { transformSchema } from './transformSchema'; +export { wrapSchema } from './wrapSchema'; + +export * from './transforms/index'; + +export { + default as makeRemoteExecutableSchema, + createResolver as defaultCreateRemoteResolver, +} from './makeRemoteExecutableSchema'; diff --git a/src/wrap/makeRemoteExecutableSchema.ts b/src/wrap/makeRemoteExecutableSchema.ts new file mode 100644 index 00000000000..9147de94b20 --- /dev/null +++ b/src/wrap/makeRemoteExecutableSchema.ts @@ -0,0 +1,130 @@ +import { ApolloLink } from 'apollo-link'; +import { + GraphQLFieldResolver, + GraphQLSchema, + Kind, + GraphQLResolveInfo, + BuildSchemaOptions, + DocumentNode, +} from 'graphql'; + +import { addResolversToSchema } from '../generate/index'; +import { Fetcher, Operation } from '../Interfaces'; +import { cloneSchema } from '../utils/index'; +import { buildSchema } from '../polyfills/index'; +import { addTypenameToAbstract } from '../delegate/addTypenameToAbstract'; +import { checkResultAndHandleErrors } from '../delegate/checkResultAndHandleErrors'; + +import linkToFetcher, { execute } from '../stitch/linkToFetcher'; +import { observableToAsyncIterable } from '../stitch/observableToAsyncIterable'; +import mapAsyncIterator from '../stitch/mapAsyncIterator'; + +import { stripResolvers, generateProxyingResolvers } from './resolvers'; + +export type ResolverFn = ( + rootValue?: any, + args?: any, + context?: any, + info?: GraphQLResolveInfo, +) => AsyncIterator; + +export default function makeRemoteExecutableSchema({ + schema: schemaOrTypeDefs, + link, + fetcher, + createResolver: customCreateResolver = createResolver, + buildSchemaOptions, +}: { + schema: GraphQLSchema | string; + link?: ApolloLink; + fetcher?: Fetcher; + createResolver?: (fetcher: Fetcher) => GraphQLFieldResolver; + buildSchemaOptions?: BuildSchemaOptions; +}): GraphQLSchema { + let finalFetcher: Fetcher = fetcher; + + if (finalFetcher == null && link != null) { + finalFetcher = linkToFetcher(link); + } + + const targetSchema = + typeof schemaOrTypeDefs === 'string' + ? buildSchema(schemaOrTypeDefs, buildSchemaOptions) + : schemaOrTypeDefs; + + const remoteSchema = cloneSchema(targetSchema); + stripResolvers(remoteSchema); + + function createProxyingResolver({ + operation, + }: { + operation: Operation; + }): GraphQLFieldResolver { + if (operation === 'query' || operation === 'mutation') { + return customCreateResolver(finalFetcher); + } + return createSubscriptionResolver(link); + } + + addResolversToSchema({ + schema: remoteSchema, + resolvers: generateProxyingResolvers({ + subschemaConfig: { schema: remoteSchema }, + createProxyingResolver, + }), + resolverValidationOptions: { + allowResolversNotInSchema: true, + }, + }); + + return remoteSchema; +} + +export function createResolver( + fetcher: Fetcher, +): GraphQLFieldResolver { + return async (_root, _args, context, info) => { + const fragments = Object.keys(info.fragments).map( + (fragment) => info.fragments[fragment], + ); + let query: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [info.operation, ...fragments], + }; + + query = addTypenameToAbstract(info.schema, query); + + const result = await fetcher({ + query, + variables: info.variableValues, + context: { graphqlContext: context }, + }); + return checkResultAndHandleErrors(result, context, info); + }; +} + +function createSubscriptionResolver(link: ApolloLink): ResolverFn { + return (_root, _args, context, info) => { + const fragments = Object.keys(info.fragments).map( + (fragment) => info.fragments[fragment], + ); + let query: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [info.operation, ...fragments], + }; + + query = addTypenameToAbstract(info.schema, query); + + const operation = { + query, + variables: info.variableValues, + context: { graphqlContext: context }, + }; + + const observable = execute(link, operation); + const originalAsyncIterator = observableToAsyncIterable(observable); + return mapAsyncIterator(originalAsyncIterator, (result) => ({ + [info.fieldName]: checkResultAndHandleErrors(result, context, info), + })); + }; +} diff --git a/src/wrap/resolvers.ts b/src/wrap/resolvers.ts new file mode 100644 index 00000000000..9967bafc3a8 --- /dev/null +++ b/src/wrap/resolvers.ts @@ -0,0 +1,154 @@ +import { + GraphQLSchema, + GraphQLFieldResolver, + GraphQLObjectType, +} from 'graphql'; + +import { + Transform, + IResolvers, + Operation, + SubschemaConfig, +} from '../Interfaces'; +import delegateToSchema from '../delegate/delegateToSchema'; +import { handleResult } from '../delegate/checkResultAndHandleErrors'; + +import { makeMergedType } from '../stitch/makeMergedType'; +import { getResponseKeyFromInfo } from '../stitch/getResponseKeyFromInfo'; +import { getErrors, getSubschema } from '../stitch/proxiedResult'; + +export type Mapping = { + [typeName: string]: { + [fieldName: string]: { + name: string; + operation: Operation; + }; + }; +}; + +export function generateProxyingResolvers({ + subschemaConfig, + transforms, + createProxyingResolver = defaultCreateProxyingResolver, +}: { + subschemaConfig: SubschemaConfig; + transforms?: Array; + createProxyingResolver?: ({ + schema, + transforms, + operation, + fieldName, + }: { + schema?: GraphQLSchema | SubschemaConfig; + transforms?: Array; + operation?: Operation; + fieldName?: string; + }) => GraphQLFieldResolver; +}): IResolvers { + const targetSchema = subschemaConfig.schema; + + const mapping = generateSimpleMapping(targetSchema); + + const result = {}; + Object.keys(mapping).forEach((name) => { + result[name] = {}; + const innerMapping = mapping[name]; + Object.keys(innerMapping).forEach((from) => { + const to = innerMapping[from]; + const resolverType = + to.operation === 'subscription' ? 'subscribe' : 'resolve'; + result[name][from] = { + [resolverType]: createProxyingResolver({ + schema: subschemaConfig, + transforms, + operation: to.operation, + fieldName: to.name, + }), + }; + }); + }); + return result; +} + +export function generateSimpleMapping(targetSchema: GraphQLSchema): Mapping { + const query = targetSchema.getQueryType(); + const mutation = targetSchema.getMutationType(); + const subscription = targetSchema.getSubscriptionType(); + + const result: Mapping = {}; + if (query != null) { + result[query.name] = generateMappingFromObjectType(query, 'query'); + } + if (mutation != null) { + result[mutation.name] = generateMappingFromObjectType(mutation, 'mutation'); + } + if (subscription != null) { + result[subscription.name] = generateMappingFromObjectType( + subscription, + 'subscription', + ); + } + + return result; +} + +export function generateMappingFromObjectType( + type: GraphQLObjectType, + operation: Operation, +): { + [fieldName: string]: { + name: string; + operation: Operation; + }; +} { + const result = {}; + const fields = type.getFields(); + Object.keys(fields).forEach((fieldName) => { + result[fieldName] = { + name: fieldName, + operation, + }; + }); + return result; +} + +function defaultCreateProxyingResolver({ + schema, + transforms, +}: { + schema: SubschemaConfig; + transforms: Array; +}): GraphQLFieldResolver { + return (parent, _args, context, info) => { + if (parent != null) { + const responseKey = getResponseKeyFromInfo(info); + const errors = getErrors(parent, responseKey); + + if (errors != null) { + const subschema = getSubschema(parent, responseKey); + + // if parent contains a proxied result from this subschema, can return that result + if (schema === subschema) { + const result = parent[responseKey]; + return handleResult(result, errors, subschema, context, info); + } + } + } + + return delegateToSchema({ + schema, + context, + info, + transforms, + }); + }; +} + +export function stripResolvers(schema: GraphQLSchema): void { + const typeMap = schema.getTypeMap(); + Object.keys(typeMap).forEach((typeName) => { + if (!typeName.startsWith('__')) { + makeMergedType(typeMap[typeName]); + } + }); +} diff --git a/src/wrap/transformSchema.ts b/src/wrap/transformSchema.ts new file mode 100644 index 00000000000..61481aa0801 --- /dev/null +++ b/src/wrap/transformSchema.ts @@ -0,0 +1,26 @@ +import { GraphQLSchema } from 'graphql'; + +import { + Transform, + SubschemaConfig, + GraphQLSchemaWithTransforms, +} from '../Interfaces'; + +import { wrapSchema } from './wrapSchema'; + +// This function is deprecated in favor of wrapSchema as the name is misleading. +// transformSchema does not just "transform" a schema, it wraps a schema with transforms +// using a round of delegation. +// The applySchemaTransforms function actually "transforms" the schema and is used during wrapping. +export function transformSchema( + subschemaOrSubschemaConfig: GraphQLSchema | SubschemaConfig, + transforms: Array, +): GraphQLSchemaWithTransforms { + const schema: GraphQLSchemaWithTransforms = wrapSchema( + subschemaOrSubschemaConfig, + transforms, + ); + + schema.transforms = transforms.slice().reverse(); + return schema; +} diff --git a/src/wrap/transforms.ts b/src/wrap/transforms.ts new file mode 100644 index 00000000000..354a1fc555d --- /dev/null +++ b/src/wrap/transforms.ts @@ -0,0 +1,44 @@ +import { GraphQLSchema } from 'graphql'; + +import { Request, Transform } from '../Interfaces'; +import { cloneSchema } from '../utils/index'; + +export function applySchemaTransforms( + originalSchema: GraphQLSchema, + transforms: Array, +): GraphQLSchema { + return transforms.reduce( + (schema: GraphQLSchema, transform: Transform) => + transform.transformSchema != null + ? transform.transformSchema(cloneSchema(schema)) + : schema, + originalSchema, + ); +} + +export function applyRequestTransforms( + originalRequest: Request, + transforms: Array, +): Request { + return transforms.reduce( + (request: Request, transform: Transform) => + transform.transformRequest != null + ? transform.transformRequest(request) + : request, + + originalRequest, + ); +} + +export function applyResultTransforms( + originalResult: any, + transforms: Array, +): any { + return transforms.reduceRight( + (result: any, transform: Transform) => + transform.transformResult != null + ? transform.transformResult(result) + : result, + originalResult, + ); +} diff --git a/src/wrap/transforms/AddArgumentsAsVariables.ts b/src/wrap/transforms/AddArgumentsAsVariables.ts new file mode 100644 index 00000000000..e441623cec8 --- /dev/null +++ b/src/wrap/transforms/AddArgumentsAsVariables.ts @@ -0,0 +1,154 @@ +import { + ArgumentNode, + DocumentNode, + FragmentDefinitionNode, + GraphQLArgument, + GraphQLField, + GraphQLObjectType, + GraphQLSchema, + Kind, + OperationDefinitionNode, + SelectionNode, + VariableDefinitionNode, +} from 'graphql'; + +import { Transform, Request } from '../../Interfaces'; +import { serializeInputValue } from '../../utils/index'; +import { updateArgument } from '../../utils/updateArgument'; + +export default class AddArgumentsAsVariables implements Transform { + private readonly targetSchema: GraphQLSchema; + private readonly args: { [key: string]: any }; + + constructor(targetSchema: GraphQLSchema, args: { [key: string]: any }) { + this.targetSchema = targetSchema; + this.args = args; + } + + public transformRequest(originalRequest: Request): Request { + const { document, newVariables } = addVariablesToRootField( + this.targetSchema, + originalRequest, + this.args, + ); + + return { + document, + variables: newVariables, + }; + } +} + +function addVariablesToRootField( + targetSchema: GraphQLSchema, + originalRequest: Request, + args: { [key: string]: any }, +): { + document: DocumentNode; + newVariables: { [key: string]: any }; +} { + const document = originalRequest.document; + const variableValues = originalRequest.variables; + + const operations: Array = document.definitions.filter( + (def) => def.kind === Kind.OPERATION_DEFINITION, + ) as Array; + const fragments: Array = document.definitions.filter( + (def) => def.kind === Kind.FRAGMENT_DEFINITION, + ) as Array; + + const newOperations = operations.map((operation: OperationDefinitionNode) => { + const variableDefinitionMap = {}; + operation.variableDefinitions.forEach((def) => { + const varName = def.variable.name.value; + variableDefinitionMap[varName] = def; + }); + + let type: GraphQLObjectType | null | undefined; + if (operation.operation === 'subscription') { + type = targetSchema.getSubscriptionType(); + } else if (operation.operation === 'mutation') { + type = targetSchema.getMutationType(); + } else { + type = targetSchema.getQueryType(); + } + const newSelectionSet: Array = []; + + operation.selectionSet.selections.forEach((selection: SelectionNode) => { + if (selection.kind === Kind.FIELD) { + const argumentNodes = selection.arguments; + const argumentNodeMap: Record = {}; + argumentNodes.forEach((argument: ArgumentNode) => { + argumentNodeMap[argument.name.value] = argument; + }); + + const targetField = type.getFields()[selection.name.value]; + + // excludes __typename + if (targetField != null) { + updateArguments( + targetField, + argumentNodeMap, + variableDefinitionMap, + variableValues, + args, + ); + } + + newSelectionSet.push({ + ...selection, + arguments: Object.keys(argumentNodeMap).map( + (argName) => argumentNodeMap[argName], + ), + }); + } else { + newSelectionSet.push(selection); + } + }); + + return { + ...operation, + variableDefinitions: Object.keys(variableDefinitionMap).map( + (varName) => variableDefinitionMap[varName], + ), + selectionSet: { + kind: Kind.SELECTION_SET, + selections: newSelectionSet, + }, + }; + }); + + return { + document: { + ...document, + definitions: [...newOperations, ...fragments], + }, + newVariables: variableValues, + }; +} + +const hasOwn = Object.prototype.hasOwnProperty; + +function updateArguments( + targetField: GraphQLField, + argumentNodeMap: Record, + variableDefinitionMap: Record, + variableValues: Record, + newArgs: Record, +): void { + targetField.args.forEach((argument: GraphQLArgument) => { + const argName = argument.name; + const argType = argument.type; + + if (hasOwn.call(newArgs, argName)) { + updateArgument( + argName, + argType, + argumentNodeMap, + variableDefinitionMap, + variableValues, + serializeInputValue(argType, newArgs[argName]), + ); + } + }); +} diff --git a/src/wrap/transforms/AddMergedTypeSelectionSets.ts b/src/wrap/transforms/AddMergedTypeSelectionSets.ts new file mode 100644 index 00000000000..261a9a64404 --- /dev/null +++ b/src/wrap/transforms/AddMergedTypeSelectionSets.ts @@ -0,0 +1,76 @@ +import { + DocumentNode, + GraphQLSchema, + GraphQLType, + Kind, + SelectionSetNode, + TypeInfo, + visit, + visitWithTypeInfo, +} from 'graphql'; + +import { Transform, Request, MergedTypeInfo } from '../../Interfaces'; + +export default class AddMergedTypeFragments implements Transform { + private readonly targetSchema: GraphQLSchema; + private readonly mapping: Record; + + constructor( + targetSchema: GraphQLSchema, + mapping: Record, + ) { + this.targetSchema = targetSchema; + this.mapping = mapping; + } + + public transformRequest(originalRequest: Request): Request { + const document = addMergedTypeSelectionSets( + this.targetSchema, + originalRequest.document, + this.mapping, + ); + return { + ...originalRequest, + document, + }; + } +} + +function addMergedTypeSelectionSets( + targetSchema: GraphQLSchema, + document: DocumentNode, + mapping: Record, +): DocumentNode { + const typeInfo = new TypeInfo(targetSchema); + return visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]( + node: SelectionSetNode, + ): SelectionSetNode | null | undefined { + const parentType: + | GraphQLType + | null + | undefined = typeInfo.getParentType(); + if (parentType != null) { + const parentTypeName = parentType.name; + let selections = node.selections; + + if (mapping[parentTypeName] != null) { + const selectionSet = mapping[parentTypeName].selectionSet; + if (selectionSet != null) { + selections = selections.concat(selectionSet.selections); + } + } + + if (selections !== node.selections) { + return { + ...node, + selections, + }; + } + } + }, + }), + ); +} diff --git a/src/wrap/transforms/AddReplacementFragments.ts b/src/wrap/transforms/AddReplacementFragments.ts new file mode 100644 index 00000000000..47ebbfdce31 --- /dev/null +++ b/src/wrap/transforms/AddReplacementFragments.ts @@ -0,0 +1,85 @@ +import { + DocumentNode, + GraphQLSchema, + GraphQLType, + Kind, + SelectionSetNode, + TypeInfo, + visit, + visitWithTypeInfo, +} from 'graphql'; + +import { + Transform, + Request, + ReplacementFragmentMapping, +} from '../../Interfaces'; + +export default class AddReplacementFragments implements Transform { + private readonly targetSchema: GraphQLSchema; + private readonly mapping: ReplacementFragmentMapping; + + constructor( + targetSchema: GraphQLSchema, + mapping: ReplacementFragmentMapping, + ) { + this.targetSchema = targetSchema; + this.mapping = mapping; + } + + public transformRequest(originalRequest: Request): Request { + const document = replaceFieldsWithFragments( + this.targetSchema, + originalRequest.document, + this.mapping, + ); + return { + ...originalRequest, + document, + }; + } +} + +function replaceFieldsWithFragments( + targetSchema: GraphQLSchema, + document: DocumentNode, + mapping: ReplacementFragmentMapping, +): DocumentNode { + const typeInfo = new TypeInfo(targetSchema); + return visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]( + node: SelectionSetNode, + ): SelectionSetNode | null | undefined { + const parentType: + | GraphQLType + | null + | undefined = typeInfo.getParentType(); + if (parentType != null) { + const parentTypeName = parentType.name; + let selections = node.selections; + + if (mapping[parentTypeName] != null) { + node.selections.forEach((selection) => { + if (selection.kind === Kind.FIELD) { + const name = selection.name.value; + const fragment = mapping[parentTypeName][name]; + if (fragment != null) { + selections = selections.concat(fragment); + } + } + }); + } + + if (selections !== node.selections) { + return { + ...node, + selections, + }; + } + } + }, + }), + ); +} diff --git a/src/wrap/transforms/AddReplacementSelectionSets.ts b/src/wrap/transforms/AddReplacementSelectionSets.ts new file mode 100644 index 00000000000..d2c10895753 --- /dev/null +++ b/src/wrap/transforms/AddReplacementSelectionSets.ts @@ -0,0 +1,82 @@ +import { + DocumentNode, + GraphQLSchema, + GraphQLType, + Kind, + SelectionSetNode, + TypeInfo, + visit, + visitWithTypeInfo, +} from 'graphql'; + +import { + Transform, + Request, + ReplacementSelectionSetMapping, +} from '../../Interfaces'; + +export default class AddReplacementSelectionSets implements Transform { + private readonly schema: GraphQLSchema; + private readonly mapping: ReplacementSelectionSetMapping; + + constructor(schema: GraphQLSchema, mapping: ReplacementSelectionSetMapping) { + this.schema = schema; + this.mapping = mapping; + } + + public transformRequest(originalRequest: Request): Request { + const document = replaceFieldsWithSelectionSet( + this.schema, + originalRequest.document, + this.mapping, + ); + return { + ...originalRequest, + document, + }; + } +} + +function replaceFieldsWithSelectionSet( + schema: GraphQLSchema, + document: DocumentNode, + mapping: ReplacementSelectionSetMapping, +): DocumentNode { + const typeInfo = new TypeInfo(schema); + return visit( + document, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]( + node: SelectionSetNode, + ): SelectionSetNode | null | undefined { + const parentType: + | GraphQLType + | null + | undefined = typeInfo.getParentType(); + if (parentType != null) { + const parentTypeName = parentType.name; + let selections = node.selections; + + if (mapping[parentTypeName] != null) { + node.selections.forEach((selection) => { + if (selection.kind === Kind.FIELD) { + const name = selection.name.value; + const selectionSet = mapping[parentTypeName][name]; + if (selectionSet != null) { + selections = selections.concat(selectionSet.selections); + } + } + }); + } + + if (selections !== node.selections) { + return { + ...node, + selections, + }; + } + } + }, + }), + ); +} diff --git a/src/wrap/transforms/AddTypenameToAbstract.ts b/src/wrap/transforms/AddTypenameToAbstract.ts new file mode 100644 index 00000000000..a6a1582c9e1 --- /dev/null +++ b/src/wrap/transforms/AddTypenameToAbstract.ts @@ -0,0 +1,23 @@ +import { GraphQLSchema } from 'graphql'; + +import { Transform, Request } from '../../Interfaces'; +import { addTypenameToAbstract } from '../../delegate/addTypenameToAbstract'; + +export default class AddTypenameToAbstract implements Transform { + private readonly targetSchema: GraphQLSchema; + + constructor(targetSchema: GraphQLSchema) { + this.targetSchema = targetSchema; + } + + public transformRequest(originalRequest: Request): Request { + const document = addTypenameToAbstract( + this.targetSchema, + originalRequest.document, + ); + return { + ...originalRequest, + document, + }; + } +} diff --git a/src/wrap/transforms/CheckResultAndHandleErrors.ts b/src/wrap/transforms/CheckResultAndHandleErrors.ts new file mode 100644 index 00000000000..9775024ff2c --- /dev/null +++ b/src/wrap/transforms/CheckResultAndHandleErrors.ts @@ -0,0 +1,45 @@ +import { GraphQLSchema, GraphQLOutputType } from 'graphql'; + +import { checkResultAndHandleErrors } from '../../delegate/checkResultAndHandleErrors'; +import { + Transform, + SubschemaConfig, + IGraphQLToolsResolveInfo, +} from '../../Interfaces'; + +export default class CheckResultAndHandleErrors implements Transform { + private readonly context?: Record; + private readonly info: IGraphQLToolsResolveInfo; + private readonly fieldName?: string; + private readonly subschema?: GraphQLSchema | SubschemaConfig; + private readonly returnType?: GraphQLOutputType; + private readonly typeMerge?: boolean; + + constructor( + info: IGraphQLToolsResolveInfo, + fieldName?: string, + subschema?: GraphQLSchema | SubschemaConfig, + context?: Record, + returnType: GraphQLOutputType = info.returnType, + typeMerge?: boolean, + ) { + this.context = context; + this.info = info; + this.fieldName = fieldName; + this.subschema = subschema; + this.returnType = returnType; + this.typeMerge = typeMerge; + } + + public transformResult(result: any): any { + return checkResultAndHandleErrors( + result, + this.context != null ? this.context : {}, + this.info, + this.fieldName, + this.subschema, + this.returnType, + this.typeMerge, + ); + } +} diff --git a/src/transforms/ExpandAbstractTypes.ts b/src/wrap/transforms/ExpandAbstractTypes.ts similarity index 50% rename from src/transforms/ExpandAbstractTypes.ts rename to src/wrap/transforms/ExpandAbstractTypes.ts index 41e8665e3da..5b9abfdf7e8 100644 --- a/src/transforms/ExpandAbstractTypes.ts +++ b/src/wrap/transforms/ExpandAbstractTypes.ts @@ -13,19 +13,20 @@ import { visit, visitWithTypeInfo, } from 'graphql'; -import implementsAbstractType from '../implementsAbstractType'; -import { Transform, Request } from '../Interfaces'; + +import implementsAbstractType from '../../utils/implementsAbstractType'; +import { Transform, Request } from '../../Interfaces'; type TypeMapping = { [key: string]: Array }; export default class ExpandAbstractTypes implements Transform { - private targetSchema: GraphQLSchema; - private mapping: TypeMapping; - private reverseMapping: TypeMapping; + private readonly targetSchema: GraphQLSchema; + private readonly mapping: TypeMapping; + private readonly reverseMapping: TypeMapping; - constructor(transformedSchema: GraphQLSchema, targetSchema: GraphQLSchema) { + constructor(sourceSchema: GraphQLSchema, targetSchema: GraphQLSchema) { this.targetSchema = targetSchema; - this.mapping = extractPossibleTypes(transformedSchema, targetSchema); + this.mapping = extractPossibleTypes(sourceSchema, targetSchema); this.reverseMapping = flipMapping(this.mapping); } @@ -44,20 +45,20 @@ export default class ExpandAbstractTypes implements Transform { } function extractPossibleTypes( - transformedSchema: GraphQLSchema, + sourceSchema: GraphQLSchema, targetSchema: GraphQLSchema, ) { - const typeMap = transformedSchema.getTypeMap(); + const typeMap = sourceSchema.getTypeMap(); const mapping: TypeMapping = {}; - Object.keys(typeMap).forEach(typeName => { + Object.keys(typeMap).forEach((typeName) => { const type = typeMap[typeName]; if (isAbstractType(type)) { const targetType = targetSchema.getType(typeName); if (!isAbstractType(targetType)) { - const implementations = transformedSchema.getPossibleTypes(type) || []; + const implementations = sourceSchema.getPossibleTypes(type); mapping[typeName] = implementations - .filter(impl => targetSchema.getType(impl.name)) - .map(impl => impl.name); + .filter((impl) => targetSchema.getType(impl.name)) + .map((impl) => impl.name); } } }); @@ -66,10 +67,10 @@ function extractPossibleTypes( function flipMapping(mapping: TypeMapping): TypeMapping { const result: TypeMapping = {}; - Object.keys(mapping).forEach(typeName => { + Object.keys(mapping).forEach((typeName) => { const toTypeNames = mapping[typeName]; - toTypeNames.forEach(toTypeName => { - if (!result[toTypeName]) { + toTypeNames.forEach((toTypeName) => { + if (result[toTypeName] == null) { result[toTypeName] = []; } result[toTypeName].push(typeName); @@ -84,21 +85,21 @@ function expandAbstractTypes( reverseMapping: TypeMapping, document: DocumentNode, ): DocumentNode { - const operations: Array< - OperationDefinitionNode - > = document.definitions.filter( - def => def.kind === Kind.OPERATION_DEFINITION, + const operations: Array = document.definitions.filter( + (def) => def.kind === Kind.OPERATION_DEFINITION, ) as Array; const fragments: Array = document.definitions.filter( - def => def.kind === Kind.FRAGMENT_DEFINITION, + (def) => def.kind === Kind.FRAGMENT_DEFINITION, ) as Array; - const existingFragmentNames = fragments.map(fragment => fragment.name.value); + const existingFragmentNames = fragments.map( + (fragment) => fragment.name.value, + ); let fragmentCounter = 0; const generateFragmentName = (typeName: string) => { let fragmentName; do { - fragmentName = `_${typeName}_Fragment${fragmentCounter}`; + fragmentName = `_${typeName}_Fragment${fragmentCounter.toString()}`; fragmentCounter++; } while (existingFragmentNames.indexOf(fragmentName) !== -1); return fragmentName; @@ -112,9 +113,9 @@ function expandAbstractTypes( fragments.forEach((fragment: FragmentDefinitionNode) => { newFragments.push(fragment); const possibleTypes = mapping[fragment.typeCondition.name.value]; - if (possibleTypes) { + if (possibleTypes != null) { fragmentReplacements[fragment.name.value] = []; - possibleTypes.forEach(possibleTypeName => { + possibleTypes.forEach((possibleTypeName) => { const name = generateFragmentName(possibleTypeName); existingFragmentNames.push(name); const newFragment: FragmentDefinitionNode = { @@ -152,69 +153,75 @@ function expandAbstractTypes( visitWithTypeInfo(typeInfo, { [Kind.SELECTION_SET](node: SelectionSetNode) { const newSelections = [...node.selections]; - const parentType: GraphQLNamedType = getNamedType( - typeInfo.getParentType(), - ); - node.selections.forEach((selection: SelectionNode) => { - if (selection.kind === Kind.INLINE_FRAGMENT) { - const possibleTypes = mapping[selection.typeCondition.name.value]; - if (possibleTypes) { - possibleTypes.forEach(possibleType => { - if ( - implementsAbstractType( - targetSchema, - parentType, - targetSchema.getType(possibleType), - ) - ) { - newSelections.push({ - kind: Kind.INLINE_FRAGMENT, - typeCondition: { - kind: Kind.NAMED_TYPE, + const maybeType = typeInfo.getParentType(); + if (maybeType != null) { + const parentType: GraphQLNamedType = getNamedType(maybeType); + node.selections.forEach((selection: SelectionNode) => { + if (selection.kind === Kind.INLINE_FRAGMENT) { + if (selection.typeCondition != null) { + const possibleTypes = + mapping[selection.typeCondition.name.value]; + if (possibleTypes != null) { + possibleTypes.forEach((possibleType) => { + const maybePossibleType = targetSchema.getType( + possibleType, + ); + if ( + maybePossibleType != null && + implementsAbstractType( + targetSchema, + parentType, + maybePossibleType, + ) + ) { + newSelections.push({ + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: possibleType, + }, + }, + selectionSet: selection.selectionSet, + }); + } + }); + } + } + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragmentName = selection.name.value; + const replacements = fragmentReplacements[fragmentName]; + if (replacements != null) { + replacements.forEach((replacement) => { + const typeName = replacement.typeName; + const maybeReplacementType = targetSchema.getType(typeName); + if ( + maybeReplacementType != null && + implementsAbstractType(targetSchema, parentType, maybeType) + ) { + newSelections.push({ + kind: Kind.FRAGMENT_SPREAD, name: { kind: Kind.NAME, - value: possibleType, + value: replacement.fragmentName, }, - }, - selectionSet: selection.selectionSet, - }); - } - }); - } - } else if (selection.kind === Kind.FRAGMENT_SPREAD) { - const fragmentName = selection.name.value; - const replacements = fragmentReplacements[fragmentName]; - if (replacements) { - replacements.forEach(replacement => { - const typeName = replacement.typeName; - if ( - implementsAbstractType( - targetSchema, - parentType, - targetSchema.getType(typeName), - ) - ) { - newSelections.push({ - kind: Kind.FRAGMENT_SPREAD, - name: { - kind: Kind.NAME, - value: replacement.fragmentName, - }, - }); - } - }); + }); + } + }); + } } - } - }); - - if (parentType && reverseMapping[parentType.name]) { - newSelections.push({ - kind: Kind.FIELD, - name: { - kind: Kind.NAME, - value: '__typename', - }, }); + + if (reverseMapping[parentType.name] != null) { + newSelections.push({ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }); + } } if (newSelections.length !== node.selections.length) { diff --git a/src/wrap/transforms/ExtendSchema.ts b/src/wrap/transforms/ExtendSchema.ts new file mode 100644 index 00000000000..7c6b7a6f052 --- /dev/null +++ b/src/wrap/transforms/ExtendSchema.ts @@ -0,0 +1,57 @@ +import { GraphQLSchema, extendSchema, parse } from 'graphql'; + +import { + Transform, + IFieldResolver, + IResolvers, + Request, +} from '../../Interfaces'; +import { addResolversToSchema } from '../../generate/index'; +import { defaultMergedResolver } from '../../stitch/index'; + +import MapFields, { FieldNodeTransformerMap } from './MapFields'; + +export default class ExtendSchema implements Transform { + private readonly typeDefs: string | undefined; + private readonly resolvers: IResolvers | undefined; + private readonly defaultFieldResolver: IFieldResolver | undefined; + private readonly transformer: MapFields; + + constructor({ + typeDefs, + resolvers = {}, + defaultFieldResolver, + fieldNodeTransformerMap, + }: { + typeDefs?: string; + resolvers?: IResolvers; + defaultFieldResolver?: IFieldResolver; + fieldNodeTransformerMap?: FieldNodeTransformerMap; + }) { + this.typeDefs = typeDefs; + this.resolvers = resolvers; + this.defaultFieldResolver = + defaultFieldResolver != null + ? defaultFieldResolver + : defaultMergedResolver; + this.transformer = new MapFields( + fieldNodeTransformerMap != null ? fieldNodeTransformerMap : {}, + ); + } + + public transformSchema(schema: GraphQLSchema): GraphQLSchema { + this.transformer.transformSchema(schema); + + return addResolversToSchema({ + schema: this.typeDefs + ? extendSchema(schema, parse(this.typeDefs)) + : schema, + resolvers: this.resolvers != null ? this.resolvers : {}, + defaultFieldResolver: this.defaultFieldResolver, + }); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/transforms/ExtractField.ts b/src/wrap/transforms/ExtractField.ts similarity index 78% rename from src/transforms/ExtractField.ts rename to src/wrap/transforms/ExtractField.ts index 216d8f61e7c..a8c27f09676 100644 --- a/src/transforms/ExtractField.ts +++ b/src/wrap/transforms/ExtractField.ts @@ -1,9 +1,10 @@ import { visit, Kind, SelectionSetNode, BREAK, FieldNode } from 'graphql'; -import { Transform, Request } from '../Interfaces'; + +import { Transform, Request } from '../../Interfaces'; export default class ExtractField implements Transform { - private from: Array; - private to: Array; + private readonly from: Array; + private readonly to: Array; constructor({ from, to }: { from: Array; to: Array }) { this.from = from; @@ -11,7 +12,7 @@ export default class ExtractField implements Transform { } public transformRequest(originalRequest: Request): Request { - let fromSelection: SelectionSetNode; + let fromSelection: SelectionSetNode | undefined; const ourPathFrom = JSON.stringify(this.from); const ourPathTo = JSON.stringify(this.to); let fieldPath: Array = []; @@ -24,7 +25,7 @@ export default class ExtractField implements Transform { return BREAK; } }, - leave: (node: FieldNode) => { + leave: () => { fieldPath.pop(); }, }, @@ -35,14 +36,17 @@ export default class ExtractField implements Transform { [Kind.FIELD]: { enter: (node: FieldNode) => { fieldPath.push(node.name.value); - if (ourPathTo === JSON.stringify(fieldPath) && fromSelection) { + if ( + ourPathTo === JSON.stringify(fieldPath) && + fromSelection != null + ) { return { ...node, selectionSet: fromSelection, }; } }, - leave: (node: FieldNode) => { + leave: () => { fieldPath.pop(); }, }, diff --git a/src/wrap/transforms/FilterInterfaceFields.ts b/src/wrap/transforms/FilterInterfaceFields.ts new file mode 100644 index 00000000000..550376cae9e --- /dev/null +++ b/src/wrap/transforms/FilterInterfaceFields.ts @@ -0,0 +1,20 @@ +import { GraphQLField, GraphQLSchema } from 'graphql'; + +import { Transform, FieldFilter } from '../../Interfaces'; + +import TransformInterfaceFields from './TransformInterfaceFields'; + +export default class FilterInterfaceFields implements Transform { + private readonly transformer: TransformInterfaceFields; + + constructor(filter: FieldFilter) { + this.transformer = new TransformInterfaceFields( + (typeName: string, fieldName: string, field: GraphQLField) => + filter(typeName, fieldName, field) ? undefined : null, + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } +} diff --git a/src/wrap/transforms/FilterObjectFields.ts b/src/wrap/transforms/FilterObjectFields.ts new file mode 100644 index 00000000000..1d55b7e0981 --- /dev/null +++ b/src/wrap/transforms/FilterObjectFields.ts @@ -0,0 +1,20 @@ +import { GraphQLField, GraphQLSchema } from 'graphql'; + +import { Transform, FieldFilter } from '../../Interfaces'; + +import TransformObjectFields from './TransformObjectFields'; + +export default class FilterObjectFields implements Transform { + private readonly transformer: TransformObjectFields; + + constructor(filter: FieldFilter) { + this.transformer = new TransformObjectFields( + (typeName: string, fieldName: string, field: GraphQLField) => + filter(typeName, fieldName, field) ? undefined : null, + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } +} diff --git a/src/transforms/FilterRootFields.ts b/src/wrap/transforms/FilterRootFields.ts similarity index 86% rename from src/transforms/FilterRootFields.ts rename to src/wrap/transforms/FilterRootFields.ts index bc7a2c1979b..19fe474e1de 100644 --- a/src/transforms/FilterRootFields.ts +++ b/src/wrap/transforms/FilterRootFields.ts @@ -1,5 +1,7 @@ import { GraphQLField, GraphQLSchema } from 'graphql'; -import { Transform } from './transforms'; + +import { Transform } from '../../Interfaces'; + import TransformRootFields from './TransformRootFields'; export type RootFilter = ( @@ -9,7 +11,7 @@ export type RootFilter = ( ) => boolean; export default class FilterRootFields implements Transform { - private transformer: TransformRootFields; + private readonly transformer: TransformRootFields; constructor(filter: RootFilter) { this.transformer = new TransformRootFields( @@ -20,9 +22,9 @@ export default class FilterRootFields implements Transform { ) => { if (filter(operation, fieldName, field)) { return undefined; - } else { - return null; } + + return null; }, ); } diff --git a/src/transforms/FilterToSchema.ts b/src/wrap/transforms/FilterToSchema.ts similarity index 50% rename from src/transforms/FilterToSchema.ts rename to src/wrap/transforms/FilterToSchema.ts index 4142e623d90..7a77dd359b2 100644 --- a/src/transforms/FilterToSchema.ts +++ b/src/wrap/transforms/FilterToSchema.ts @@ -4,14 +4,8 @@ import { FieldNode, FragmentDefinitionNode, FragmentSpreadNode, - GraphQLInterfaceType, - GraphQLList, - GraphQLNamedType, - GraphQLNonNull, - GraphQLObjectType, GraphQLSchema, GraphQLType, - GraphQLUnionType, InlineFragmentNode, Kind, OperationDefinitionNode, @@ -20,43 +14,48 @@ import { VariableDefinitionNode, VariableNode, visit, + TypeInfo, + visitWithTypeInfo, + getNamedType, + isObjectType, + isInterfaceType, } from 'graphql'; -import { Request } from '../Interfaces'; -import implementsAbstractType from '../implementsAbstractType'; -import { Transform } from './transforms'; + +import { Transform, Request } from '../../Interfaces'; +import implementsAbstractType from '../../utils/implementsAbstractType'; export default class FilterToSchema implements Transform { - private targetSchema: GraphQLSchema; + private readonly targetSchema: GraphQLSchema; constructor(targetSchema: GraphQLSchema) { this.targetSchema = targetSchema; } public transformRequest(originalRequest: Request): Request { - const document = filterDocumentToSchema( - this.targetSchema, - originalRequest.document, - ); return { ...originalRequest, - document, + ...filterToSchema( + this.targetSchema, + originalRequest.document, + originalRequest.variables, + ), }; } } -function filterDocumentToSchema( +function filterToSchema( targetSchema: GraphQLSchema, document: DocumentNode, -): DocumentNode { - const operations: Array< - OperationDefinitionNode - > = document.definitions.filter( - def => def.kind === Kind.OPERATION_DEFINITION, - ) as Array; + variables: Record, +): { document: DocumentNode; variables: Record } { + const operations: Array = document.definitions.filter( + (def) => def.kind === Kind.OPERATION_DEFINITION, + ) as Array; const fragments: Array = document.definitions.filter( - def => def.kind === Kind.FRAGMENT_DEFINITION, + (def) => def.kind === Kind.FRAGMENT_DEFINITION, ) as Array; + let usedVariables: Array = []; let usedFragments: Array = []; const newOperations: Array = []; let newFragments: Array = []; @@ -95,7 +94,7 @@ function filterDocumentToSchema( targetSchema, type, validFragmentsWithType, - operation.selectionSet + operation.selectionSet, ); usedFragments = union(usedFragments, operationUsedFragments); @@ -111,14 +110,18 @@ function filterDocumentToSchema( validFragmentsWithType, usedFragments, ); - const fullUsedVariables = - union(operationUsedVariables, collectedUsedVariables); + const operationOrFragmentVariables = union( + operationUsedVariables, + collectedUsedVariables, + ); + usedVariables = union(usedVariables, operationOrFragmentVariables); newFragments = collectedNewFragments; fragmentSet = collectedFragmentSet; const variableDefinitions = operation.variableDefinitions.filter( (variable: VariableDefinitionNode) => - fullUsedVariables.indexOf(variable.variable.name.value) !== -1, + operationOrFragmentVariables.indexOf(variable.variable.name.value) !== + -1, ); newOperations.push({ @@ -131,28 +134,38 @@ function filterDocumentToSchema( }); }); + const newVariables: Record = {}; + usedVariables.forEach((variableName) => { + newVariables[variableName] = variables[variableName]; + }); + return { - kind: Kind.DOCUMENT, - definitions: [...newOperations, ...newFragments], + document: { + kind: Kind.DOCUMENT, + definitions: [...newOperations, ...newFragments], + }, + variables: newVariables, }; } function collectFragmentVariables( targetSchema: GraphQLSchema, - fragmentSet: Object, + fragmentSet: object, validFragments: Array, validFragmentsWithType: { [name: string]: GraphQLType }, usedFragments: Array, ) { + let remainingFragments = usedFragments.slice(); + let usedVariables: Array = []; - let newFragments: Array = []; + const newFragments: Array = []; - while (usedFragments.length !== 0) { - const nextFragmentName = usedFragments.pop(); + while (remainingFragments.length !== 0) { + const nextFragmentName = remainingFragments.pop(); const fragment = validFragments.find( - fr => fr.name.value === nextFragmentName, + (fr) => fr.name.value === nextFragmentName, ); - if (fragment) { + if (fragment != null) { const name = nextFragmentName; const typeName = fragment.typeCondition.name.value; const type = targetSchema.getType(typeName); @@ -165,8 +178,8 @@ function collectFragmentVariables( type, validFragmentsWithType, fragment.selectionSet, - ); - usedFragments = union(usedFragments, fragmentUsedFragments); + ); + remainingFragments = union(remainingFragments, fragmentUsedFragments); usedVariables = union(usedVariables, fragmentUsedVariables); if (!fragmentSet[name]) { @@ -199,111 +212,90 @@ function filterSelectionSet( ) { const usedFragments: Array = []; const usedVariables: Array = []; - const typeStack: Array = [type]; - // Should be rewritten using visitWithSchema - const filteredSelectionSet = visit(selectionSet, { - [Kind.FIELD]: { - enter(node: FieldNode): null | undefined | FieldNode { - let parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - if ( - parentType instanceof GraphQLObjectType || - parentType instanceof GraphQLInterfaceType - ) { - const fields = parentType.getFields(); - const field = - node.name.value === '__typename' - ? TypeNameMetaFieldDef - : fields[node.name.value]; - if (!field) { - return null; - } else { - typeStack.push(field.type); - } + const typeInfo = new TypeInfo(schema, undefined, type); + const filteredSelectionSet = visit( + selectionSet, + visitWithTypeInfo(typeInfo, { + [Kind.FIELD]: { + enter(node: FieldNode): null | undefined | FieldNode { + const parentType = typeInfo.getParentType(); + if (isObjectType(parentType) || isInterfaceType(parentType)) { + const fields = parentType.getFields(); + const field = + node.name.value === '__typename' + ? TypeNameMetaFieldDef + : fields[node.name.value]; + if (!field) { + return null; + } - const argNames = (field.args || []).map(arg => arg.name); - if (node.arguments) { - let args = node.arguments.filter((arg: ArgumentNode) => { - return argNames.indexOf(arg.name.value) !== -1; - }); - if (args.length !== node.arguments.length) { - return { - ...node, - arguments: args, - }; + const argNames = (field.args != null ? field.args : []).map( + (arg) => arg.name, + ); + if (node.arguments != null) { + const args = node.arguments.filter( + (arg: ArgumentNode) => argNames.indexOf(arg.name.value) !== -1, + ); + if (args.length !== node.arguments.length) { + return { + ...node, + arguments: args, + }; + } } } - } else if ( - parentType instanceof GraphQLUnionType && - node.name.value === '__typename' - ) { - typeStack.push(TypeNameMetaFieldDef.type); - } - }, - leave(node: FieldNode): null | undefined | FieldNode { - const currentType = typeStack.pop(); - const resolvedType = resolveType(currentType); - if ( - resolvedType instanceof GraphQLObjectType || - resolvedType instanceof GraphQLInterfaceType - ) { - const selections = node.selectionSet && node.selectionSet.selections || null; - if (!selections || selections.length === 0) { - // need to remove any added variables. Is there a better way to do this? - visit(node, { - [Kind.VARIABLE](variableNode: VariableNode) { - const index = usedVariables.indexOf(variableNode.name.value); - if (index !== -1) { - usedVariables.splice(index, 1); - } - } + }, + leave(node: FieldNode): null | undefined | FieldNode { + const resolvedType = getNamedType(typeInfo.getType()); + if (isObjectType(resolvedType) || isInterfaceType(resolvedType)) { + const selections = + node.selectionSet != null ? node.selectionSet.selections : null; + if (selections == null || selections.length === 0) { + // need to remove any added variables. Is there a better way to do this? + visit(node, { + [Kind.VARIABLE](variableNode: VariableNode) { + const index = usedVariables.indexOf(variableNode.name.value); + if (index !== -1) { + usedVariables.splice(index, 1); + } + }, + }); + return null; } - ); - return null; } - } + }, }, - }, - [Kind.FRAGMENT_SPREAD](node: FragmentSpreadNode): null | undefined { - if (node.name.value in validFragments) { - const parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - const innerType = validFragments[node.name.value]; - if (!implementsAbstractType(schema, parentType, innerType)) { - return null; - } else { + [Kind.FRAGMENT_SPREAD](node: FragmentSpreadNode): null | undefined { + if (node.name.value in validFragments) { + const parentType = typeInfo.getParentType(); + const innerType = validFragments[node.name.value]; + if (!implementsAbstractType(schema, parentType, innerType)) { + return null; + } + usedFragments.push(node.name.value); return; } - } else { + return null; - } - }, - [Kind.INLINE_FRAGMENT]: { - enter(node: InlineFragmentNode): null | undefined { - if (node.typeCondition) { - const innerType = schema.getType(node.typeCondition.name.value); - const parentType: GraphQLNamedType = resolveType( - typeStack[typeStack.length - 1], - ); - if (implementsAbstractType(schema, parentType, innerType)) { - typeStack.push(innerType); - } else { - return null; + }, + [Kind.INLINE_FRAGMENT]: { + enter(node: InlineFragmentNode): null | undefined { + if (node.typeCondition != null) { + const parentType = typeInfo.getParentType(); + const innerType = schema.getType(node.typeCondition.name.value); + if (!implementsAbstractType(schema, parentType, innerType)) { + return null; + } } - } + }, }, - leave(node: InlineFragmentNode) { - typeStack.pop(); + [Kind.VARIABLE](node: VariableNode) { + usedVariables.push(node.name.value); }, - }, - [Kind.VARIABLE](node: VariableNode) { - usedVariables.push(node.name.value); - }, - }); + }), + ); return { selectionSet: filteredSelectionSet, @@ -312,22 +304,11 @@ function filterSelectionSet( }; } -function resolveType(type: GraphQLType): GraphQLNamedType { - let lastType = type; - while ( - lastType instanceof GraphQLNonNull || - lastType instanceof GraphQLList - ) { - lastType = lastType.ofType; - } - return lastType; -} - function union(...arrays: Array>): Array { const cache: { [key: string]: boolean } = {}; const result: Array = []; - arrays.forEach(array => { - array.forEach(item => { + arrays.forEach((array) => { + array.forEach((item) => { if (!cache[item]) { cache[item] = true; result.push(item); diff --git a/src/transforms/FilterTypes.ts b/src/wrap/transforms/FilterTypes.ts similarity index 50% rename from src/transforms/FilterTypes.ts rename to src/wrap/transforms/FilterTypes.ts index 8d8196bd98d..da8def470f8 100644 --- a/src/transforms/FilterTypes.ts +++ b/src/wrap/transforms/FilterTypes.ts @@ -1,24 +1,23 @@ -/* tslint:disable:no-unused-expression */ - import { GraphQLSchema, GraphQLNamedType } from 'graphql'; -import { Transform } from '../transforms/transforms'; -import { visitSchema, VisitSchemaKind } from '../transforms/visitSchema'; + +import { mapSchema } from '../../utils/index'; +import { Transform, MapperKind } from '../../Interfaces'; export default class FilterTypes implements Transform { - private filter: (type: GraphQLNamedType) => boolean; + private readonly filter: (type: GraphQLNamedType) => boolean; constructor(filter: (type: GraphQLNamedType) => boolean) { this.filter = filter; } public transformSchema(schema: GraphQLSchema): GraphQLSchema { - return visitSchema(schema, { - [VisitSchemaKind.TYPE]: (type: GraphQLNamedType) => { + return mapSchema(schema, { + [MapperKind.TYPE]: (type: GraphQLNamedType) => { if (this.filter(type)) { return undefined; - } else { - return null; } + + return null; }, }); } diff --git a/src/wrap/transforms/HoistField.ts b/src/wrap/transforms/HoistField.ts new file mode 100644 index 00000000000..c2d1eae9902 --- /dev/null +++ b/src/wrap/transforms/HoistField.ts @@ -0,0 +1,68 @@ +import { GraphQLSchema, GraphQLObjectType, getNullableType } from 'graphql'; + +import { healSchema, wrapFieldNode, renameFieldNode } from '../../utils/index'; +import { createMergedResolver } from '../../stitch/index'; +import { appendFields, removeFields } from '../../utils/fields'; +import { Transform, Request } from '../../Interfaces'; + +import MapFields from './MapFields'; + +export default class HoistField implements Transform { + private readonly typeName: string; + private readonly path: Array; + private readonly newFieldName: string; + private readonly pathToField: Array; + private readonly oldFieldName: string; + private readonly transformer: Transform; + + constructor(typeName: string, path: Array, newFieldName: string) { + this.typeName = typeName; + this.path = path; + this.newFieldName = newFieldName; + + this.pathToField = this.path.slice(); + this.oldFieldName = this.pathToField.pop(); + this.transformer = new MapFields({ + [typeName]: { + [newFieldName]: (fieldNode) => + wrapFieldNode( + renameFieldNode(fieldNode, this.oldFieldName), + this.pathToField, + ), + }, + }); + } + + public transformSchema(schema: GraphQLSchema): GraphQLSchema { + const typeMap = schema.getTypeMap(); + + const innerType: GraphQLObjectType = this.pathToField.reduce( + (acc, pathSegment) => + getNullableType(acc.getFields()[pathSegment].type) as GraphQLObjectType, + typeMap[this.typeName] as GraphQLObjectType, + ); + + const targetField = removeFields( + typeMap, + innerType.name, + (fieldName) => fieldName === this.oldFieldName, + )[this.oldFieldName]; + + const targetType = targetField.type as GraphQLObjectType; + + appendFields(typeMap, this.typeName, { + [this.newFieldName]: { + type: targetType, + resolve: createMergedResolver({ fromPath: this.pathToField }), + }, + }); + + healSchema(schema); + + return this.transformer.transformSchema(schema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/wrap/transforms/MapFields.ts b/src/wrap/transforms/MapFields.ts new file mode 100644 index 00000000000..7d204c70f55 --- /dev/null +++ b/src/wrap/transforms/MapFields.ts @@ -0,0 +1,53 @@ +import { + GraphQLSchema, + FieldNode, + SelectionNode, + FragmentDefinitionNode, +} from 'graphql'; + +import { Transform, Request } from '../../Interfaces'; +import { toConfig } from '../../polyfills/index'; + +import TransformObjectFields from './TransformObjectFields'; + +export type FieldNodeTransformer = ( + fieldNode: FieldNode, + fragments: Record, +) => SelectionNode | Array; + +export type FieldNodeTransformerMap = { + [typeName: string]: { + [fieldName: string]: FieldNodeTransformer; + }; +}; + +export default class MapFields implements Transform { + private readonly transformer: TransformObjectFields; + + constructor(fieldNodeTransformerMap: FieldNodeTransformerMap) { + this.transformer = new TransformObjectFields( + (_typeName, _fieldName, field) => toConfig(field), + (typeName, fieldName, fieldNode, fragments) => { + const typeTransformers = fieldNodeTransformerMap[typeName]; + if (typeTransformers == null) { + return fieldNode; + } + + const fieldNodeTransformer = typeTransformers[fieldName]; + if (fieldNodeTransformer == null) { + return fieldNode; + } + + return fieldNodeTransformer(fieldNode, fragments); + }, + ); + } + + public transformSchema(schema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(schema); + } + + public transformRequest(request: Request): Request { + return this.transformer.transformRequest(request); + } +} diff --git a/src/wrap/transforms/RenameInterfaceFields.ts b/src/wrap/transforms/RenameInterfaceFields.ts new file mode 100644 index 00000000000..b1e174c838c --- /dev/null +++ b/src/wrap/transforms/RenameInterfaceFields.ts @@ -0,0 +1,31 @@ +import { GraphQLField, GraphQLSchema } from 'graphql'; + +import { Transform, Request } from '../../Interfaces'; + +import TransformInterfaceFields from './TransformInterfaceFields'; + +export default class RenameInterfaceFields implements Transform { + private readonly transformer: TransformInterfaceFields; + + constructor( + renamer: ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => string, + ) { + this.transformer = new TransformInterfaceFields( + (typeName: string, fieldName: string, field: GraphQLField) => ({ + name: renamer(typeName, fieldName, field), + }), + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/wrap/transforms/RenameObjectFields.ts b/src/wrap/transforms/RenameObjectFields.ts new file mode 100644 index 00000000000..1ca613cbab2 --- /dev/null +++ b/src/wrap/transforms/RenameObjectFields.ts @@ -0,0 +1,31 @@ +import { GraphQLField, GraphQLSchema } from 'graphql'; + +import { Transform, Request } from '../../Interfaces'; + +import TransformObjectFields from './TransformObjectFields'; + +export default class RenameObjectFields implements Transform { + private readonly transformer: TransformObjectFields; + + constructor( + renamer: ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => string, + ) { + this.transformer = new TransformObjectFields( + (typeName: string, fieldName: string, field: GraphQLField) => ({ + name: renamer(typeName, fieldName, field), + }), + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/transforms/RenameRootFields.ts b/src/wrap/transforms/RenameRootFields.ts similarity index 53% rename from src/transforms/RenameRootFields.ts rename to src/wrap/transforms/RenameRootFields.ts index 30fdbf4fe67..1a9f5bfe468 100644 --- a/src/transforms/RenameRootFields.ts +++ b/src/wrap/transforms/RenameRootFields.ts @@ -1,13 +1,11 @@ -import { GraphQLNamedType, GraphQLField, GraphQLSchema } from 'graphql'; -import { Transform } from './transforms'; -import { - createResolveType, - fieldToFieldConfig, -} from '../stitching/schemaRecreation'; +import { GraphQLField, GraphQLSchema } from 'graphql'; + +import { Transform, Request } from '../../Interfaces'; + import TransformRootFields from './TransformRootFields'; export default class RenameRootFields implements Transform { - private transformer: TransformRootFields; + private readonly transformer: TransformRootFields; constructor( renamer: ( @@ -16,24 +14,22 @@ export default class RenameRootFields implements Transform { field: GraphQLField, ) => string, ) { - const resolveType = createResolveType( - (name: string, type: GraphQLNamedType): GraphQLNamedType => type, - ); this.transformer = new TransformRootFields( ( operation: 'Query' | 'Mutation' | 'Subscription', fieldName: string, field: GraphQLField, - ) => { - return { - name: renamer(operation, fieldName, field), - field: fieldToFieldConfig(field, resolveType, true), - }; - }, + ) => ({ + name: renamer(operation, fieldName, field), + }), ); } public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { return this.transformer.transformSchema(originalSchema); } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } } diff --git a/src/wrap/transforms/RenameRootTypes.ts b/src/wrap/transforms/RenameRootTypes.ts new file mode 100644 index 00000000000..db09e38d7dc --- /dev/null +++ b/src/wrap/transforms/RenameRootTypes.ts @@ -0,0 +1,89 @@ +import { + visit, + GraphQLSchema, + NamedTypeNode, + Kind, + GraphQLObjectType, +} from 'graphql'; + +import { Request, Result, MapperKind, Transform } from '../../Interfaces'; +import { mapSchema } from '../../utils/index'; +import { toConfig } from '../../polyfills/index'; + +export default class RenameRootTypes implements Transform { + private readonly renamer: (name: string) => string | undefined; + private map: { [key: string]: string }; + private reverseMap: { [key: string]: string }; + + constructor(renamer: (name: string) => string | undefined) { + this.renamer = renamer; + this.map = {}; + this.reverseMap = {}; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return mapSchema(originalSchema, { + [MapperKind.ROOT_OBJECT]: (type) => { + const oldName = type.name; + const newName = this.renamer(oldName); + if (newName && newName !== oldName) { + this.map[oldName] = type.name; + this.reverseMap[newName] = oldName; + return new GraphQLObjectType({ + ...toConfig(type), + name: newName, + }); + } + }, + }); + } + + public transformRequest(originalRequest: Request): Request { + const newDocument = visit(originalRequest.document, { + [Kind.NAMED_TYPE]: (node: NamedTypeNode) => { + const name = node.name.value; + if (name in this.reverseMap) { + return { + ...node, + name: { + kind: Kind.NAME, + value: this.reverseMap[name], + }, + }; + } + }, + }); + return { + document: newDocument, + variables: originalRequest.variables, + }; + } + + public transformResult(result: Result): Result { + return { + ...result, + data: this.renameTypes(result.data), + }; + } + + private renameTypes(value: any): any { + if (value == null) { + return value; + } else if (Array.isArray(value)) { + value.forEach((v, index) => { + value[index] = this.renameTypes(v); + }); + return value; + } else if (typeof value === 'object') { + Object.keys(value).forEach((key) => { + value[key] = + key === '__typename' + ? this.renamer(value[key]) + : this.renameTypes(value[key]); + }); + return value; + } + + return value; + } +} diff --git a/src/wrap/transforms/RenameTypes.ts b/src/wrap/transforms/RenameTypes.ts new file mode 100644 index 00000000000..1891267d29f --- /dev/null +++ b/src/wrap/transforms/RenameTypes.ts @@ -0,0 +1,142 @@ +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLNamedType, + GraphQLObjectType, + GraphQLSchema, + GraphQLScalarType, + GraphQLUnionType, + Kind, + NamedTypeNode, + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isScalarType, + isUnionType, + visit, +} from 'graphql'; + +import { isSpecifiedScalarType, toConfig } from '../../polyfills/index'; +import { Transform, Request, Result, MapperKind } from '../../Interfaces'; +import { mapSchema } from '../../utils/index'; + +export type RenameOptions = { + renameBuiltins: boolean; + renameScalars: boolean; +}; + +export default class RenameTypes implements Transform { + private readonly renamer: (name: string) => string | undefined; + private map: { [key: string]: string }; + private reverseMap: { [key: string]: string }; + private readonly renameBuiltins: boolean; + private readonly renameScalars: boolean; + + constructor( + renamer: (name: string) => string | undefined, + options?: RenameOptions, + ) { + this.renamer = renamer; + this.map = {}; + this.reverseMap = {}; + const { renameBuiltins = false, renameScalars = true } = + options != null ? options : {}; + this.renameBuiltins = renameBuiltins; + this.renameScalars = renameScalars; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return mapSchema(originalSchema, { + [MapperKind.TYPE]: (type: GraphQLNamedType) => { + if (isSpecifiedScalarType(type) && !this.renameBuiltins) { + return undefined; + } + if (isScalarType(type) && !this.renameScalars) { + return undefined; + } + const oldName = type.name; + const newName = this.renamer(oldName); + if (newName && newName !== oldName) { + this.map[oldName] = type.name; + this.reverseMap[newName] = oldName; + + const newConfig = { + ...toConfig(type), + name: newName, + }; + + if (isObjectType(type)) { + return new GraphQLObjectType(newConfig); + } else if (isInterfaceType(type)) { + return new GraphQLInterfaceType(newConfig); + } else if (isUnionType(type)) { + return new GraphQLUnionType(newConfig); + } else if (isInputObjectType(type)) { + return new GraphQLInputObjectType(newConfig); + } else if (isEnumType(type)) { + return new GraphQLEnumType(newConfig); + } else if (isScalarType(type)) { + return new GraphQLScalarType(newConfig); + } + + throw new Error(`Unknown type ${type as string}.`); + } + }, + + [MapperKind.ROOT_OBJECT]() { + return undefined; + }, + }); + } + + public transformRequest(originalRequest: Request): Request { + const newDocument = visit(originalRequest.document, { + [Kind.NAMED_TYPE]: (node: NamedTypeNode) => { + const name = node.name.value; + if (name in this.reverseMap) { + return { + ...node, + name: { + kind: Kind.NAME, + value: this.reverseMap[name], + }, + }; + } + }, + }); + return { + document: newDocument, + variables: originalRequest.variables, + }; + } + + public transformResult(result: Result): Result { + return { + ...result, + data: this.renameTypes(result.data), + }; + } + + private renameTypes(value: any): any { + if (value == null) { + return value; + } else if (Array.isArray(value)) { + value.forEach((v, index) => { + value[index] = this.renameTypes(v); + }); + return value; + } else if (typeof value === 'object') { + Object.keys(value).forEach((key) => { + value[key] = + key === '__typename' + ? this.renamer(value[key]) + : this.renameTypes(value[key]); + }); + return value; + } + + return value; + } +} diff --git a/src/transforms/ReplaceFieldWithFragment.ts b/src/wrap/transforms/ReplaceFieldWithFragment.ts similarity index 51% rename from src/transforms/ReplaceFieldWithFragment.ts rename to src/wrap/transforms/ReplaceFieldWithFragment.ts index 41b472d9629..a8aa3d87d26 100644 --- a/src/transforms/ReplaceFieldWithFragment.ts +++ b/src/wrap/transforms/ReplaceFieldWithFragment.ts @@ -10,14 +10,14 @@ import { parse, visit, visitWithTypeInfo, - SelectionNode, } from 'graphql'; -import { Request } from '../Interfaces'; -import { Transform } from './transforms'; + +import { concatInlineFragments } from '../../utils/index'; +import { Transform, Request } from '../../Interfaces'; export default class ReplaceFieldWithFragment implements Transform { - private targetSchema: GraphQLSchema; - private mapping: FieldToFragmentMapping; + private readonly targetSchema: GraphQLSchema; + private readonly mapping: FieldToFragmentMapping; constructor( targetSchema: GraphQLSchema, @@ -31,12 +31,14 @@ export default class ReplaceFieldWithFragment implements Transform { for (const { field, fragment } of fragments) { const parsedFragment = parseFragmentToInlineFragment(fragment); const actualTypeName = parsedFragment.typeCondition.name.value; - this.mapping[actualTypeName] = this.mapping[actualTypeName] || {}; + if (this.mapping[actualTypeName] == null) { + this.mapping[actualTypeName] = {}; + } - if (this.mapping[actualTypeName][field]) { - this.mapping[actualTypeName][field].push(parsedFragment); - } else { + if (this.mapping[actualTypeName][field] == null) { this.mapping[actualTypeName][field] = [parsedFragment]; + } else { + this.mapping[actualTypeName][field].push(parsedFragment); } } } @@ -55,7 +57,7 @@ export default class ReplaceFieldWithFragment implements Transform { } type FieldToFragmentMapping = { - [typeName: string]: { [fieldName: string]: InlineFragmentNode[] }; + [typeName: string]: { [fieldName: string]: Array }; }; function replaceFieldsWithFragments( @@ -71,16 +73,16 @@ function replaceFieldsWithFragments( node: SelectionSetNode, ): SelectionSetNode | null | undefined { const parentType: GraphQLType = typeInfo.getParentType(); - if (parentType) { + if (parentType != null) { const parentTypeName = parentType.name; let selections = node.selections; - if (mapping[parentTypeName]) { - node.selections.forEach(selection => { + if (mapping[parentTypeName] != null) { + node.selections.forEach((selection) => { if (selection.kind === Kind.FIELD) { const name = selection.name.value; const fragments = mapping[parentTypeName][name]; - if (fragments && fragments.length > 0) { + if (fragments != null && fragments.length > 0) { const fragment = concatInlineFragments( parentTypeName, fragments, @@ -129,103 +131,3 @@ function parseFragmentToInlineFragment( throw new Error('Could not parse fragment'); } - -function concatInlineFragments( - type: string, - fragments: InlineFragmentNode[], -): InlineFragmentNode { - const fragmentSelections: SelectionNode[] = fragments.reduce( - (selections, fragment) => { - return selections.concat(fragment.selectionSet.selections); - }, - [], - ); - - const deduplicatedFragmentSelection: SelectionNode[] = deduplicateSelection( - fragmentSelections, - ); - - return { - kind: Kind.INLINE_FRAGMENT, - typeCondition: { - kind: Kind.NAMED_TYPE, - name: { - kind: Kind.NAME, - value: type, - }, - }, - selectionSet: { - kind: Kind.SELECTION_SET, - selections: deduplicatedFragmentSelection, - }, - }; -} - -function deduplicateSelection(nodes: SelectionNode[]): SelectionNode[] { - const selectionMap = nodes.reduce<{ [key: string]: SelectionNode }>( - (map, node) => { - switch (node.kind) { - case 'Field': { - if (node.alias) { - if (map.hasOwnProperty(node.alias.value)) { - return map; - } else { - return { - ...map, - [node.alias.value]: node, - }; - } - } else { - if (map.hasOwnProperty(node.name.value)) { - return map; - } else { - return { - ...map, - [node.name.value]: node, - }; - } - } - } - case 'FragmentSpread': { - if (map.hasOwnProperty(node.name.value)) { - return map; - } else { - return { - ...map, - [node.name.value]: node, - }; - } - } - case 'InlineFragment': { - if (map.__fragment) { - const fragment = map.__fragment as InlineFragmentNode; - - return { - ...map, - __fragment: concatInlineFragments( - fragment.typeCondition.name.value, - [fragment, node], - ), - }; - } else { - return { - ...map, - __fragment: node, - }; - } - } - default: { - return map; - } - } - }, - {}, - ); - - const selection = Object.keys(selectionMap).reduce( - (selectionList, node) => selectionList.concat(selectionMap[node]), - [], - ); - - return selection; -} diff --git a/src/wrap/transforms/TransformCompositeFields.ts b/src/wrap/transforms/TransformCompositeFields.ts new file mode 100644 index 00000000000..2825000675c --- /dev/null +++ b/src/wrap/transforms/TransformCompositeFields.ts @@ -0,0 +1,222 @@ +import { + GraphQLSchema, + GraphQLType, + DocumentNode, + TypeInfo, + visit, + visitWithTypeInfo, + Kind, + SelectionSetNode, + SelectionNode, + FragmentDefinitionNode, + GraphQLInterfaceType, + isObjectType, + isInterfaceType, + GraphQLObjectType, +} from 'graphql'; + +import isEmptyObject from '../../utils/isEmptyObject'; +import { + Transform, + Request, + MapperKind, + FieldTransformer, + FieldNodeTransformer, + RenamedField, +} from '../../Interfaces'; +import { mapSchema } from '../../utils/index'; +import { toConfig } from '../../polyfills/index'; + +type FieldMapping = { + [typeName: string]: { + [newFieldName: string]: string; + }; +}; + +export default class TransformCompositeFields implements Transform { + private readonly fieldTransformer: FieldTransformer; + private readonly fieldNodeTransformer: FieldNodeTransformer; + private transformedSchema: GraphQLSchema; + private mapping: FieldMapping; + + constructor( + fieldTransformer: FieldTransformer, + fieldNodeTransformer?: FieldNodeTransformer, + ) { + this.fieldTransformer = fieldTransformer; + this.fieldNodeTransformer = fieldNodeTransformer; + this.mapping = {}; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + this.transformedSchema = mapSchema(originalSchema, { + [MapperKind.OBJECT_TYPE]: (type: GraphQLObjectType) => + this.transformFields(type, this.fieldTransformer), + [MapperKind.INTERFACE_TYPE]: (type: GraphQLInterfaceType) => + this.transformFields(type, this.fieldTransformer), + }); + + return this.transformedSchema; + } + + public transformRequest(originalRequest: Request): Request { + const fragments = {}; + originalRequest.document.definitions + .filter((def) => def.kind === Kind.FRAGMENT_DEFINITION) + .forEach((def) => { + fragments[(def as FragmentDefinitionNode).name.value] = def; + }); + const document = this.transformDocument( + originalRequest.document, + this.mapping, + this.fieldNodeTransformer, + fragments, + ); + return { + ...originalRequest, + document, + }; + } + + private transformFields( + type: GraphQLObjectType, + fieldTransformer: FieldTransformer, + ): GraphQLObjectType; + + private transformFields( + type: GraphQLInterfaceType, + fieldTransformer: FieldTransformer, + ): GraphQLInterfaceType; + + private transformFields(type: any, fieldTransformer: FieldTransformer): any { + const typeConfig = toConfig(type); + const fields = type.getFields(); + const newFields = {}; + + Object.keys(fields).forEach((fieldName) => { + const field = fields[fieldName]; + const transformedField = fieldTransformer(type.name, fieldName, field); + + if (typeof transformedField === 'undefined') { + newFields[fieldName] = typeConfig.fields[fieldName]; + } else if (transformedField !== null) { + const newName = (transformedField as RenamedField).name; + + if (newName) { + newFields[newName] = + (transformedField as RenamedField).field != null + ? (transformedField as RenamedField).field + : typeConfig.fields[fieldName]; + + if (newName !== fieldName) { + const typeName = type.name; + if (!this.mapping[typeName]) { + this.mapping[typeName] = {}; + } + this.mapping[typeName][newName] = fieldName; + } + } else { + newFields[fieldName] = transformedField; + } + } + }); + + if (isEmptyObject(newFields)) { + return null; + } + + if (isObjectType(type)) { + return new GraphQLObjectType({ + ...toConfig(type), + fields: newFields, + }); + } else if (isInterfaceType(type)) { + return new GraphQLInterfaceType({ + ...toConfig(type), + fields: newFields, + }); + } + } + + private transformDocument( + document: DocumentNode, + mapping: FieldMapping, + fieldNodeTransformer?: FieldNodeTransformer, + fragments: Record = {}, + ): DocumentNode { + const typeInfo = new TypeInfo(this.transformedSchema); + const newDocument: DocumentNode = visit( + document, + visitWithTypeInfo(typeInfo, { + leave: { + [Kind.SELECTION_SET]: (node: SelectionSetNode): SelectionSetNode => { + const parentType: GraphQLType = typeInfo.getParentType(); + if (parentType != null) { + const parentTypeName = parentType.name; + let newSelections: Array = []; + + node.selections.forEach((selection) => { + if (selection.kind !== Kind.FIELD) { + newSelections.push(selection); + return; + } + + const newName = selection.name.value; + + const transformedSelection = + fieldNodeTransformer != null + ? fieldNodeTransformer( + parentTypeName, + newName, + selection, + fragments, + ) + : selection; + + if (Array.isArray(transformedSelection)) { + newSelections = newSelections.concat(transformedSelection); + return; + } + + if (transformedSelection.kind !== Kind.FIELD) { + newSelections.push(transformedSelection); + return; + } + + const typeMapping = mapping[parentTypeName]; + if (typeMapping == null) { + newSelections.push(transformedSelection); + return; + } + + const oldName = mapping[parentTypeName][newName]; + if (oldName == null) { + newSelections.push(transformedSelection); + return; + } + + newSelections.push({ + ...transformedSelection, + name: { + kind: Kind.NAME, + value: oldName, + }, + alias: { + kind: Kind.NAME, + value: newName, + }, + }); + }); + + return { + ...node, + selections: newSelections, + }; + } + }, + }, + }), + ); + return newDocument; + } +} diff --git a/src/wrap/transforms/TransformInterfaceFields.ts b/src/wrap/transforms/TransformInterfaceFields.ts new file mode 100644 index 00000000000..47544b64baa --- /dev/null +++ b/src/wrap/transforms/TransformInterfaceFields.ts @@ -0,0 +1,49 @@ +import { GraphQLSchema, GraphQLField, isInterfaceType } from 'graphql'; + +import { + Transform, + Request, + FieldTransformer, + FieldNodeTransformer, +} from '../../Interfaces'; + +import TransformCompositeFields from './TransformCompositeFields'; + +export default class TransformInterfaceFields implements Transform { + private readonly interfaceFieldTransformer: FieldTransformer; + private readonly fieldNodeTransformer: FieldNodeTransformer; + private transformer: TransformCompositeFields; + + constructor( + interfaceFieldTransformer: FieldTransformer, + fieldNodeTransformer?: FieldNodeTransformer, + ) { + this.interfaceFieldTransformer = interfaceFieldTransformer; + this.fieldNodeTransformer = fieldNodeTransformer; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + const compositeToObjectFieldTransformer = ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => { + if (isInterfaceType(originalSchema.getType(typeName))) { + return this.interfaceFieldTransformer(typeName, fieldName, field); + } + + return undefined; + }; + + this.transformer = new TransformCompositeFields( + compositeToObjectFieldTransformer, + this.fieldNodeTransformer, + ); + + return this.transformer.transformSchema(originalSchema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/wrap/transforms/TransformObjectFields.ts b/src/wrap/transforms/TransformObjectFields.ts new file mode 100644 index 00000000000..c566bb5d34f --- /dev/null +++ b/src/wrap/transforms/TransformObjectFields.ts @@ -0,0 +1,49 @@ +import { GraphQLSchema, GraphQLField, isObjectType } from 'graphql'; + +import { + Transform, + Request, + FieldTransformer, + FieldNodeTransformer, +} from '../../Interfaces'; + +import TransformCompositeFields from './TransformCompositeFields'; + +export default class TransformObjectFields implements Transform { + private readonly objectFieldTransformer: FieldTransformer; + private readonly fieldNodeTransformer: FieldNodeTransformer; + private transformer: TransformCompositeFields; + + constructor( + objectFieldTransformer: FieldTransformer, + fieldNodeTransformer?: FieldNodeTransformer, + ) { + this.objectFieldTransformer = objectFieldTransformer; + this.fieldNodeTransformer = fieldNodeTransformer; + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + const compositeToObjectFieldTransformer = ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => { + if (isObjectType(originalSchema.getType(typeName))) { + return this.objectFieldTransformer(typeName, fieldName, field); + } + + return undefined; + }; + + this.transformer = new TransformCompositeFields( + compositeToObjectFieldTransformer, + this.fieldNodeTransformer, + ); + + return this.transformer.transformSchema(originalSchema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/wrap/transforms/TransformQuery.ts b/src/wrap/transforms/TransformQuery.ts new file mode 100644 index 00000000000..83cd1caa917 --- /dev/null +++ b/src/wrap/transforms/TransformQuery.ts @@ -0,0 +1,148 @@ +import { + visit, + Kind, + SelectionSetNode, + FragmentDefinitionNode, + GraphQLError, +} from 'graphql'; + +import { Transform, Request, Result } from '../../Interfaces'; + +export type QueryTransformer = ( + selectionSet: SelectionSetNode, + fragments: Record, +) => SelectionSetNode; + +export type ResultTransformer = (result: any) => any; + +export type ErrorPathTransformer = ( + path: ReadonlyArray, +) => Array; + +export default class TransformQuery implements Transform { + private readonly path: Array; + private readonly queryTransformer: QueryTransformer; + private readonly resultTransformer: ResultTransformer; + private readonly errorPathTransformer: ErrorPathTransformer; + private readonly fragments: Record; + + constructor({ + path, + queryTransformer, + resultTransformer = (result) => result, + errorPathTransformer = (errorPath) => [].concat(errorPath), + fragments = {}, + }: { + path: Array; + queryTransformer: QueryTransformer; + resultTransformer?: ResultTransformer; + errorPathTransformer?: ErrorPathTransformer; + fragments?: Record; + }) { + this.path = path; + this.queryTransformer = queryTransformer; + this.resultTransformer = resultTransformer; + this.errorPathTransformer = errorPathTransformer; + this.fragments = fragments; + } + + public transformRequest(originalRequest: Request): Request { + const document = originalRequest.document; + + const pathLength = this.path.length; + let index = 0; + const newDocument = visit(document, { + [Kind.FIELD]: { + enter: (node) => { + if (index === pathLength || node.name.value !== this.path[index]) { + return false; + } + + index++; + + if (index === pathLength) { + const selectionSet = this.queryTransformer( + node.selectionSet, + this.fragments, + ); + + return { + ...node, + selectionSet, + }; + } + }, + leave: () => { + index--; + }, + }, + }); + return { + ...originalRequest, + document: newDocument, + }; + } + + public transformResult(originalResult: Result): Result { + const data = this.transformData(originalResult.data); + const errors = originalResult.errors; + return { + data, + errors: errors != null ? this.transformErrors(errors) : undefined, + }; + } + + private transformData(data: any): any { + const leafIndex = this.path.length - 1; + let index = 0; + let newData = data; + if (newData) { + let next = this.path[index]; + while (index < leafIndex) { + if (data[next]) { + newData = newData[next]; + } else { + break; + } + index++; + next = this.path[index]; + } + newData[next] = this.resultTransformer(newData[next]); + } + return newData; + } + + private transformErrors( + errors: ReadonlyArray, + ): ReadonlyArray { + return errors.map((error) => { + const path: ReadonlyArray = error.path; + + let match = true; + let index = 0; + while (index < this.path.length) { + if (path[index] !== this.path[index]) { + match = false; + break; + } + index++; + } + + const newPath = match + ? path + .slice(0, index) + .concat(this.errorPathTransformer(path.slice(index))) + : path; + + return new GraphQLError( + error.message, + error.nodes, + error.source, + error.positions, + newPath, + error.originalError, + error.extensions, + ); + }); + } +} diff --git a/src/wrap/transforms/TransformRootFields.ts b/src/wrap/transforms/TransformRootFields.ts new file mode 100644 index 00000000000..d2a14908a30 --- /dev/null +++ b/src/wrap/transforms/TransformRootFields.ts @@ -0,0 +1,50 @@ +import { GraphQLSchema, GraphQLField, GraphQLFieldConfig } from 'graphql'; + +import { Transform, Request, FieldNodeTransformer } from '../../Interfaces'; + +import TransformObjectFields from './TransformObjectFields'; + +export type RootTransformer = ( + operation: 'Query' | 'Mutation' | 'Subscription', + fieldName: string, + field: GraphQLField, +) => GraphQLFieldConfig | RenamedField | null | undefined; + +type RenamedField = { name: string; field?: GraphQLFieldConfig }; + +export default class TransformRootFields implements Transform { + private readonly transformer: TransformObjectFields; + + constructor( + rootFieldTransformer: RootTransformer, + fieldNodeTransformer?: FieldNodeTransformer, + ) { + const rootToObjectFieldTransformer = ( + typeName: string, + fieldName: string, + field: GraphQLField, + ) => { + if ( + typeName === 'Query' || + typeName === 'Mutation' || + typeName === 'Subscription' + ) { + return rootFieldTransformer(typeName, fieldName, field); + } + + return undefined; + }; + this.transformer = new TransformObjectFields( + rootToObjectFieldTransformer, + fieldNodeTransformer, + ); + } + + public transformSchema(originalSchema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(originalSchema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/wrap/transforms/WrapFields.ts b/src/wrap/transforms/WrapFields.ts new file mode 100644 index 00000000000..8da0f32744b --- /dev/null +++ b/src/wrap/transforms/WrapFields.ts @@ -0,0 +1,94 @@ +import { GraphQLSchema, GraphQLObjectType } from 'graphql'; + +import { Transform, Request } from '../../Interfaces'; +import { + hoistFieldNodes, + healSchema, + appendFields, + removeFields, +} from '../../utils/index'; +import { + defaultMergedResolver, + createMergedResolver, +} from '../../stitch/index'; + +import MapFields from './MapFields'; + +export default class WrapFields implements Transform { + private readonly outerTypeName: string; + private readonly wrappingFieldNames: Array; + private readonly wrappingTypeNames: Array; + private readonly numWraps: number; + private readonly fieldNames: Array; + private readonly transformer: Transform; + + constructor( + outerTypeName: string, + wrappingFieldNames: Array, + wrappingTypeNames: Array, + fieldNames?: Array, + ) { + this.outerTypeName = outerTypeName; + this.wrappingFieldNames = wrappingFieldNames; + this.wrappingTypeNames = wrappingTypeNames; + this.numWraps = wrappingFieldNames.length; + this.fieldNames = fieldNames; + + const remainingWrappingFieldNames = this.wrappingFieldNames.slice(); + const outerMostWrappingFieldName = remainingWrappingFieldNames.shift(); + this.transformer = new MapFields({ + [outerTypeName]: { + [outerMostWrappingFieldName]: (fieldNode, fragments) => + hoistFieldNodes({ + fieldNode, + path: remainingWrappingFieldNames, + fieldNames: this.fieldNames, + fragments, + }), + }, + }); + } + + public transformSchema(schema: GraphQLSchema): GraphQLSchema { + const typeMap = schema.getTypeMap(); + + const targetFields = removeFields( + typeMap, + this.outerTypeName, + !this.fieldNames + ? () => true + : (fieldName) => this.fieldNames.includes(fieldName), + ); + + let wrapIndex = this.numWraps - 1; + + const innerMostWrappingTypeName = this.wrappingTypeNames[wrapIndex]; + appendFields(typeMap, innerMostWrappingTypeName, targetFields); + + for (wrapIndex--; wrapIndex > -1; wrapIndex--) { + appendFields(typeMap, this.wrappingTypeNames[wrapIndex], { + [this.wrappingFieldNames[wrapIndex + 1]]: { + type: typeMap[ + this.wrappingTypeNames[wrapIndex + 1] + ] as GraphQLObjectType, + resolve: defaultMergedResolver, + }, + }); + } + + appendFields(typeMap, this.outerTypeName, { + [this.wrappingFieldNames[0]]: { + type: typeMap[this.wrappingTypeNames[0]] as GraphQLObjectType, + resolve: createMergedResolver({ dehoist: true }), + }, + }); + + healSchema(schema); + + return this.transformer.transformSchema(schema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/transforms/WrapQuery.ts b/src/wrap/transforms/WrapQuery.ts similarity index 65% rename from src/transforms/WrapQuery.ts rename to src/wrap/transforms/WrapQuery.ts index 7878e7cf9f8..8ddacfa4143 100644 --- a/src/transforms/WrapQuery.ts +++ b/src/wrap/transforms/WrapQuery.ts @@ -1,14 +1,27 @@ -import { FieldNode, visit, Kind, SelectionNode, SelectionSetNode } from 'graphql'; -import { Transform, Request, Result } from '../Interfaces'; +import { + FieldNode, + visit, + Kind, + SelectionNode, + SelectionSetNode, +} from 'graphql'; -export type QueryWrapper = (subtree: SelectionSetNode) => SelectionNode | SelectionSetNode; +import { Transform, Request, Result } from '../../Interfaces'; + +export type QueryWrapper = ( + subtree: SelectionSetNode, +) => SelectionNode | SelectionSetNode; export default class WrapQuery implements Transform { - private wrapper: QueryWrapper; - private extractor: (result: any) => any; - private path: Array; + private readonly wrapper: QueryWrapper; + private readonly extractor: (result: any) => any; + private readonly path: Array; - constructor(path: Array, wrapper: QueryWrapper, extractor: (result: any) => any) { + constructor( + path: Array, + wrapper: QueryWrapper, + extractor: (result: any) => any, + ) { this.path = path; this.wrapper = wrapper; this.extractor = extractor; @@ -28,33 +41,33 @@ export default class WrapQuery implements Transform { // Selection can be either a single selection or a selection set. If it's just one selection, // let's wrap it in a selection set. Otherwise, keep it as is. const selectionSet = - wrapResult.kind === Kind.SELECTION_SET + wrapResult != null && wrapResult.kind === Kind.SELECTION_SET ? wrapResult : { kind: Kind.SELECTION_SET, - selections: [wrapResult] + selections: [wrapResult], }; return { ...node, - selectionSet + selectionSet, }; } }, - leave: (node: FieldNode) => { + leave: () => { fieldPath.pop(); - } - } + }, + }, }); return { ...originalRequest, - document: newDocument + document: newDocument, }; } public transformResult(originalResult: Result): Result { const rootData = originalResult.data; - if (rootData) { + if (rootData != null) { let data = rootData; const path = [...this.path]; while (path.length > 1) { @@ -68,7 +81,7 @@ export default class WrapQuery implements Transform { return { data: rootData, - errors: originalResult.errors + errors: originalResult.errors, }; } } diff --git a/src/wrap/transforms/WrapType.ts b/src/wrap/transforms/WrapType.ts new file mode 100644 index 00000000000..ebd7c9f0430 --- /dev/null +++ b/src/wrap/transforms/WrapType.ts @@ -0,0 +1,26 @@ +import { GraphQLSchema } from 'graphql'; + +import { Transform, Request } from '../../Interfaces'; + +import WrapFields from './WrapFields'; + +export default class WrapType implements Transform { + private readonly transformer: Transform; + + constructor(outerTypeName: string, innerTypeName: string, fieldName: string) { + this.transformer = new WrapFields( + outerTypeName, + [fieldName], + [innerTypeName], + undefined, + ); + } + + public transformSchema(schema: GraphQLSchema): GraphQLSchema { + return this.transformer.transformSchema(schema); + } + + public transformRequest(originalRequest: Request): Request { + return this.transformer.transformRequest(originalRequest); + } +} diff --git a/src/wrap/transforms/index.ts b/src/wrap/transforms/index.ts new file mode 100644 index 00000000000..68934b3d614 --- /dev/null +++ b/src/wrap/transforms/index.ts @@ -0,0 +1,36 @@ +export { default as CheckResultAndHandleErrors } from './CheckResultAndHandleErrors'; +export { default as ExpandAbstractTypes } from './ExpandAbstractTypes'; +export { default as AddReplacementSelectionSets } from './AddReplacementSelectionSets'; +export { default as AddMergedTypeSelectionSets } from './AddMergedTypeSelectionSets'; +export { default as AddArgumentsAsVariables } from './AddArgumentsAsVariables'; +export { default as FilterToSchema } from './FilterToSchema'; +export { default as AddTypenameToAbstract } from './AddTypenameToAbstract'; + +export { default as RenameTypes } from './RenameTypes'; +export { default as FilterTypes } from './FilterTypes'; +export { default as RenameRootTypes } from './RenameRootTypes'; +export { default as TransformCompositeFields } from './TransformCompositeFields'; +export { default as TransformRootFields } from './TransformRootFields'; +export { default as RenameRootFields } from './RenameRootFields'; +export { default as FilterRootFields } from './FilterRootFields'; +export { default as TransformObjectFields } from './TransformObjectFields'; +export { default as RenameObjectFields } from './RenameObjectFields'; +export { default as FilterObjectFields } from './FilterObjectFields'; +export { default as TransformInterfaceFields } from './TransformInterfaceFields'; +export { default as RenameInterfaceFields } from './RenameInterfaceFields'; +export { default as FilterInterfaceFields } from './FilterInterfaceFields'; +export { default as TransformQuery } from './TransformQuery'; + +export { default as ExtendSchema } from './ExtendSchema'; +export { default as WrapType } from './WrapType'; +export { default as WrapFields } from './WrapFields'; +export { default as HoistField } from './HoistField'; +export { default as MapFields } from './MapFields'; + +// superseded by AddReplacementFragments +export { default as ReplaceFieldWithFragment } from './ReplaceFieldWithFragment'; +// superseded by AddReplacementSelectionSets +export { default as AddReplacementFragments } from './AddReplacementFragments'; +// superseded by TransformQuery +export { default as WrapQuery } from './WrapQuery'; +export { default as ExtractField } from './ExtractField'; diff --git a/src/wrap/wrapSchema.ts b/src/wrap/wrapSchema.ts new file mode 100644 index 00000000000..a60bb19c0b6 --- /dev/null +++ b/src/wrap/wrapSchema.ts @@ -0,0 +1,40 @@ +import { GraphQLSchema } from 'graphql'; + +import { addResolversToSchema } from '../generate/index'; +import { Transform, SubschemaConfig, isSubschemaConfig } from '../Interfaces'; +import { cloneSchema } from '../utils/index'; + +import { generateProxyingResolvers, stripResolvers } from './resolvers'; +import { applySchemaTransforms } from './transforms'; + +export function wrapSchema( + subschemaOrSubschemaConfig: GraphQLSchema | SubschemaConfig, + transforms?: Array, +): GraphQLSchema { + const subschemaConfig: SubschemaConfig = isSubschemaConfig( + subschemaOrSubschemaConfig, + ) + ? subschemaOrSubschemaConfig + : { schema: subschemaOrSubschemaConfig }; + + const schema = cloneSchema(subschemaConfig.schema); + stripResolvers(schema); + + addResolversToSchema({ + schema, + resolvers: generateProxyingResolvers({ subschemaConfig, transforms }), + resolverValidationOptions: { + allowResolversNotInSchema: true, + }, + }); + + let schemaTransforms: Array = []; + if (subschemaConfig.transforms != null) { + schemaTransforms = schemaTransforms.concat(subschemaConfig.transforms); + } + if (transforms != null) { + schemaTransforms = schemaTransforms.concat(transforms); + } + + return applySchemaTransforms(schema, schemaTransforms); +} diff --git a/tsconfig.json b/tsconfig.json index 767de85424b..5ac842d3f2b 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,27 @@ { "compilerOptions": { - "experimentalDecorators": true, - "lib": ["es7", "dom", "esnext.asynciterable"], - "module": "commonjs", + "rootDir": "./src", + "outDir": "./dist", + + "lib": ["es7", "esnext.asynciterable", "dom"], + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "importHelpers": true, + "noImplicitAny": true, + "noImplicitUseStrict": true, "suppressImplicitAnyIndexErrors": true, - "moduleResolution": "node", - "emitDecoratorMetadata": true, "noUnusedLocals": true, + + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "sourceMap": true, "declaration": true, - "rootDir": "./src", - "outDir": "./dist", - "removeComments": false, - "noImplicitUseStrict": true, + "removeComments": false }, "exclude": ["node_modules", "dist"] } diff --git a/tslint.json b/tslint.json deleted file mode 100755 index 727d23d408e..00000000000 --- a/tslint.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "rules": { - "align": [ - false, - "parameters", - "arguments", - "statements" - ], - "ban": false, - "class-name": true, - "curly": true, - "eofline": true, - "forin": true, - "indent": [ - true, - "spaces" - ], - "interface-name": false, - "jsdoc-format": true, - "label-position": true, - "max-line-length": [ - true, - 140 - ], - "member-access": true, - "member-ordering": [ - true, - "public-before-private", - "static-before-instance", - "variables-before-functions" - ], - "no-any": false, - "no-arg": true, - "no-bitwise": true, - "no-conditional-assignment": true, - "no-consecutive-blank-lines": false, - "no-console": [ - true, - "log", - "debug", - "info", - "time", - "timeEnd", - "trace" - ], - "no-construct": true, - "no-parameter-properties": true, - "no-debugger": true, - "no-duplicate-variable": true, - "no-empty": true, - "no-eval": true, - "no-inferrable-types": false, - "no-internal-module": true, - "no-null-keyword": false, - "no-require-imports": false, - "no-shadowed-variable": true, - "no-switch-case-fall-through": true, - "no-trailing-whitespace": true, - "no-unused-expression": true, - "no-use-before-declare": false, - "no-var-keyword": true, - "object-literal-sort-keys": false, - "one-line": [ - true, - "check-open-brace", - "check-catch", - "check-else", - "check-finally", - "check-whitespace" - ], - "quotemark": [ - true, - "single", - "avoid-escape" - ], - "radix": true, - "semicolon": [ - true, - "always" - ], - "switch-default": true, - "trailing-comma": [ - false - ], - "triple-equals": [ - true, - "allow-null-check" - ], - "typedef": [ - false, - "call-signature", - "parameter", - "arrow-parameter", - "property-declaration", - "variable-declaration", - "member-variable-declaration" - ], - "typedef-whitespace": [ - true, - { - "call-signature": "nospace", - "index-signature": "nospace", - "parameter": "nospace", - "property-declaration": "nospace", - "variable-declaration": "nospace" - }, - { - "call-signature": "space", - "index-signature": "space", - "parameter": "space", - "property-declaration": "space", - "variable-declaration": "space" - } - ], - "variable-name": [ - true, - "check-format", - "allow-leading-underscore", - "ban-keywords", - "allow-pascal-case" - ], - "whitespace": [ - true, - "check-branch", - "check-decl", - "check-operator", - "check-separator", - "check-type" - ] - } -} diff --git a/typings.d.ts b/typings.d.ts index e69de29bb2d..e2b0b1d1555 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -0,0 +1 @@ +declare module 'extract-files'