├── .npmignore ├── .prettierignore ├── .gitattributes ├── src ├── index.ts ├── generateDirectivesFile.ts ├── RelayNoUnusedArguments.ts ├── RelayCompatMissingConnectionDirective.ts ├── RelayKnownVariableNames.ts ├── RelayArgumentsOfCorrectType.ts ├── utils.ts ├── dependencies.ts ├── RelayDefaultValueOfCorrectType.ts ├── generateConfig.ts ├── RelayVariablesInAllowedPosition.ts ├── RelayCompatRequiredPageInfoFields.ts ├── argumentDefinitions.ts └── RelayKnownArgumentNames.ts ├── .gitignore ├── .editorconfig ├── tsconfig.build.json ├── jest.config.js ├── .travis.yml ├── tsconfig.json ├── tslint.json ├── LICENSE ├── tests ├── generateDirectivesFile-test.ts ├── RelayNoUnusedArguments-test.ts ├── generateConfig-test.ts ├── RelayCompatMissingConnectionDirective-test.ts ├── RelayDefaultValueOfCorrectType-test.ts ├── RelayArgumentsOfCorrectType-test.ts ├── RelayKnownVariableNames-test.ts ├── RelayVariablesInAllowedPosition-test.ts ├── RelayKnownArgumentNames-test.ts └── RelayCompatRequiredPageInfoFields-test.ts ├── package.json ├── README.md └── CHANGELOG.md /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.png binary 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { generateConfig } from "./generateConfig" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | dist/ 3 | node_modules/ 4 | .vscode/ 5 | *.tgz 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | max_line_length = 120 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/typings/*.d.ts", "src/**/*.ts"], 4 | "compilerOptions": { 5 | "rootDir": "src", 6 | "outDir": "dist" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | moduleFileExtensions: ["ts", "js", "json"], 4 | testMatch: ["**/tests/**/*.ts", "**/*.test.ts"], 5 | transform: { "\\.ts$": "ts-jest" }, 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 10 5 | - stable 6 | 7 | script: 8 | - yarn run lint 9 | - yarn run test 10 | 11 | cache: 12 | yarn: true 13 | directories: 14 | - node_modules 15 | 16 | matrix: 17 | fast_finish: true 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "lib": ["es6", "dom"], 5 | "module": "commonjs", 6 | "declaration": true, 7 | "importHelpers": false, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true 10 | }, 11 | "include": ["src/typings/*.d.ts", "src/**/*.ts", "tests/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": ["tslint-plugin-prettier"], 3 | "extends": ["tslint:recommended"], 4 | "rules": { 5 | "prettier": true, 6 | "arrow-parens": false, 7 | "interface-name": [true, "never-prefix"], 8 | "max-classes-per-file": false, 9 | "member-access": [false, "check-accessor", "check-constructor"], 10 | "no-console": [true, ["error", ["warn", "error"]]], 11 | "object-literal-sort-keys": false, 12 | "ordered-imports": true, 13 | "switch-default": false, 14 | "variable-name": [true, "allow-pascal-case", "allow-leading-underscore"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/generateDirectivesFile.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import * as os from "os" 3 | import * as path from "path" 4 | import { IRTransforms } from "relay-compiler" 5 | 6 | export function directivesFilename() { 7 | const { version } = require("relay-compiler/package.json") 8 | return path.join(os.tmpdir(), `relay-compiler-directives-v${version}.graphql`) 9 | } 10 | 11 | export function generateDirectivesFile() { 12 | const tempfile = directivesFilename() 13 | const extensions = [ 14 | ...IRTransforms.schemaExtensions, 15 | "directive @arguments on FRAGMENT_SPREAD", 16 | "directive @argumentDefinitions on FRAGMENT_DEFINITION", 17 | ] 18 | fs.writeFileSync(tempfile, extensions.join("\n")) 19 | return tempfile 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Eloy Durán (https://github.com/alloy) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/RelayNoUnusedArguments.ts: -------------------------------------------------------------------------------- 1 | import { ValidationRule } from "graphql" 2 | import { getFragmentArgumentDefinitions } from "./argumentDefinitions" 3 | import { GraphQLError, visit } from "./dependencies" 4 | 5 | export function unusedArgumentMessage(varName: string, framgnetName: string): string { 6 | return `Argument "${varName}" in fragment "${framgnetName}" is never used.` 7 | } 8 | 9 | // tslint:disable-next-line: no-shadowed-variable 10 | export const RelayNoUnusedArguments: ValidationRule = function RelayNoUnusedArguments(context) { 11 | return { 12 | FragmentDefinition(fragmentDef) { 13 | const argumentDefinitions = getFragmentArgumentDefinitions(context, fragmentDef) 14 | const usages: { [name: string]: number } = {} 15 | 16 | visit(fragmentDef.selectionSet, { 17 | Variable(variableNode) { 18 | usages[variableNode.name.value] = 1 19 | }, 20 | }) 21 | 22 | Object.keys(argumentDefinitions).forEach((arg) => { 23 | const definition = argumentDefinitions[arg] 24 | 25 | if (!usages[arg]) { 26 | context.reportError(new GraphQLError(unusedArgumentMessage(arg, fragmentDef.name.value), definition.node)) 27 | } 28 | }) 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/RelayCompatMissingConnectionDirective.ts: -------------------------------------------------------------------------------- 1 | import { FieldNode, ValidationRule } from "graphql" 2 | import { GraphQLError } from "./dependencies" 3 | import { getConnectionDirective, isConnectionType } from "./utils" 4 | 5 | function hasAfterArgument(fieldNode: FieldNode): boolean { 6 | return !!(fieldNode.arguments && fieldNode.arguments.find((arg) => arg.name.value === "after")) 7 | } 8 | 9 | function hasBeforeArgument(fieldNode: FieldNode): boolean { 10 | return !!(fieldNode.arguments && fieldNode.arguments.find((arg) => arg.name.value === "before")) 11 | } 12 | 13 | // tslint:disable-next-line: no-shadowed-variable 14 | export const RelayCompatMissingConnectionDirective: ValidationRule = function RelayCompatMissingConnectionDirective( 15 | context 16 | ) { 17 | return { 18 | Field: { 19 | enter(fieldNode) { 20 | if (!fieldNode.selectionSet) { 21 | return 22 | } 23 | const type = context.getType() 24 | if (!type || !isConnectionType(type)) { 25 | return 26 | } 27 | const connectionDirective = getConnectionDirective(fieldNode) 28 | if (!connectionDirective && (hasAfterArgument(fieldNode) || hasBeforeArgument(fieldNode))) { 29 | context.reportError(new GraphQLError("Missing @connection directive", fieldNode)) 30 | } 31 | }, 32 | }, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/generateDirectivesFile-test.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, unlinkSync } from "fs" 2 | import { parse, visit } from "graphql" 3 | import { directivesFilename, generateDirectivesFile } from "../src/generateDirectivesFile" 4 | 5 | describe(generateDirectivesFile, () => { 6 | const tempfile = directivesFilename() 7 | 8 | beforeAll(() => { 9 | if (existsSync(tempfile)) { 10 | unlinkSync(tempfile) 11 | } 12 | }) 13 | 14 | it("generates the file", () => { 15 | expect(generateDirectivesFile()).toEqual(tempfile) 16 | expect(existsSync(tempfile)).toBe(true) 17 | }) 18 | 19 | it("includes relay-compiler's directives", () => { 20 | const document = parse(readFileSync(tempfile, "utf8")) 21 | const directives: string[] = [] 22 | visit(document, { 23 | DirectiveDefinition(node) { 24 | directives.push(node.name.value) 25 | return false 26 | }, 27 | }) 28 | expect(directives.sort()).toMatchInlineSnapshot(` 29 | Array [ 30 | "DEPRECATED__relay_ignore_unused_variables_error", 31 | "appendEdge", 32 | "argumentDefinitions", 33 | "arguments", 34 | "connection", 35 | "deleteRecord", 36 | "inline", 37 | "match", 38 | "module", 39 | "prependEdge", 40 | "raw_response_type", 41 | "refetchable", 42 | "relay", 43 | "relay_test_operation", 44 | "stream_connection", 45 | ] 46 | `) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/RelayKnownVariableNames.ts: -------------------------------------------------------------------------------- 1 | import { ValidationRule } from "graphql" 2 | import { getRecursiveVariableUsagesWithRelayInfo } from "./argumentDefinitions" 3 | import { GraphQLError } from "./dependencies" 4 | 5 | export function undefinedVarMessage( 6 | varName: string, 7 | opName: string | undefined, 8 | usingFragmentName: string | null 9 | ): string { 10 | opName = !opName ? "unnamed operation" : opName 11 | return usingFragmentName 12 | ? `Variable "$${varName}" is used by fragment "${usingFragmentName}", but not defined by operation "${opName}".` 13 | : `Variable "$${varName}" is not defined by operation "${opName}".` 14 | } 15 | 16 | // tslint:disable-next-line: no-shadowed-variable 17 | export const RelayKnownVariableNames: ValidationRule = function RelayKnownVariableNames(context) { 18 | return { 19 | OperationDefinition(opDef) { 20 | const usages = getRecursiveVariableUsagesWithRelayInfo(context, opDef) 21 | 22 | const errors = Object.create(null) 23 | 24 | usages.forEach((usage) => { 25 | const varName = usage.node.name.value 26 | if (!usage.variableDefinition) { 27 | const location = [...(!usage.usingFragmentName ? [usage.node] : []), opDef] 28 | const errorStr = undefinedVarMessage(varName, opDef.name && opDef.name.value, usage.usingFragmentName) 29 | if (!errors[errorStr]) { 30 | if (usage.usingFragmentName) { 31 | errors[errorStr] = true 32 | } 33 | context.reportError(new GraphQLError(errorStr, location)) 34 | } 35 | } 36 | }) 37 | }, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vscode-apollo-relay", 3 | "version": "1.5.2", 4 | "description": "Simple configuration of vscode-apollo for Relay projects.", 5 | "keywords": [ 6 | "apollo", 7 | "graphql", 8 | "relay", 9 | "vscode" 10 | ], 11 | "main": "dist/index.js", 12 | "types": "dist/index.d.ts", 13 | "repository": "https://github.com/relay-tools/vscode-apollo-relay", 14 | "homepage": "https://github.com/relay-tools/vscode-apollo-relay#readme", 15 | "author": { 16 | "name": "Eloy Durán", 17 | "email": "eloy.de.enige@gmail.com", 18 | "url": "https://github.com/alloy" 19 | }, 20 | "license": "MIT", 21 | "scripts": { 22 | "prepublish": "yarn run build", 23 | "lint": "tslint -p ./tsconfig.json", 24 | "type-check": "tsc -p ./tsconfig.json --noEmit --pretty", 25 | "test": "jest", 26 | "prebuild": "rm -rf ./dist", 27 | "build": "tsc -p ./tsconfig.build.json", 28 | "release": "standard-version" 29 | }, 30 | "dependencies": {}, 31 | "devDependencies": { 32 | "@types/jest": "^26.0.14", 33 | "@types/node": "^12.7.4", 34 | "@types/relay-compiler": "^8.0.0", 35 | "@types/relay-config": "^6.0.0", 36 | "apollo-language-server": "^1.23.4", 37 | "graphql": "^15.3.0", 38 | "jest": "26.4.2", 39 | "prettier": "^2.1.2", 40 | "relay-compiler": "^10.0.1", 41 | "relay-compiler-language-typescript": "^13.0.1", 42 | "relay-config": "^10.0.1", 43 | "standard-version": "^9.0.0", 44 | "ts-jest": "26.3.0", 45 | "tslint": "^6.1.3", 46 | "tslint-plugin-prettier": "^2.3.0", 47 | "typescript": "^4.0.3" 48 | }, 49 | "peerDependencies": { 50 | "relay-compiler": ">=5.0.0", 51 | "relay-config": ">=5.0.0" 52 | }, 53 | "engines": { 54 | "node": ">= 8" 55 | }, 56 | "files": [ 57 | "/dist/**/*" 58 | ], 59 | "prettier": { 60 | "semi": false, 61 | "singleQuote": false, 62 | "trailingComma": "es5", 63 | "bracketSpacing": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/RelayArgumentsOfCorrectType.ts: -------------------------------------------------------------------------------- 1 | import { ValidationRule } from "graphql" 2 | import { getFragmentArgumentDefinitions } from "./argumentDefinitions" 3 | import { GraphQLError, valueFromAST } from "./dependencies" 4 | import { containsVariableNodes, findFragmentSpreadParent } from "./utils" 5 | 6 | // tslint:disable-next-line: no-shadowed-variable 7 | export const RelayArgumentsOfCorrectType: ValidationRule = function RelayArgumentsOfCorrectType(context) { 8 | return { 9 | Directive(directive, _key, _parent, _path, ancestors) { 10 | if (directive.name.value !== "arguments" || !directive.arguments) { 11 | return false 12 | } 13 | const fragmentSpread = findFragmentSpreadParent(ancestors) 14 | if (!fragmentSpread) { 15 | return false 16 | } 17 | const fragmentDefinition = context.getFragment(fragmentSpread.name.value) 18 | if (!fragmentDefinition) { 19 | return false 20 | } 21 | 22 | const typedArguments = getFragmentArgumentDefinitions(context, fragmentDefinition) 23 | 24 | directive.arguments.forEach((arg) => { 25 | const argDef = typedArguments[arg.name.value] 26 | if (!argDef || !argDef.schemaType) { 27 | return 28 | } 29 | 30 | const schemaType = argDef.schemaType 31 | 32 | if (containsVariableNodes(arg.value)) { 33 | return 34 | } 35 | 36 | const value = valueFromAST(arg.value, schemaType) 37 | 38 | if (value === undefined) { 39 | context.reportError( 40 | new GraphQLError( 41 | badValueMessage(fragmentDefinition.name.value, arg.name.value, schemaType.toString()), 42 | arg.value 43 | ) 44 | ) 45 | } 46 | }) 47 | }, 48 | } 49 | } 50 | 51 | function badValueMessage(fragmentName: string, argName: string, argType: string): string { 52 | return `Argument "${argName}" for fragment "${fragmentName}" is expected to be of type "${argType}".` 53 | } 54 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ASTNode, 3 | DirectiveNode, 4 | FieldNode, 5 | FragmentDefinitionNode, 6 | FragmentSpreadNode, 7 | GraphQLOutputType, 8 | GraphQLType, 9 | } from "graphql" 10 | import { BREAK, getNullableType, GraphQLNonNull, GraphQLObjectType, visit } from "./dependencies" 11 | 12 | export function findFragmentSpreadParent(nodes: readonly any[]): FragmentSpreadNode | undefined { 13 | return nodes.find(isFragmentSpread) 14 | } 15 | 16 | export function findFragmentDefinitionParent(nodes: readonly any[]): FragmentDefinitionNode | undefined { 17 | return nodes.find(isFragmentDefinition) 18 | } 19 | 20 | export function isFragmentSpread(node: any): node is FragmentSpreadNode { 21 | return node != null && node.kind === "FragmentSpread" 22 | } 23 | 24 | export function isFragmentDefinition(node: any): node is FragmentDefinitionNode { 25 | return node != null && node.kind === "FragmentDefinition" 26 | } 27 | 28 | export function containsVariableNodes(node: ASTNode): boolean { 29 | let hasVars = false 30 | 31 | visit(node, { 32 | Variable() { 33 | hasVars = true 34 | return BREAK 35 | }, 36 | }) 37 | return hasVars 38 | } 39 | 40 | export function makeNonNullable(type: GraphQLType): GraphQLType { 41 | if (!(type instanceof GraphQLNonNull)) { 42 | return new GraphQLNonNull(type) 43 | } 44 | return type 45 | } 46 | 47 | export function getConnectionDirective(fieldNode: FieldNode): { key: string | null; directive: DirectiveNode } | null { 48 | const directive = fieldNode.directives && fieldNode.directives.find((d) => d.name.value === "connection") 49 | 50 | if (!directive) { 51 | return null 52 | } 53 | 54 | const keyArgument = directive.arguments && directive.arguments.find((arg) => arg.name.value === "key") 55 | if (!keyArgument || keyArgument.value.kind !== "StringValue") { 56 | return { 57 | key: null, 58 | directive, 59 | } 60 | } 61 | 62 | return { 63 | key: keyArgument.value.value, 64 | directive, 65 | } 66 | } 67 | 68 | export function isConnectionType(type: GraphQLOutputType): boolean { 69 | const nullableType = getNullableType(type) 70 | 71 | if (!(nullableType instanceof GraphQLObjectType)) { 72 | return false 73 | } 74 | 75 | return nullableType.name.endsWith("Connection") 76 | } 77 | -------------------------------------------------------------------------------- /src/dependencies.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ok, so in this module we find an ancestral module that resides inside vscode extension, as we need to require 3 | * dependencies used by the vscode extension from the same dependency tree in order to not lead to conflicts where 4 | * e.g. graphql-js parts come from different modules (i.e. the user's node_modules). 5 | */ 6 | 7 | import * as _ApolloValidation from "apollo-language-server/lib/errors/validation" 8 | import * as _GraphQL from "graphql" 9 | import * as _RelayCompilerMain from "relay-compiler/lib/bin/RelayCompilerMain" 10 | import * as _RelayConfig from "relay-config" 11 | 12 | const mod = require.main || module 13 | const isJest = typeof jest !== "undefined" 14 | 15 | export const { defaultValidationRules } = mod.require( 16 | isJest ? "apollo-language-server/lib/errors/validation" : "./errors/validation" 17 | ) as typeof _ApolloValidation 18 | 19 | export const { 20 | BREAK, 21 | GraphQLError, 22 | parseType, 23 | visit, 24 | isNonNullType, 25 | valueFromAST, 26 | isTypeSubTypeOf, 27 | getNullableType, 28 | typeFromAST, 29 | GraphQLNonNull, 30 | GraphQLObjectType, 31 | visitWithTypeInfo, 32 | isInputType, 33 | TypeInfo, 34 | } = mod.require("graphql") as typeof _GraphQL 35 | 36 | export const didYouMean = mod.require("graphql/jsutils/didYouMean").default as (suggestions: string[]) => string 37 | 38 | export const suggestionList = mod.require("graphql/jsutils/suggestionList").default as ( 39 | input: string, 40 | options: string[] 41 | ) => string[] 42 | 43 | let relayConfigMod: typeof _RelayConfig | null = null 44 | try { 45 | // tslint:disable-next-line: no-var-requires 46 | relayConfigMod = require("relay-config") 47 | } catch { 48 | // ignore 49 | } 50 | export const RelayConfig = relayConfigMod 51 | 52 | let relayCompilerMainMod: typeof _RelayCompilerMain | null = null 53 | try { 54 | // relay-compiler v6 55 | // tslint:disable-next-line: no-var-requires 56 | relayCompilerMainMod = require("relay-compiler/lib/bin/RelayCompilerMain") 57 | } catch { 58 | try { 59 | // relay-compiler v5 60 | // tslint:disable-next-line: no-var-requires 61 | relayCompilerMainMod = require("relay-compiler/lib/RelayCompilerMain") 62 | } catch { 63 | // ignore 64 | } 65 | } 66 | export const RelayCompilerMain = relayCompilerMainMod 67 | -------------------------------------------------------------------------------- /tests/RelayNoUnusedArguments-test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs" 2 | import { buildSchema, parse, validate } from "graphql" 3 | import { generateDirectivesFile } from "../src/generateDirectivesFile" 4 | import { RelayNoUnusedArguments } from "../src/RelayNoUnusedArguments" 5 | 6 | const schema = buildSchema(` 7 | ${readFileSync(generateDirectivesFile(), "utf8")} 8 | 9 | type Foo { 10 | bar: String 11 | baz: Boolean 12 | } 13 | 14 | type Query { 15 | foo: Foo 16 | node(id: ID!): Foo 17 | conditionalNode(id: ID!, condition: Boolean!): Foo 18 | } 19 | `) 20 | 21 | function validateDocuments(source: string) { 22 | return validate(schema, parse(source), [RelayNoUnusedArguments]) 23 | } 24 | 25 | describe(RelayNoUnusedArguments, () => { 26 | it("Allows fragments without arguments", () => { 27 | const errors = validateDocuments( 28 | ` 29 | fragment MyFragment on Query { 30 | foo { 31 | bar 32 | } 33 | }` 34 | ) 35 | expect(errors).toHaveLength(0) 36 | }) 37 | 38 | it("Allows fragments that uses its arguments arguments", () => { 39 | const errors = validateDocuments( 40 | ` 41 | fragment MyFragment on Query @argumentDefinitions(arg1: { type: "Boolean!" }) { 42 | foo @include(if: $arg1) { 43 | bar 44 | } 45 | }` 46 | ) 47 | expect(errors).toHaveLength(0) 48 | }) 49 | 50 | it("Disallows fragments that does not use its arguments arguments", () => { 51 | const errors = validateDocuments( 52 | ` 53 | fragment MyFragment on Query @argumentDefinitions(arg1: { type: "Boolean!" }) { 54 | foo { 55 | bar 56 | } 57 | }` 58 | ) 59 | expect(errors).toHaveLength(1) 60 | expect(errors).toContainEqual( 61 | expect.objectContaining({ message: 'Argument "arg1" in fragment "MyFragment" is never used.' }) 62 | ) 63 | }) 64 | 65 | it("Disallows one of two arguments in a fragment that is not used", () => { 66 | const errors = validateDocuments( 67 | ` 68 | fragment MyFragment on Query @argumentDefinitions(arg1: { type: "Boolean!" }, arg2: { type: "Boolean!" }) { 69 | foo @include(if: $arg2) { 70 | bar 71 | } 72 | }` 73 | ) 74 | expect(errors).toHaveLength(1) 75 | expect(errors).toContainEqual( 76 | expect.objectContaining({ message: 'Argument "arg1" in fragment "MyFragment" is never used.' }) 77 | ) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vscode-apollo-relay 2 | 3 | [![npm](https://img.shields.io/npm/v/vscode-apollo-relay.svg)](https://www.npmjs.com/package/vscode-apollo-relay) 4 | [![build](https://img.shields.io/travis/relay-tools/vscode-apollo-relay/master.svg)](https://travis-ci.org/relay-tools/vscode-apollo-relay/builds) 5 | 6 | Simple configuration of [vscode-apollo] for Relay projects. 7 | 8 | Features: 9 | 10 | - Read all user configuration from [relay-config], if the project is setup with it. 11 | - Provides definitions for all Relay directives for validation and auto-completion purposes. 12 | - Provides validation of `@argumentDefinitions` and `@arguments` directives. 13 | 14 | [Changelog](https://github.com/relay-tools/vscode-apollo-relay/blob/master/CHANGELOG.md) 15 | 16 | ## Install 17 | 18 | ```sh 19 | # using npm 20 | npm install --save vscode-apollo-relay 21 | 22 | # using yarn 23 | yarn add vscode-apollo-relay 24 | ``` 25 | 26 | ## Usage 27 | 28 | In your `apollo.config.js` file: 29 | 30 | ```js 31 | const { config } = require("vscode-apollo-relay").generateConfig() 32 | module.exports = config 33 | ``` 34 | 35 | Or, if you don’t use [relay-config] and the default values don’t work for you: 36 | 37 | ```js 38 | const path = require("path") 39 | const { 40 | config, 41 | directivesFile, 42 | includesGlobPattern 43 | } = require("vscode-apollo-relay").generateConfig() 44 | 45 | module.exports = { 46 | client: { 47 | ...config.client, 48 | service: { 49 | ...config.client.service, 50 | localSchemaFile: "./path/to/schema.graphql", 51 | }, 52 | includes: [ 53 | directivesFile, 54 | path.join("./path/to/source", includesGlobPattern(["js", "jsx"])) 55 | ], 56 | excludes: ["./path/to/exclude"], 57 | } 58 | } 59 | ``` 60 | 61 | ### Compat 62 | 63 | If you are still using the compatibility mode of Relay you can enable additional validation rules that only apply to Relay compat. `generateConfig` takes a `compat` boolean argument to enable these extra validation rules. Ie: 64 | 65 | ```js 66 | const { config } = require("vscode-apollo-relay").generateConfig(/* compat: */ true) 67 | module.exports = config 68 | ``` 69 | 70 | ## Development 71 | 72 | ```sh 73 | # lint 74 | yarn run lint 75 | 76 | # build 77 | yarn run build 78 | 79 | # test 80 | yarn run test 81 | ``` 82 | 83 | ## License 84 | 85 | MIT © [Eloy Durán](https://github.com/alloy) 86 | 87 | [vscode-apollo]: https://marketplace.visualstudio.com/items?itemName=apollographql.vscode-apollo 88 | [relay-config]: https://relay.dev/docs/getting-started/installation-and-setup/#set-up-relay-with-a-single-config-file 89 | -------------------------------------------------------------------------------- /tests/generateConfig-test.ts: -------------------------------------------------------------------------------- 1 | import { LocalServiceConfig } from "apollo-language-server/lib/config" 2 | import { ValidationRule } from "graphql/validation" 3 | import { Config } from "relay-compiler/lib/bin/RelayCompilerMain" 4 | import { generateConfig } from "../src/generateConfig" 5 | 6 | jest.mock("cosmiconfig", () => () => ({ 7 | searchSync: () => ({ 8 | config: { 9 | schema: "path/to/schema.graphql", 10 | src: "path/to/src-root", 11 | exclude: ["path/to/exclude"], 12 | } as Config, 13 | }), 14 | })) 15 | 16 | describe(generateConfig, () => { 17 | xdescribe("when user does not use relay-config", () => { 18 | it("uses a default schema file", () => { 19 | jest.mock("relay-config", () => ({ loadConfig: () => null })) 20 | const config = generateConfig().config.client!.service as LocalServiceConfig 21 | expect(config.localSchemaFile).toEqual("./data/schema.graphql") 22 | }) 23 | 24 | it("uses a default source root", () => { 25 | jest.mock("relay-config", () => ({ loadConfig: () => null })) 26 | const config = generateConfig().config.client! 27 | expect(config.includes).toEqual("./src/**/*.{graphql,js,jsx}") 28 | }) 29 | }) 30 | 31 | it("specifies the schema file", () => { 32 | const config = generateConfig().config.client!.service as LocalServiceConfig 33 | expect(config.localSchemaFile).toEqual("path/to/schema.graphql") 34 | }) 35 | 36 | it("specifies the source files to include", () => { 37 | const config = generateConfig().config.client! 38 | expect(config.includes).toContain("path/to/src-root/**/*.{graphql,js,jsx}") 39 | }) 40 | 41 | it("specifies the source files to exclude", () => { 42 | const config = generateConfig().config.client! 43 | expect(config.excludes).toContain("path/to/exclude") 44 | }) 45 | 46 | it("excludes validation rules that are incompatible with Relay", () => { 47 | const config = generateConfig().config.client! 48 | const rules = config.validationRules as ValidationRule[] 49 | expect(rules.map(({ name }) => name)).not.toContain("NoUndefinedVariablesRule") 50 | }) 51 | 52 | it("includes the RelayUnknownArgumentNames validation rule", () => { 53 | const config = generateConfig().config.client! 54 | const rules = config.validationRules as ValidationRule[] 55 | expect(rules.map(({ name }) => name)).toContain("RelayKnownArgumentNames") 56 | }) 57 | 58 | it("specifies the relay-compiler directives dump to include", () => { 59 | const config = generateConfig().config.client! 60 | expect(config.includes).toContainEqual(expect.stringMatching(/relay-compiler-directives-v\d+\.\d+\.\d+/)) 61 | }) 62 | 63 | it.todo("specifies the source files to include with a different language plugin") 64 | }) 65 | -------------------------------------------------------------------------------- /src/RelayDefaultValueOfCorrectType.ts: -------------------------------------------------------------------------------- 1 | import { ValidationRule } from "graphql" 2 | import { getFragmentArgumentDefinitions } from "./argumentDefinitions" 3 | import { GraphQLError, valueFromAST } from "./dependencies" 4 | import { containsVariableNodes, findFragmentDefinitionParent, makeNonNullable } from "./utils" 5 | 6 | // tslint:disable-next-line: no-shadowed-variable 7 | export const RelayDefaultValueOfCorrectType: ValidationRule = function RelayDefaultValueOfCorrectType(context) { 8 | return { 9 | Directive(directive, _key, _parent, _path, ancestors) { 10 | if (directive.name.value !== "argumentDefinitions" || !directive.arguments) { 11 | return false 12 | } 13 | const fragmentDefinition = findFragmentDefinitionParent(ancestors) 14 | if (!fragmentDefinition) { 15 | return false 16 | } 17 | 18 | const typedArguments = getFragmentArgumentDefinitions(context, fragmentDefinition) 19 | 20 | directive.arguments.forEach((argument) => { 21 | if (argument.value.kind !== "ObjectValue") { 22 | return 23 | } 24 | const defaultValueField = argument.value.fields.find((f) => f.name.value === "defaultValue") 25 | if (!defaultValueField) { 26 | return 27 | } 28 | const arg = typedArguments[argument.name.value] 29 | if (!arg) { 30 | return 31 | } 32 | if (arg.schemaType && arg.defaultValue && !containsVariableNodes(arg.defaultValue)) { 33 | if (arg.defaultValue.kind === "NullValue") { 34 | context.reportError( 35 | new GraphQLError( 36 | cannotSpecifyNullAsDefaultValue(fragmentDefinition.name.value, argument.name.value), 37 | defaultValueField.value 38 | ) 39 | ) 40 | return 41 | } 42 | const value = valueFromAST(arg.defaultValue, arg.schemaType) 43 | 44 | if (value === undefined) { 45 | context.reportError( 46 | new GraphQLError( 47 | badValueMessage( 48 | fragmentDefinition.name.value, 49 | argument.name.value, 50 | makeNonNullable(arg.schemaType).toString() 51 | ), 52 | defaultValueField.value 53 | ) 54 | ) 55 | } 56 | } 57 | }) 58 | }, 59 | } 60 | } 61 | 62 | function badValueMessage(fragmentName: string, argName: string, argType: string): string { 63 | return `defaultValue for argument "${argName}" on fragment "${fragmentName}" is expected to be of type "${argType}".` 64 | } 65 | 66 | function cannotSpecifyNullAsDefaultValue(fragmentName: string, argName: string): string { 67 | return `defaultValue for argument "${argName}" on fragment "${fragmentName}" cannot be null. Instead, omit defaultValue.` 68 | } 69 | -------------------------------------------------------------------------------- /tests/RelayCompatMissingConnectionDirective-test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs" 2 | import { buildSchema, parse, validate } from "graphql" 3 | import { generateDirectivesFile } from "../src/generateDirectivesFile" 4 | import { RelayCompatMissingConnectionDirective } from "../src/RelayCompatMissingConnectionDirective" 5 | 6 | const schema = buildSchema(` 7 | ${readFileSync(generateDirectivesFile(), "utf8")} 8 | 9 | type Foo { 10 | bar: String 11 | baz: Boolean 12 | } 13 | 14 | type FooEdge { 15 | node: Foo 16 | cursor: String! 17 | } 18 | 19 | type FooConnection { 20 | edges: [FooEdge!]! 21 | pageInfo: PageInfo! 22 | } 23 | 24 | type PageInfo { 25 | hasPreviousPage: Boolean! 26 | hasNextPage: Boolean! 27 | endCursor: String 28 | startCursor: String 29 | } 30 | 31 | type Query { 32 | fooConnection(first: Int last: Int after: String before: String): FooConnection 33 | foo: Foo 34 | } 35 | `) 36 | 37 | function validateDocuments(source: string) { 38 | return validate(schema, parse(source), [RelayCompatMissingConnectionDirective]) 39 | } 40 | 41 | describe(RelayCompatMissingConnectionDirective, () => { 42 | it("Should allow connections without @connection directive when no before or after is used", () => { 43 | const errors = validateDocuments(` 44 | query MyQuery { 45 | fooConnection { 46 | __typename 47 | } 48 | } 49 | `) 50 | expect(errors).toHaveLength(0) 51 | }) 52 | it("Should disallow connections without @connection directive when before is used", () => { 53 | const errors = validateDocuments(` 54 | query MyQuery { 55 | fooConnection(before: $before) { 56 | __typename 57 | } 58 | } 59 | `) 60 | expect(errors).toHaveLength(1) 61 | expect(errors).toContainEqual( 62 | expect.objectContaining({ 63 | message: "Missing @connection directive", 64 | }) 65 | ) 66 | }) 67 | it("Should disallow connections without @connection directive when after is used", () => { 68 | const errors = validateDocuments(` 69 | query MyQuery { 70 | fooConnection(after: $after) { 71 | __typename 72 | } 73 | } 74 | `) 75 | expect(errors).toHaveLength(1) 76 | expect(errors).toContainEqual( 77 | expect.objectContaining({ 78 | message: "Missing @connection directive", 79 | }) 80 | ) 81 | }) 82 | it("Should allow connections with @connection directive", () => { 83 | const errors = validateDocuments(` 84 | query MyQuery { 85 | fooConnection(before: $before) @connection(key: "MyQuery_fooConnection") { 86 | __typename 87 | } 88 | } 89 | `) 90 | expect(errors).toHaveLength(0) 91 | }) 92 | 93 | it("Should allow non connectionfields without @connection directive", () => { 94 | const errors = validateDocuments(` 95 | query MyQuery { 96 | foo 97 | } 98 | `) 99 | expect(errors).toHaveLength(0) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.5.2](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.5.1...v1.5.2) (2021-12-01) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * dependency import defaultValidationRules ([e4f5d6e](https://github.com/relay-tools/vscode-apollo-relay/commit/e4f5d6eb00126a39c8d66965c0f144d436f50d11)) 11 | 12 | ### [1.5.1](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.4.4...v1.5.1) (2020-09-19) 13 | 14 | ### [1.4.4](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.4.3...v1.4.4) (2020-09-19) 15 | 16 | ### [1.4.3](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.4.2...v1.4.3) (2019-09-24) 17 | 18 | 19 | ### Bug Fixes 20 | 21 | * Ensure Apollo NoMissingClientDirectives rule is never used ([06b312b](https://github.com/relay-tools/vscode-apollo-relay/commit/06b312b)) 22 | 23 | ### [1.4.2](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.4.1...v1.4.2) (2019-09-18) 24 | 25 | 26 | ### Bug Fixes 27 | 28 | * Actually be compatible with relay v6 ([065ff0a](https://github.com/relay-tools/vscode-apollo-relay/commit/065ff0a)) 29 | 30 | ### [1.4.1](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.4.0...v1.4.1) (2019-09-18) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * Make compatible with relay v6. ([fc16681](https://github.com/relay-tools/vscode-apollo-relay/commit/fc16681)), closes [#17](https://github.com/relay-tools/vscode-apollo-relay/issues/17) 36 | 37 | ## [1.4.0](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.3.1...v1.4.0) (2019-09-17) 38 | 39 | 40 | ### Features 41 | 42 | * Pagination validation ([29c8445](https://github.com/relay-tools/vscode-apollo-relay/commit/29c8445)) 43 | 44 | ### [1.3.1](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.3.0...v1.3.1) (2019-09-12) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * Fix example of manual config. ([1c42df5](https://github.com/relay-tools/vscode-apollo-relay/commit/1c42df5)) 50 | 51 | ## [1.3.0](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.2.0...v1.3.0) (2019-09-11) 52 | 53 | 54 | ### Features 55 | 56 | * Validate default values ([42264c2](https://github.com/relay-tools/vscode-apollo-relay/commit/42264c2)) 57 | 58 | ## [1.2.0](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.1.1...v1.2.0) (2019-09-11) 59 | 60 | 61 | ### Features 62 | 63 | * Validate literal argument values ([9b2276c](https://github.com/relay-tools/vscode-apollo-relay/commit/9b2276c)) 64 | 65 | ### [1.1.1](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.1.0...v1.1.1) (2019-09-10) 66 | 67 | 68 | ### Bug Fixes 69 | 70 | * Don't import isInputType from `graphql` itself ([9d9c2ed](https://github.com/relay-tools/vscode-apollo-relay/commit/9d9c2ed)) 71 | 72 | ## [1.1.0](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.0.2...v1.1.0) (2019-09-10) 73 | 74 | 75 | ### Features 76 | 77 | * Adds variable validation. ([71c2783](https://github.com/relay-tools/vscode-apollo-relay/commit/71c2783)), closes [#7](https://github.com/relay-tools/vscode-apollo-relay/issues/7) 78 | 79 | ### [1.0.2](https://github.com/relay-tools/vscode-apollo-relay/compare/v1.0.1...v1.0.2) (2019-09-09) 80 | 81 | ### 1.0.1 (2019-09-09) 82 | -------------------------------------------------------------------------------- /src/generateConfig.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path" 2 | 3 | import { ApolloConfigFormat } from "apollo-language-server/lib/config" 4 | import { ValidationRule } from "graphql" 5 | import { defaultValidationRules, RelayCompilerMain, RelayConfig } from "./dependencies" 6 | import { generateDirectivesFile } from "./generateDirectivesFile" 7 | import { RelayArgumentsOfCorrectType } from "./RelayArgumentsOfCorrectType" 8 | import { RelayCompatMissingConnectionDirective } from "./RelayCompatMissingConnectionDirective" 9 | import { RelayCompatRequiredPageInfoFields } from "./RelayCompatRequiredPageInfoFields" 10 | import { RelayDefaultValueOfCorrectType } from "./RelayDefaultValueOfCorrectType" 11 | import { RelayKnownArgumentNames } from "./RelayKnownArgumentNames" 12 | import { RelayKnownVariableNames } from "./RelayKnownVariableNames" 13 | import { RelayNoUnusedArguments } from "./RelayNoUnusedArguments" 14 | import { RelayVariablesInAllowedPosition } from "./RelayVariablesInAllowedPosition" 15 | 16 | const DEFAULTS = { 17 | localSchemaFile: "./data/schema.graphql", 18 | src: "./src", 19 | } 20 | 21 | const ValidationRulesToExcludeForRelay = [ 22 | "KnownArgumentNames", 23 | "NoUndefinedVariables", 24 | "VariablesInAllowedPosition", 25 | "NoMissingClientDirectives", 26 | ] 27 | 28 | function loadRelayConfig() { 29 | if (!RelayConfig) { 30 | console.log("User has not installed relay-config, so needs manual configuration.") 31 | return null 32 | } else { 33 | const config = RelayConfig.loadConfig() 34 | if (!config) { 35 | console.log("Unable to load user's config from relay-config, so needs manual configuration.") 36 | } 37 | return config || null 38 | } 39 | } 40 | 41 | function getInputExtensions(relayConfig: ReturnType) { 42 | if (!RelayCompilerMain) { 43 | console.log("Unable to load relay-compiler, so `includes` may need manual configuration.") 44 | } 45 | const languagePlugin = 46 | RelayCompilerMain && RelayCompilerMain.getLanguagePlugin((relayConfig && relayConfig.language) || "javascript") 47 | return languagePlugin ? languagePlugin.inputExtensions : ["js", "jsx"] 48 | } 49 | 50 | export function generateConfig(compat: boolean = false) { 51 | const relayConfig = loadRelayConfig() 52 | const extensions = getInputExtensions(relayConfig) 53 | const directivesFile = generateDirectivesFile() 54 | const compatOnlyRules = compat ? [RelayCompatRequiredPageInfoFields, RelayCompatMissingConnectionDirective] : [] 55 | 56 | const includesGlobPattern = (inputExtensions: string[]) => `**/*.{graphql,${inputExtensions.join(",")}}` 57 | 58 | const config: ApolloConfigFormat = { 59 | client: { 60 | service: { 61 | name: "local", 62 | localSchemaFile: relayConfig ? relayConfig.schema : DEFAULTS.localSchemaFile, 63 | }, 64 | validationRules: [ 65 | RelayKnownArgumentNames, 66 | RelayKnownVariableNames, 67 | RelayVariablesInAllowedPosition, 68 | RelayArgumentsOfCorrectType, 69 | RelayDefaultValueOfCorrectType, 70 | RelayNoUnusedArguments, 71 | ...compatOnlyRules, 72 | ...defaultValidationRules.filter( 73 | (rule: ValidationRule) => !ValidationRulesToExcludeForRelay.some((name) => rule.name.startsWith(name)) 74 | ), 75 | ], 76 | includes: [directivesFile, path.join((relayConfig || DEFAULTS).src, includesGlobPattern(extensions))], 77 | excludes: relayConfig ? relayConfig.exclude : [], 78 | tagName: "graphql", 79 | }, 80 | } 81 | 82 | return { config, directivesFile, includesGlobPattern } 83 | } 84 | -------------------------------------------------------------------------------- /tests/RelayDefaultValueOfCorrectType-test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs" 2 | import { buildSchema, parse, validate } from "graphql" 3 | import { generateDirectivesFile } from "../src/generateDirectivesFile" 4 | import { RelayDefaultValueOfCorrectType } from "../src/RelayDefaultValueOfCorrectType" 5 | 6 | const schema = buildSchema(` 7 | ${readFileSync(generateDirectivesFile(), "utf8")} 8 | 9 | type Foo { 10 | bar: String 11 | baz: Boolean 12 | } 13 | 14 | type Query { 15 | foo: Foo 16 | node(id: ID!): Foo 17 | nodes(ids: [ID!]): [Foo] 18 | conditionalNode(id: ID!, condition: Boolean!): Foo 19 | } 20 | `) 21 | 22 | function validateDocuments(source: string) { 23 | return validate(schema, parse(source), [RelayDefaultValueOfCorrectType]) 24 | } 25 | 26 | describe(RelayDefaultValueOfCorrectType, () => { 27 | it("Disallows null as default value", () => { 28 | const errors = validateDocuments(` 29 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "Int", defaultValue: null }) { 30 | __typename 31 | } 32 | `) 33 | 34 | // My relay compiler hates when/if I do this. 35 | expect(errors).toHaveLength(1) 36 | expect(errors).toContainEqual( 37 | expect.objectContaining({ 38 | message: 39 | 'defaultValue for argument "intVal" on fragment "MyFragment" cannot be null. Instead, omit defaultValue.', 40 | }) 41 | ) 42 | }) 43 | 44 | it("Disallows bad default values", () => { 45 | const errors = validateDocuments(` 46 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "Int", defaultValue: "String" }, reqStrVal: { type: "String!", defaultValue: 5 }) { 47 | __typename 48 | } 49 | 50 | `) 51 | 52 | expect(errors).toHaveLength(2) 53 | expect(errors).toContainEqual( 54 | expect.objectContaining({ 55 | message: 'defaultValue for argument "intVal" on fragment "MyFragment" is expected to be of type "Int!".', 56 | }) 57 | ) 58 | expect(errors).toContainEqual( 59 | expect.objectContaining({ 60 | message: 'defaultValue for argument "reqStrVal" on fragment "MyFragment" is expected to be of type "String!".', 61 | }) 62 | ) 63 | }) 64 | 65 | it("Allows good default values", () => { 66 | const errors = validateDocuments(` 67 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "Int", defaultValue: 10 }, reqStrVal: { type: "String!", defaultValue: "Hello" }) { 68 | __typename 69 | } 70 | 71 | `) 72 | 73 | expect(errors).toHaveLength(0) 74 | }) 75 | 76 | it("Disallows bad list values", () => { 77 | const errors = validateDocuments(` 78 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "[Int]", defaultValue: ["String"] }, reqStrVal: { type: "[String!]!", defaultValue: [5] }) { 79 | __typename 80 | } 81 | 82 | `) 83 | 84 | expect(errors).toHaveLength(2) 85 | expect(errors).toContainEqual( 86 | expect.objectContaining({ 87 | message: 'defaultValue for argument "intVal" on fragment "MyFragment" is expected to be of type "[Int]!".', 88 | }) 89 | ) 90 | expect(errors).toContainEqual( 91 | expect.objectContaining({ 92 | message: 93 | 'defaultValue for argument "reqStrVal" on fragment "MyFragment" is expected to be of type "[String!]!".', 94 | }) 95 | ) 96 | }) 97 | 98 | it("Allows good literal arguments for lists", () => { 99 | const errors = validateDocuments(` 100 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "[Int]", defaultValue: [10] }, reqStrVal: { type: "[String!]!", defaultValue: ["Test"] }) { 101 | __typename 102 | } 103 | `) 104 | 105 | expect(errors).toHaveLength(0) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /tests/RelayArgumentsOfCorrectType-test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs" 2 | import { buildSchema, parse, validate } from "graphql" 3 | import { generateDirectivesFile } from "../src/generateDirectivesFile" 4 | import { RelayArgumentsOfCorrectType } from "../src/RelayArgumentsOfCorrectType" 5 | 6 | const schema = buildSchema(` 7 | ${readFileSync(generateDirectivesFile(), "utf8")} 8 | 9 | type Foo { 10 | bar: String 11 | baz: Boolean 12 | } 13 | 14 | type Query { 15 | foo: Foo 16 | node(id: ID!): Foo 17 | nodes(ids: [ID!]): [Foo] 18 | conditionalNode(id: ID!, condition: Boolean!): Foo 19 | } 20 | `) 21 | 22 | function validateDocuments(source: string) { 23 | return validate(schema, parse(source), [RelayArgumentsOfCorrectType]) 24 | } 25 | 26 | describe(RelayArgumentsOfCorrectType, () => { 27 | it("Disallows bad literal arguments", () => { 28 | const errors = validateDocuments(` 29 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "Int" }, reqStrVal: { type: "String!" }) { 30 | __typename 31 | } 32 | 33 | query MyQuery { 34 | ... MyFragment @arguments(intVal: "Test", reqStrVal: null) 35 | } 36 | `) 37 | 38 | expect(errors).toHaveLength(2) 39 | expect(errors).toContainEqual( 40 | expect.objectContaining({ 41 | message: 'Argument "intVal" for fragment "MyFragment" is expected to be of type "Int".', 42 | }) 43 | ) 44 | expect(errors).toContainEqual( 45 | expect.objectContaining({ 46 | message: 'Argument "reqStrVal" for fragment "MyFragment" is expected to be of type "String!".', 47 | }) 48 | ) 49 | }) 50 | 51 | it("Allows good literal arguments", () => { 52 | const errors = validateDocuments(` 53 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "Int" }, reqStrVal: { type: "String!" }) { 54 | __typename 55 | } 56 | 57 | query MyQuery { 58 | ... MyFragment @arguments(intVal: null, reqStrVal: "string") 59 | } 60 | `) 61 | 62 | expect(errors).toHaveLength(0) 63 | }) 64 | 65 | // We should probably have a different validation rule for this. As far as I know this sort of stuff is not allowed within relay 66 | it("Ignores nested variable arguments", () => { 67 | const errors = validateDocuments(` 68 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "[Int!]" }, reqStrVal: { type: "String!" }) { 69 | __typename 70 | } 71 | 72 | query MyQuery { 73 | ... MyFragment @arguments(intVal: [$variable], reqStrVal: "string") 74 | } 75 | `) 76 | 77 | expect(errors).toHaveLength(0) 78 | }) 79 | 80 | it("Disallows bad literal arguments for lists", () => { 81 | const errors = validateDocuments(` 82 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "[Int]" }, reqStrVal: { type: "[String!]!" }) { 83 | __typename 84 | } 85 | 86 | query MyQuery { 87 | ... MyFragment @arguments(intVal: ["Test"], reqStrVal: [null]) 88 | } 89 | `) 90 | 91 | expect(errors).toHaveLength(2) 92 | expect(errors).toContainEqual( 93 | expect.objectContaining({ 94 | message: 'Argument "intVal" for fragment "MyFragment" is expected to be of type "[Int]".', 95 | }) 96 | ) 97 | expect(errors).toContainEqual( 98 | expect.objectContaining({ 99 | message: 'Argument "reqStrVal" for fragment "MyFragment" is expected to be of type "[String!]!".', 100 | }) 101 | ) 102 | }) 103 | 104 | it("Allows good literal arguments for lists", () => { 105 | const errors = validateDocuments(` 106 | fragment MyFragment on Query @argumentDefinitions(intVal: { type: "[Int]" }, reqStrVal: { type: "[String!]!" }) { 107 | __typename 108 | } 109 | 110 | query MyQuery { 111 | ... MyFragment @arguments(intVal: [10], reqStrVal: ["Test"]) 112 | } 113 | `) 114 | 115 | expect(errors).toHaveLength(0) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /tests/RelayKnownVariableNames-test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs" 2 | import { buildSchema, parse, validate } from "graphql" 3 | import { generateDirectivesFile } from "../src/generateDirectivesFile" 4 | import { RelayKnownVariableNames } from "../src/RelayKnownVariableNames" 5 | 6 | const schema = buildSchema(` 7 | ${readFileSync(generateDirectivesFile(), "utf8")} 8 | 9 | type Foo { 10 | bar: String 11 | baz: Boolean 12 | } 13 | 14 | type Query { 15 | foo: Foo 16 | node(id: ID!): Foo 17 | conditionalNode(id: ID!, condition: Boolean!): Foo 18 | } 19 | `) 20 | 21 | function validateDocuments(source: string) { 22 | return validate(schema, parse(source), [RelayKnownVariableNames]) 23 | } 24 | 25 | describe(RelayKnownVariableNames, () => { 26 | it("Validates regular queries fine", () => { 27 | const errors = validateDocuments(` 28 | query MyQuery($id: ID!) { 29 | ... { 30 | node(id: $id) @include(if: $shouldInclude) { 31 | bar 32 | } 33 | } 34 | } 35 | `) 36 | 37 | expect(errors.length).toBe(1) 38 | expect(errors).toContainEqual( 39 | expect.objectContaining({ message: 'Variable "$shouldInclude" is not defined by operation "MyQuery".' }) 40 | ) 41 | }) 42 | 43 | it("Does not Validate fragments before they are used in operations", () => { 44 | const errors = validateDocuments(` 45 | fragment MyFragment on Query @argumentDefinitions(id: { type: "ID!"}) { 46 | ... { 47 | node(id: $id) @include(if: $shouldInclude) { 48 | bar 49 | } 50 | } 51 | } 52 | `) 53 | 54 | expect(errors.length).toBe(0) 55 | }) 56 | 57 | it("Validates fragments when used in at least one operation", () => { 58 | const errors = validateDocuments(` 59 | query MyQuery { 60 | ... MyFragment @arguments(id: "ID") 61 | } 62 | fragment MyFragment on Query @argumentDefinitions(id: { type: "ID!"}) { 63 | ... { 64 | node(id: $id) @include(if: $shouldInclude) { 65 | bar 66 | } 67 | } 68 | } 69 | `) 70 | 71 | expect(errors).toHaveLength(1) 72 | expect(errors).toContainEqual( 73 | expect.objectContaining({ 74 | message: 'Variable "$shouldInclude" is used by fragment "MyFragment", but not defined by operation "MyQuery".', 75 | }) 76 | ) 77 | }) 78 | 79 | it("Allows operation defined variables", () => { 80 | const errors = validateDocuments(` 81 | query MyQuery ($id: ID!) { 82 | ... MyFragment 83 | } 84 | fragment MyFragment on Query { 85 | ... { 86 | node(id: $id) @include(if: $shouldInclude) { 87 | bar 88 | } 89 | } 90 | } 91 | `) 92 | 93 | expect(errors).toHaveLength(1) 94 | expect(errors).toContainEqual( 95 | expect.objectContaining({ 96 | message: 'Variable "$shouldInclude" is used by fragment "MyFragment", but not defined by operation "MyQuery".', 97 | }) 98 | ) 99 | }) 100 | 101 | it("Allows operation defined variables across multiple queries", () => { 102 | const errors = validateDocuments(` 103 | query MyQuery ($id: ID!, $shouldInclude: Boolean!) { 104 | ... MyFragment 105 | } 106 | query MyOtherQuery ($id: ID!, $shouldInclude: Boolean) { 107 | ... MyFragment 108 | } 109 | fragment MyFragment on Query { 110 | ... { 111 | conditionalNode(id: $id, condition: $shouldInclude){ 112 | bar 113 | } 114 | } 115 | } 116 | `) 117 | 118 | expect(errors).toHaveLength(0) 119 | }) 120 | 121 | it("Recognises which operations are not defining a variable defined in some operation", () => { 122 | const errors = validateDocuments(` 123 | query MyQuery ($id: ID!, $shouldInclude: Boolean!) { 124 | ... MyFragment 125 | } 126 | query MyOtherQuery ($id: ID!) { 127 | ... MyFragment 128 | } 129 | fragment MyFragment on Query { 130 | ... { 131 | conditionalNode(id: $id, condition: $shouldInclude){ 132 | bar 133 | } 134 | } 135 | } 136 | `) 137 | 138 | expect(errors).toHaveLength(1) 139 | expect(errors).toContainEqual( 140 | expect.objectContaining({ 141 | message: 142 | 'Variable "$shouldInclude" is used by fragment "MyFragment", but not defined by operation "MyOtherQuery".', 143 | }) 144 | ) 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /src/RelayVariablesInAllowedPosition.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema, GraphQLType, ValidationRule, ValueNode } from "graphql" 2 | import { getRecursiveVariableUsagesWithRelayInfo, isFragmentDefinedVariable } from "./argumentDefinitions" 3 | import { GraphQLError, isNonNullType, isTypeSubTypeOf } from "./dependencies" 4 | 5 | // tslint:disable-next-line: no-shadowed-variable 6 | export const RelayVariablesInAllowedPosition: ValidationRule = function RelayVariablesInAllowedPosition(context) { 7 | return { 8 | FragmentDefinition(fragmentDef) { 9 | const schema = context.getSchema() 10 | const varUsages = getRecursiveVariableUsagesWithRelayInfo(context, fragmentDef) 11 | 12 | varUsages.forEach((usage) => { 13 | if (usage.variableDefinition) { 14 | const varDefType = usage.variableDefinition.schemaType 15 | const varDefDefault = usage.variableDefinition.defaultValue 16 | const definitionNode = usage.variableDefinition.node 17 | 18 | const locationType = usage.type 19 | const locationDefaultValue = usage.defaultValue 20 | const varName = usage.node.name.value 21 | if ( 22 | varDefType && 23 | locationType && 24 | definitionNode && 25 | !allowedVariableUsage(schema, varDefType, varDefDefault, locationType, locationDefaultValue) 26 | ) { 27 | // The diagnostics in vscode does seemingly not support errors in one file having a related location 28 | // in a different file 29 | const location = [...(!usage.usingFragmentName ? [definitionNode] : []), usage.node] 30 | context.reportError( 31 | new GraphQLError(badVarPosMessage(varName, varDefType.toString(), locationType.toString()), location) 32 | ) 33 | } 34 | } 35 | }) 36 | }, 37 | OperationDefinition(opDef) { 38 | const schema = context.getSchema() 39 | const varUsages = getRecursiveVariableUsagesWithRelayInfo(context, opDef) 40 | 41 | const errors = Object.create(null) 42 | 43 | varUsages.forEach((usage) => { 44 | // We only check for variables that are not defined in the fragment itself 45 | // as the visitor for the fragment definition will test for that 46 | // thus giving errors for those variables even when the fragment is not 47 | // used in an operation 48 | if (usage.variableDefinition && !isFragmentDefinedVariable(usage.variableDefinition)) { 49 | const varDefType = usage.variableDefinition.schemaType 50 | const varDefDefault = usage.variableDefinition.defaultValue 51 | const definitionNode = usage.variableDefinition.node 52 | 53 | const locationType = usage.type 54 | const locationDefaultValue = usage.defaultValue 55 | const varName = usage.node.name.value 56 | if ( 57 | varDefType && 58 | locationType && 59 | definitionNode && 60 | !allowedVariableUsage(schema, varDefType, varDefDefault, locationType, locationDefaultValue) 61 | ) { 62 | // The diagnostics in vscode does seemingly not support errors in one file having a related location 63 | // in a different file 64 | const location = [...(!usage.usingFragmentName ? [usage.node] : []), opDef] 65 | const errorStr = badVarPosMessage(varName, varDefType.toString(), locationType.toString()) 66 | if (!errors[errorStr]) { 67 | if (usage.usingFragmentName) { 68 | errors[errorStr] = true 69 | } 70 | context.reportError(new GraphQLError(errorStr, location)) 71 | } 72 | } 73 | } 74 | }) 75 | }, 76 | } 77 | } 78 | 79 | /** 80 | * This is ported from the `VariablesInAllowedPosition` rule from GraphQL itself 81 | */ 82 | function allowedVariableUsage( 83 | schema: GraphQLSchema, 84 | varType: GraphQLType, 85 | varDefaultValue: ValueNode | undefined, 86 | locationType: GraphQLType, 87 | locationDefaultValue: ValueNode | undefined 88 | ): boolean { 89 | if (isNonNullType(locationType) && !isNonNullType(varType)) { 90 | const hasNonNullVariableDefaultValue = varDefaultValue != null && varDefaultValue.kind !== "NullValue" 91 | const hasLocationDefaultValue = locationDefaultValue !== undefined 92 | if (!hasNonNullVariableDefaultValue && !hasLocationDefaultValue) { 93 | return false 94 | } 95 | const nullableLocationType = locationType.ofType 96 | return isTypeSubTypeOf(schema, varType, nullableLocationType) 97 | } 98 | return isTypeSubTypeOf(schema, varType, locationType) 99 | } 100 | 101 | function badVarPosMessage(varName: string, varType: string, expectedType: string): string { 102 | return `Variable "$${varName}" of type "${varType}" used in position expecting type "${expectedType}".` 103 | } 104 | -------------------------------------------------------------------------------- /src/RelayCompatRequiredPageInfoFields.ts: -------------------------------------------------------------------------------- 1 | import { FieldNode, FragmentDefinitionNode, SelectionSetNode, ValidationRule } from "graphql" 2 | import { GraphQLError, visit } from "./dependencies" 3 | import { getConnectionDirective, isConnectionType } from "./utils" 4 | 5 | function hasFirstArgument(fieldNode: FieldNode): boolean { 6 | return !!(fieldNode.arguments && fieldNode.arguments.find((arg) => arg.name.value === "first")) 7 | } 8 | 9 | function hasLastArgument(fieldNode: FieldNode): boolean { 10 | return !!(fieldNode.arguments && fieldNode.arguments.find((arg) => arg.name.value === "last")) 11 | } 12 | 13 | function hasAfterArgument(fieldNode: FieldNode): boolean { 14 | return !!(fieldNode.arguments && fieldNode.arguments.find((arg) => arg.name.value === "after")) 15 | } 16 | 17 | function hasBeforeArgument(fieldNode: FieldNode): boolean { 18 | return !!(fieldNode.arguments && fieldNode.arguments.find((arg) => arg.name.value === "before")) 19 | } 20 | 21 | function rollupFieldsInfo(fieldsInfo: PaginationFields[]): PaginationFields { 22 | return fieldsInfo.reduce( 23 | (carry, el) => { 24 | carry.hasNextPage = carry.hasNextPage || el.hasNextPage 25 | carry.hasPreviousPage = carry.hasPreviousPage || el.hasPreviousPage 26 | carry.endCursor = carry.endCursor || el.endCursor 27 | carry.startCursor = carry.startCursor || el.startCursor 28 | return carry 29 | }, 30 | { 31 | hasNextPage: false, 32 | hasPreviousPage: false, 33 | endCursor: false, 34 | startCursor: false, 35 | } 36 | ) 37 | } 38 | 39 | export function connectionSelectionSetPaginationInfo( 40 | getFragment: (name: string) => FragmentDefinitionNode | null | undefined, 41 | selectionSetNode: SelectionSetNode 42 | ): PaginationFields { 43 | const fieldsInfo: PaginationFields[] = [] 44 | 45 | visit(selectionSetNode, { 46 | SelectionSet(selectionSet) { 47 | if (selectionSet !== selectionSetNode) { 48 | // Don't recurse into other selection sets 49 | return false 50 | } 51 | }, 52 | InlineFragment(inlineFragment) { 53 | fieldsInfo.push(connectionSelectionSetPaginationInfo(getFragment, inlineFragment.selectionSet)) 54 | return false 55 | }, 56 | Field(fieldNode) { 57 | if (fieldNode.name.value === "pageInfo" && fieldNode.selectionSet) { 58 | fieldsInfo.push(pageInfoSelectionSetPaginationInfo(getFragment, fieldNode.selectionSet)) 59 | return false 60 | } 61 | }, 62 | FragmentSpread(fragmentSpread) { 63 | const fragmentDefinitionNode = getFragment(fragmentSpread.name.value) 64 | if (fragmentDefinitionNode) { 65 | fieldsInfo.push(connectionSelectionSetPaginationInfo(getFragment, fragmentDefinitionNode.selectionSet)) 66 | } 67 | return false 68 | }, 69 | }) 70 | return rollupFieldsInfo(fieldsInfo) 71 | } 72 | 73 | interface PaginationFields { 74 | hasNextPage: boolean 75 | hasPreviousPage: boolean 76 | startCursor: boolean 77 | endCursor: boolean 78 | } 79 | 80 | function pageInfoSelectionSetPaginationInfo( 81 | getFragment: (name: string) => FragmentDefinitionNode | null | undefined, 82 | selectionSetNode: SelectionSetNode 83 | ): PaginationFields { 84 | const fields: PaginationFields = { 85 | hasNextPage: false, 86 | hasPreviousPage: false, 87 | startCursor: false, 88 | endCursor: false, 89 | } 90 | const nestedFieldsInfo: PaginationFields[] = [] 91 | 92 | visit(selectionSetNode, { 93 | SelectionSet(selectionSet) { 94 | if (selectionSet !== selectionSetNode) { 95 | // Don't recurse into other selection sets 96 | return false 97 | } 98 | }, 99 | InlineFragment(inlineFragment) { 100 | nestedFieldsInfo.push(pageInfoSelectionSetPaginationInfo(getFragment, inlineFragment.selectionSet)) 101 | return false 102 | }, 103 | Field(fieldNode) { 104 | if (fieldNode.name.value === "startCursor") { 105 | fields.startCursor = true 106 | } 107 | if (fieldNode.name.value === "endCursor") { 108 | fields.endCursor = true 109 | } 110 | if (fieldNode.name.value === "hasPreviousPage") { 111 | fields.hasPreviousPage = true 112 | } 113 | if (fieldNode.name.value === "hasNextPage") { 114 | fields.hasNextPage = true 115 | } 116 | }, 117 | FragmentSpread(fragmentSpread) { 118 | const fragmentDefinitionNode = getFragment(fragmentSpread.name.value) 119 | if (fragmentDefinitionNode) { 120 | nestedFieldsInfo.push(pageInfoSelectionSetPaginationInfo(getFragment, fragmentDefinitionNode.selectionSet)) 121 | } 122 | return false 123 | }, 124 | }) 125 | return rollupFieldsInfo([fields, ...nestedFieldsInfo]) 126 | } 127 | 128 | // tslint:disable-next-line: no-shadowed-variable 129 | export const RelayCompatRequiredPageInfoFields: ValidationRule = function RelayCompatRequiredPageInfoFields(context) { 130 | return { 131 | Field: { 132 | enter(fieldNode) { 133 | if (!fieldNode.selectionSet) { 134 | return 135 | } 136 | const type = context.getType() 137 | if (!type || !isConnectionType(type)) { 138 | return 139 | } 140 | const connectionDirective = getConnectionDirective(fieldNode) 141 | if (!connectionDirective) { 142 | return 143 | } 144 | 145 | const isForwardConnection = hasFirstArgument(fieldNode) && hasAfterArgument(fieldNode) 146 | const isBackwardConnection = hasLastArgument(fieldNode) && hasBeforeArgument(fieldNode) 147 | const selectionName = fieldNode.alias || fieldNode.name 148 | 149 | const paginationFields = connectionSelectionSetPaginationInfo( 150 | (name) => context.getFragment(name), 151 | fieldNode.selectionSet 152 | ) 153 | 154 | const connectionName = connectionDirective.key || selectionName 155 | 156 | if (isForwardConnection) { 157 | if (!paginationFields.hasNextPage) { 158 | context.reportError( 159 | new GraphQLError( 160 | `Missing pageInfo.hasNextPage field on connection "${connectionName}".`, 161 | connectionDirective.directive 162 | ) 163 | ) 164 | } 165 | if (!paginationFields.endCursor) { 166 | context.reportError( 167 | new GraphQLError( 168 | `Missing pageInfo.endCursor field on connection "${connectionName}".`, 169 | connectionDirective.directive 170 | ) 171 | ) 172 | } 173 | } 174 | 175 | if (isBackwardConnection) { 176 | if (!paginationFields.hasPreviousPage) { 177 | context.reportError( 178 | new GraphQLError( 179 | `Missing pageInfo.hasPreviousPage field on connection "${connectionName}".`, 180 | connectionDirective.directive 181 | ) 182 | ) 183 | } 184 | if (!paginationFields.startCursor) { 185 | context.reportError( 186 | new GraphQLError( 187 | `Missing pageInfo.startCursor field on connection "${connectionName}".`, 188 | connectionDirective.directive 189 | ) 190 | ) 191 | } 192 | } 193 | }, 194 | }, 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/argumentDefinitions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentNode, 3 | FragmentDefinitionNode, 4 | GraphQLInputType, 5 | NameNode, 6 | OperationDefinitionNode, 7 | TypeNode, 8 | ValidationContext, 9 | ValueNode, 10 | VariableNode, 11 | } from "graphql" 12 | import { NodeWithSelectionSet, VariableUsage } from "graphql/validation/ValidationContext" 13 | import { isInputType, parseType, typeFromAST, TypeInfo, visit, visitWithTypeInfo } from "./dependencies" 14 | import { findFragmentSpreadParent } from "./utils" 15 | 16 | export function getArgumentDefinitions(fragmentDefinitionNode: FragmentDefinitionNode) { 17 | let argumentDefinitionNodes: readonly ArgumentNode[] | undefined 18 | visit(fragmentDefinitionNode, { 19 | Directive(argumentDefinitionsDirectiveNode) { 20 | if (argumentDefinitionsDirectiveNode.name.value === "argumentDefinitions") { 21 | argumentDefinitionNodes = argumentDefinitionsDirectiveNode.arguments 22 | } else { 23 | return false 24 | } 25 | }, 26 | }) 27 | return argumentDefinitionNodes 28 | } 29 | 30 | export function getFragmentArgumentDefinitions( 31 | context: ValidationContext, 32 | fragmentDefinitionNode: FragmentDefinitionNode 33 | ): { [varName: string]: VariableOrArgumentDefinition } { 34 | const argDefs = getArgumentDefinitions(fragmentDefinitionNode) 35 | if (argDefs == null) { 36 | return {} 37 | } 38 | 39 | return argDefs.reduce((carry, argDef) => { 40 | const node = argDef.name 41 | const name = argDef.name.value 42 | 43 | let astTypeNode: TypeNode | undefined 44 | let defaultValue: ValueNode | undefined 45 | 46 | if (argDef.value.kind === "ObjectValue") { 47 | const typeField = argDef.value.fields.find((f) => f.name.value === "type") 48 | const defaultValueField = argDef.value.fields.find((f) => f.name.value === "defaultValue") 49 | 50 | if (typeField != null && typeField.value.kind === "StringValue") { 51 | try { 52 | astTypeNode = parseType(typeField.value.value) 53 | } catch { 54 | // ignore 55 | } 56 | } 57 | if (defaultValueField != null) { 58 | defaultValue = defaultValueField.value 59 | } 60 | } 61 | 62 | let schemaType: GraphQLInputType | undefined 63 | if (astTypeNode != null) { 64 | try { 65 | const type = typeFromAST(context.getSchema(), astTypeNode as any) 66 | if (isInputType(type)) { 67 | schemaType = type 68 | } 69 | } catch { 70 | // ignore 71 | } 72 | } 73 | 74 | carry[name] = { 75 | node, 76 | schemaType, 77 | typeNode: astTypeNode, 78 | defaultValue, 79 | } 80 | 81 | return carry 82 | }, {} as { [varName: string]: VariableOrArgumentDefinition }) 83 | } 84 | 85 | function getVariableUsages(context: ValidationContext, nodeWithSelection: NodeWithSelectionSet): VariableUsage[] { 86 | const typeInfo = new TypeInfo(context.getSchema()) 87 | const newUsages: VariableUsage[] = [] 88 | visit( 89 | nodeWithSelection, 90 | visitWithTypeInfo(typeInfo, { 91 | VariableDefinition: () => false, 92 | Directive: (directive, _key, _parent, _hans, ancestors) => { 93 | if (directive.name.value !== "arguments" || !directive.arguments) { 94 | return 95 | } 96 | const fragmentSpreadParent = findFragmentSpreadParent(ancestors) 97 | if (!fragmentSpreadParent) { 98 | return false 99 | } 100 | const fragmentDefinition = context.getFragment(fragmentSpreadParent.name.value) 101 | if (fragmentDefinition == null) { 102 | return false 103 | } 104 | const fragmentArguments = getFragmentArgumentDefinitions(context, fragmentDefinition) 105 | 106 | directive.arguments.forEach((arg) => { 107 | const argumentName = arg.name.value 108 | const argumentValue = arg.value 109 | if (argumentValue.kind === "Variable") { 110 | const definition = fragmentArguments[argumentName] 111 | if (!definition) { 112 | newUsages.push({ 113 | node: argumentValue, 114 | type: undefined, 115 | defaultValue: undefined, 116 | }) 117 | } else { 118 | newUsages.push({ 119 | node: argumentValue, 120 | type: definition.schemaType, 121 | defaultValue: definition.defaultValue, 122 | }) 123 | } 124 | } 125 | }) 126 | return false 127 | }, 128 | Variable(variable) { 129 | newUsages.push({ 130 | node: variable, 131 | type: typeInfo.getInputType(), 132 | defaultValue: typeInfo.getDefaultValue(), 133 | }) 134 | }, 135 | }) 136 | ) 137 | 138 | return newUsages 139 | } 140 | 141 | export interface VariableOrArgumentDefinition { 142 | node: VariableNode | NameNode 143 | schemaType?: GraphQLInputType 144 | typeNode?: TypeNode 145 | defaultValue?: ValueNode 146 | } 147 | 148 | export function isFragmentDefinedVariable(variableOrArgumentDefinition: VariableOrArgumentDefinition): boolean { 149 | return variableOrArgumentDefinition.node.kind === "Name" 150 | } 151 | 152 | export interface VariableUsageWithDefinition extends VariableUsage { 153 | variableDefinition?: VariableOrArgumentDefinition 154 | usingFragmentName: string | null 155 | } 156 | export function getRecursiveVariableUsagesWithRelayInfo( 157 | context: ValidationContext, 158 | nodeWithSelectionSet: OperationDefinitionNode | FragmentDefinitionNode 159 | ): readonly VariableUsageWithDefinition[] { 160 | const schema = context.getSchema() 161 | const rootVariables = 162 | nodeWithSelectionSet.kind === "OperationDefinition" 163 | ? nodeWithSelectionSet.variableDefinitions == null 164 | ? {} 165 | : nodeWithSelectionSet.variableDefinitions.reduce((carry, varDef) => { 166 | const variableName = varDef.variable.name.value 167 | carry[variableName] = { 168 | node: varDef.variable, 169 | defaultValue: varDef.defaultValue, 170 | typeNode: varDef.type, 171 | } 172 | try { 173 | const schemaType = typeFromAST(schema, varDef.type as any) 174 | if (isInputType(schemaType)) { 175 | carry[variableName].schemaType = schemaType 176 | } 177 | } catch { 178 | // ignore 179 | } 180 | return carry 181 | }, {} as { [varName: string]: VariableOrArgumentDefinition }) 182 | : getFragmentArgumentDefinitions(context, nodeWithSelectionSet) 183 | const fragments = 184 | nodeWithSelectionSet.kind === "OperationDefinition" 185 | ? context.getRecursivelyReferencedFragments(nodeWithSelectionSet) 186 | : [] 187 | 188 | const rootUsages = getVariableUsages(context, nodeWithSelectionSet).map((usage) => { 189 | const newUsage = { ...usage, usingFragmentName: null } as VariableUsageWithDefinition 190 | const varName = usage.node.name.value 191 | if (rootVariables[varName]) { 192 | newUsage.variableDefinition = rootVariables[varName] 193 | } 194 | return newUsage 195 | }) 196 | 197 | const fragmentUsages = fragments.map((fragment): VariableUsageWithDefinition[] => { 198 | const argumentDefs = getFragmentArgumentDefinitions(context, fragment) 199 | 200 | const framgentUsages = getVariableUsages(context, fragment) 201 | 202 | return framgentUsages.map((usage) => ({ 203 | ...usage, 204 | variableDefinition: argumentDefs[usage.node.name.value] 205 | ? argumentDefs[usage.node.name.value] 206 | : rootVariables[usage.node.name.value], 207 | usingFragmentName: fragment.name.value, 208 | })) 209 | }) 210 | return [...rootUsages].concat(Array.prototype.concat.apply([], fragmentUsages)) 211 | } 212 | -------------------------------------------------------------------------------- /src/RelayKnownArgumentNames.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DirectiveNode, 3 | FragmentDefinitionNode, 4 | FragmentSpreadNode, 5 | ObjectValueNode, 6 | TypeNode, 7 | ValidationContext, 8 | ValidationRule, 9 | } from "graphql" 10 | import { getArgumentDefinitions } from "./argumentDefinitions" 11 | import { defaultValidationRules, didYouMean, GraphQLError, parseType, suggestionList, visit } from "./dependencies" 12 | import { containsVariableNodes } from "./utils" 13 | 14 | const KnownArgumentNames = defaultValidationRules.find((rule) => rule.name.startsWith("KnownArgumentNames"))! 15 | 16 | // tslint:disable-next-line: no-shadowed-variable 17 | export const RelayKnownArgumentNames: ValidationRule = function RelayKnownArgumentNames(context) { 18 | const originalRuleVisitor = KnownArgumentNames(context) 19 | return { 20 | Argument(argumentNode) { 21 | /** 22 | * Always forward field arguments to the original rule. 23 | */ 24 | visit(argumentNode, originalRuleVisitor) 25 | return false 26 | }, 27 | FragmentSpread(fragmentSpreadNode) { 28 | const fragmentDefinitionNode = context.getFragment(fragmentSpreadNode.name.value) 29 | if ( 30 | fragmentDefinitionNode && 31 | (!fragmentSpreadNode.directives || 32 | fragmentSpreadNode.directives.findIndex((directive) => directive.name.value === "arguments") === -1) && 33 | getArgumentDefinitions(fragmentDefinitionNode) 34 | ) { 35 | validateFragmentArguments(context, fragmentDefinitionNode, fragmentSpreadNode) 36 | } 37 | }, 38 | Directive(directiveNode, _key, _parent, _nodePath, ancestors) { 39 | if (directiveNode.name.value === "argumentDefinitions") { 40 | validateFragmentArgumentDefinitions(context, directiveNode) 41 | return false 42 | } 43 | if (directiveNode.name.value === "arguments") { 44 | const fragmentSpreadNode = ancestors[ancestors.length - 1] as FragmentSpreadNode 45 | const fragmentDefinitionNode = context.getFragment(fragmentSpreadNode.name.value) 46 | if (fragmentDefinitionNode) { 47 | validateFragmentArguments(context, fragmentDefinitionNode, fragmentSpreadNode, directiveNode) 48 | } 49 | return false 50 | } 51 | /** 52 | * Forward any other directives to original rule. 53 | */ 54 | visit(directiveNode, originalRuleVisitor) 55 | return false 56 | }, 57 | } 58 | } 59 | 60 | function validateFragmentArgumentDefinitions(context: ValidationContext, directiveNode: DirectiveNode) { 61 | if (!directiveNode.arguments || directiveNode.arguments.length === 0) { 62 | context.reportError(new GraphQLError(`Missing required argument definitions.`, directiveNode)) 63 | } else { 64 | directiveNode.arguments.forEach((argumentNode) => { 65 | const metadataNode = argumentNode.value 66 | if (metadataNode.kind !== "ObjectValue" || !metadataNode.fields.some((field) => field.name.value === "type")) { 67 | context.reportError( 68 | new GraphQLError( 69 | `Metadata of argument definition should be of type "Object" with a "type" and optional "defaultValue" key.`, 70 | metadataNode 71 | ) 72 | ) 73 | } else { 74 | metadataNode.fields.forEach((fieldNode) => { 75 | const name = fieldNode.name.value 76 | if (name !== "type" && name !== "defaultValue") { 77 | context.reportError( 78 | new GraphQLError(`Unknown key "${name}" in argument definition metadata.`, fieldNode.name) 79 | ) 80 | } 81 | const valueNode = fieldNode.value 82 | if (name === "type") { 83 | if (valueNode.kind !== "StringValue") { 84 | context.reportError( 85 | new GraphQLError( 86 | `Value for "type" in argument definition metadata must be specified as string literal.`, 87 | valueNode 88 | ) 89 | ) 90 | } else { 91 | let typeNode: TypeNode | null = null 92 | try { 93 | typeNode = parseType(valueNode.value) 94 | } catch (error) { 95 | context.reportError(new GraphQLError(error.message, valueNode)) 96 | } 97 | if (typeNode) { 98 | while (typeNode.kind === "NonNullType" || typeNode.kind === "ListType") { 99 | typeNode = typeNode.type 100 | } 101 | if (!context.getSchema().getType(typeNode.name.value)) { 102 | context.reportError( 103 | new GraphQLError( 104 | `Unknown type "${typeNode.name.value}" in argument definition metadata.`, 105 | valueNode 106 | ) 107 | ) 108 | } 109 | } 110 | } 111 | } else if (name === "defaultValue") { 112 | if (containsVariableNodes(fieldNode.value)) { 113 | context.reportError( 114 | new GraphQLError( 115 | `defaultValue contains variables for argument ${argumentNode.name.value} in argument definition metadata.`, 116 | valueNode 117 | ) 118 | ) 119 | } 120 | } 121 | }) 122 | } 123 | }) 124 | } 125 | } 126 | 127 | function isNullableArgument(argumentDefinition: ObjectValueNode): boolean { 128 | const typeField = argumentDefinition.fields.find((f) => f.name.value === "type") 129 | if (typeField == null) { 130 | return false 131 | } 132 | 133 | if (typeField.value.kind !== "StringValue") { 134 | return false 135 | } 136 | try { 137 | const type = parseType(typeField.value.value) 138 | return type.kind !== "NonNullType" 139 | } catch (e) { 140 | return false 141 | } 142 | } 143 | 144 | function validateFragmentArguments( 145 | context: ValidationContext, 146 | fragmentDefinitionNode: FragmentDefinitionNode, 147 | fragmentSpreadNode: FragmentSpreadNode, 148 | directiveNode?: DirectiveNode 149 | ) { 150 | const argumentDefinitionNodes = getArgumentDefinitions(fragmentDefinitionNode) 151 | if (!argumentDefinitionNodes) { 152 | context.reportError( 153 | new GraphQLError( 154 | `No fragment argument definitions exist for fragment "${fragmentSpreadNode.name.value}".`, 155 | fragmentSpreadNode 156 | ) 157 | ) 158 | } else { 159 | const argumentNodes = [...((directiveNode && directiveNode.arguments) || [])] 160 | argumentDefinitionNodes.forEach((argumentDef) => { 161 | const argumentIndex = argumentNodes.findIndex((a) => a.name.value === argumentDef.name.value) 162 | if (argumentIndex >= 0) { 163 | argumentNodes.splice(argumentIndex, 1) 164 | } else { 165 | const value = argumentDef.value 166 | if (value.kind === "ObjectValue") { 167 | if ( 168 | value.fields.findIndex((field) => field.name.value === "defaultValue") === -1 && 169 | !isNullableArgument(value) 170 | ) { 171 | context.reportError( 172 | new GraphQLError( 173 | `Missing required fragment argument "${argumentDef.name.value}".`, 174 | directiveNode || fragmentSpreadNode 175 | ) 176 | ) 177 | } 178 | } else { 179 | console.log(`Unexpected fragment argument value kind "${value.kind}".`) 180 | } 181 | } 182 | }) 183 | argumentNodes.forEach((argumentNode) => { 184 | const suggestions: string[] = suggestionList( 185 | argumentNode.name.value, 186 | argumentDefinitionNodes.map((argDef) => argDef.name.value) 187 | ) 188 | context.reportError( 189 | new GraphQLError( 190 | `Unknown fragment argument "${argumentNode.name.value}".` + didYouMean(suggestions.map((x) => `"${x}"`)), 191 | directiveNode 192 | ) 193 | ) 194 | }) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/RelayVariablesInAllowedPosition-test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs" 2 | import { buildSchema, parse, validate } from "graphql" 3 | import { generateDirectivesFile } from "../src/generateDirectivesFile" 4 | import { RelayVariablesInAllowedPosition } from "../src/RelayVariablesInAllowedPosition" 5 | 6 | const schema = buildSchema(` 7 | ${readFileSync(generateDirectivesFile(), "utf8")} 8 | 9 | type Foo { 10 | bar: String 11 | baz: Boolean 12 | } 13 | 14 | type Query { 15 | foo: Foo 16 | node(id: ID!): Foo 17 | nodes(ids: [ID!]): [Foo] 18 | conditionalNode(id: ID!, condition: Boolean!): Foo 19 | } 20 | `) 21 | 22 | function validateDocuments(source: string) { 23 | return validate(schema, parse(source), [RelayVariablesInAllowedPosition]) 24 | } 25 | 26 | describe(RelayVariablesInAllowedPosition, () => { 27 | it("Allows valid query", () => { 28 | const errors = validateDocuments(` 29 | query MyQuery($id: ID!) { 30 | ... { 31 | node(id: $id) { 32 | bar 33 | } 34 | } 35 | } 36 | `) 37 | 38 | expect(errors.length).toBe(0) 39 | }) 40 | 41 | it("Disallows invalid query", () => { 42 | const errors = validateDocuments(` 43 | query MyQuery($id: ID) { 44 | ... { 45 | node(id: $id) { 46 | bar 47 | } 48 | } 49 | } 50 | `) 51 | 52 | expect(errors.length).toBe(1) 53 | expect(errors).toContainEqual( 54 | expect.objectContaining({ message: 'Variable "$id" of type "ID" used in position expecting type "ID!".' }) 55 | ) 56 | }) 57 | 58 | it("Allows valid fragment", () => { 59 | const errors = validateDocuments(` 60 | fragment MyFragment on Query @argumentDefinitions(id: { type: "ID!" }) { 61 | ... { 62 | node(id: $id) { 63 | bar 64 | } 65 | } 66 | } 67 | `) 68 | 69 | expect(errors.length).toBe(0) 70 | }) 71 | 72 | it("Respects default values allowing nullable types in non nullable locations", () => { 73 | const errors = validateDocuments(` 74 | query MyQuery ($ids: [ID!]) { 75 | ... MyFragment @arguments(ids: $ids) 76 | } 77 | fragment MyFragment on Query @argumentDefinitions(ids: { type: "[ID!]!", defaultValue: [] }) { 78 | ... { 79 | nodes(ids: $ids) { 80 | bar 81 | } 82 | } 83 | } 84 | `) 85 | 86 | expect(errors.length).toBe(0) 87 | }) 88 | 89 | it("Respects default values in operation definition allowing nullable types in non nullable locations", () => { 90 | const errors = validateDocuments(` 91 | query MyQueryTest ($ids: [ID!] = []) { 92 | ... MyFragment @arguments(ids: $ids) 93 | } 94 | fragment MyFragment on Query @argumentDefinitions(ids: { type: "[ID!]!" }) { 95 | ... { 96 | nodes(ids: $ids) { 97 | bar 98 | } 99 | } 100 | } 101 | `) 102 | 103 | expect(errors.length).toBe(0) 104 | }) 105 | 106 | it("Does validate standalone fragments", () => { 107 | const errors = validateDocuments(` 108 | fragment MyFragment on Query @argumentDefinitions(id: { type: "ID" }) { 109 | ... { 110 | node(id: $id) { 111 | bar 112 | } 113 | } 114 | } 115 | 116 | fragment MyFragment2 on Query @argumentDefinitions(ids: { type: "[ID]" }) { 117 | ... { 118 | nodes(ids: $ids) { 119 | bar 120 | } 121 | } 122 | } 123 | `) 124 | 125 | expect(errors).toHaveLength(2) 126 | expect(errors).toContainEqual( 127 | expect.objectContaining({ 128 | message: 'Variable "$id" of type "ID" used in position expecting type "ID!".', 129 | }) 130 | ) 131 | expect(errors).toContainEqual( 132 | expect.objectContaining({ 133 | message: 'Variable "$ids" of type "[ID]" used in position expecting type "[ID!]".', 134 | }) 135 | ) 136 | }) 137 | 138 | it("Does validate fragments that are being used", () => { 139 | const errors = validateDocuments(` 140 | query MyQuery { 141 | ... MyFragment @arguments(id: "ID") 142 | ... MyFragment2 143 | } 144 | fragment MyFragment on Query @argumentDefinitions(id: { type: "ID" }) { 145 | ... { 146 | node(id: $id) { 147 | bar 148 | } 149 | } 150 | } 151 | 152 | fragment MyFragment2 on Query @argumentDefinitions(ids: { type: "[ID]" }) { 153 | ... { 154 | nodes(ids: $ids) { 155 | bar 156 | } 157 | } 158 | } 159 | `) 160 | 161 | expect(errors).toHaveLength(2) 162 | expect(errors).toContainEqual( 163 | expect.objectContaining({ 164 | message: 'Variable "$id" of type "ID" used in position expecting type "ID!".', 165 | }) 166 | ) 167 | expect(errors).toContainEqual( 168 | expect.objectContaining({ 169 | message: 'Variable "$ids" of type "[ID]" used in position expecting type "[ID!]".', 170 | }) 171 | ) 172 | }) 173 | 174 | it("Validates @arguments usage", () => { 175 | const errors = validateDocuments(` 176 | query MyQuery($id: ID) { 177 | ... MyFragment @arguments(id: $id) 178 | } 179 | fragment MyFragment on Query @argumentDefinitions(id: { type: "ID!" }) { 180 | ... { 181 | node(id: $id) { 182 | bar 183 | } 184 | } 185 | } 186 | `) 187 | 188 | expect(errors.length).toBe(1) 189 | expect(errors).toContainEqual( 190 | expect.objectContaining({ message: 'Variable "$id" of type "ID" used in position expecting type "ID!".' }) 191 | ) 192 | }) 193 | 194 | it("Validates variables used in fragments defined by operation", () => { 195 | const errors = validateDocuments(` 196 | query MyQuery($id: ID, $shouldInclude: Boolean!) { 197 | ... MyFragment 198 | } 199 | fragment MyFragment on Query { 200 | ... { 201 | node(id: $id) @include(if: $shouldInclude) { 202 | bar 203 | } 204 | } 205 | } 206 | `) 207 | 208 | expect(errors.length).toBe(1) 209 | expect(errors).toContainEqual( 210 | expect.objectContaining({ 211 | message: 'Variable "$id" of type "ID" used in position expecting type "ID!".', 212 | }) 213 | ) 214 | }) 215 | 216 | it("Validates operation defined variables across multiple queries", () => { 217 | const errors = validateDocuments(` 218 | query MyQuery ($id: ID!, $shouldInclude: Boolean!) { 219 | ... MyFragment 220 | } 221 | query MyOtherQuery ($id: ID!, $shouldInclude: Boolean) { 222 | ... MyFragment 223 | } 224 | fragment MyFragment on Query { 225 | ... { 226 | conditionalNode(id: $id, condition: $shouldInclude){ 227 | bar 228 | } 229 | } 230 | } 231 | `) 232 | 233 | expect(errors).toHaveLength(1) 234 | expect(errors).toContainEqual( 235 | expect.objectContaining({ 236 | message: 'Variable "$shouldInclude" of type "Boolean" used in position expecting type "Boolean!".', 237 | }) 238 | ) 239 | }) 240 | 241 | it("Validates operation defined variables in incompatible ways across multiple queries", () => { 242 | const errors = validateDocuments(` 243 | query MyQuery ($id: ID!, $shouldInclude: Boolean!) { 244 | ... MyFragment 245 | } 246 | query MyOtherQuery ($id: ID!, $shouldInclude: Int!) { 247 | ... MyFragment 248 | } 249 | fragment MyFragment on Query { 250 | ... { 251 | conditionalNode(id: $id, condition: $shouldInclude){ 252 | bar 253 | } 254 | } 255 | } 256 | `) 257 | 258 | expect(errors).toHaveLength(1) 259 | expect(errors).toContainEqual( 260 | expect.objectContaining({ 261 | message: 'Variable "$shouldInclude" of type "Int!" used in position expecting type "Boolean!".', 262 | }) 263 | ) 264 | }) 265 | }) 266 | -------------------------------------------------------------------------------- /tests/RelayKnownArgumentNames-test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs" 2 | import { buildSchema, parse, validate } from "graphql" 3 | import { generateDirectivesFile } from "../src/generateDirectivesFile" 4 | import { RelayKnownArgumentNames } from "../src/RelayKnownArgumentNames" 5 | 6 | const schema = buildSchema(` 7 | ${readFileSync(generateDirectivesFile(), "utf8")} 8 | 9 | type Foo { 10 | bar: String 11 | baz: Boolean 12 | } 13 | 14 | type Query { 15 | foo: Foo 16 | } 17 | `) 18 | 19 | function validateDocuments(source: string) { 20 | return validate(schema, parse(source), [RelayKnownArgumentNames]) 21 | } 22 | 23 | describe(RelayKnownArgumentNames, () => { 24 | describe("concerning standard KnownArguments behaviour", () => { 25 | it("validates field arguments", () => { 26 | const errors = validateDocuments(` 27 | fragment FragmentWithFieldArgument on Foo { 28 | bar(az: true) 29 | } 30 | `) 31 | expect(errors).toContainEqual( 32 | expect.objectContaining({ 33 | message: `Unknown argument "az" on field "bar" of type "Foo".`, 34 | }) 35 | ) 36 | }) 37 | 38 | it("validates directive arguments", () => { 39 | const errors = validateDocuments(` 40 | fragment FragmentWithDirectiveArgument on Foo { 41 | ...OtherFragment @relay(ask: true) 42 | } 43 | fragment OtherFragment on Foo { 44 | bar 45 | } 46 | `) 47 | expect(errors).toContainEqual( 48 | expect.objectContaining({ 49 | message: `Unknown argument "ask" on directive "@relay". Did you mean "mask"?`, 50 | }) 51 | ) 52 | }) 53 | }) 54 | 55 | describe("concerning fragment argument definitions", () => { 56 | it("validates that argument definitions get a list", () => { 57 | const errors = validateDocuments(` 58 | fragment FragmentWithoutArguments on Foo @argumentDefinitions { 59 | bar 60 | } 61 | `) 62 | expect(errors).toContainEqual( 63 | expect.objectContaining({ 64 | message: `Missing required argument definitions.`, 65 | }) 66 | ) 67 | }) 68 | 69 | it("validates that argument definitions exist", () => { 70 | const errors = validateDocuments(` 71 | fragment FragmentWithoutArguments on Foo { 72 | bar 73 | } 74 | 75 | fragment FragmentSpreadWithArguments on Foo { 76 | ...FragmentWithoutArguments @arguments(someArgument: "something") 77 | } 78 | `) 79 | expect(errors).toContainEqual( 80 | expect.objectContaining({ 81 | message: `No fragment argument definitions exist for fragment "FragmentWithoutArguments".`, 82 | }) 83 | ) 84 | }) 85 | 86 | it("validates that the argument definition is an object", () => { 87 | const errors = validateDocuments(` 88 | fragment FragmentWithArguments on Foo @argumentDefinitions( 89 | requiredArgument: true 90 | ) { 91 | bar 92 | } 93 | `) 94 | expect(errors).toContainEqual( 95 | expect.objectContaining({ 96 | message: `Metadata of argument definition should be of type "Object" with a "type" and optional "defaultValue" key.`, 97 | }) 98 | ) 99 | }) 100 | 101 | it("validates that a `type` is specified", () => { 102 | const errors = validateDocuments(` 103 | fragment FragmentWithArguments on Foo @argumentDefinitions( 104 | requiredArgument: {} 105 | ) { 106 | bar 107 | } 108 | `) 109 | expect(errors).toContainEqual( 110 | expect.objectContaining({ 111 | message: `Metadata of argument definition should be of type "Object" with a "type" and optional "defaultValue" key.`, 112 | }) 113 | ) 114 | }) 115 | 116 | it("validates that no unknown fields exist in metadata", () => { 117 | const errors = validateDocuments(` 118 | fragment FragmentWithArguments on Foo @argumentDefinitions( 119 | requiredArgument: { type: "String", foo: true } 120 | ) { 121 | bar 122 | } 123 | `) 124 | expect(errors).toContainEqual( 125 | expect.objectContaining({ 126 | message: `Unknown key "foo" in argument definition metadata.`, 127 | }) 128 | ) 129 | }) 130 | 131 | it("validates that the type is specified as a string value", () => { 132 | const errors = validateDocuments(` 133 | fragment FragmentWithArguments on Foo @argumentDefinitions( 134 | argumentWithTypeAsLiteral: { type: Foo } 135 | ) { 136 | bar 137 | } 138 | `) 139 | expect(errors).toContainEqual( 140 | expect.objectContaining({ 141 | message: `Value for "type" in argument definition metadata must be specified as string literal.`, 142 | }) 143 | ) 144 | }) 145 | 146 | it("validates that the defaultValue contains no variables", () => { 147 | const errors = validateDocuments(` 148 | fragment FragmentWithArguments on Foo @argumentDefinitions( 149 | argumentWithTypeAsLiteral: { type: "Foo", defaultValue: [$myVar] } 150 | ) { 151 | bar 152 | } 153 | `) 154 | expect(errors).toContainEqual( 155 | expect.objectContaining({ 156 | message: `Value for "type" in argument definition metadata must be specified as string literal.`, 157 | }) 158 | ) 159 | }) 160 | 161 | it("validates that the type is valid", () => { 162 | const errors = validateDocuments(` 163 | fragment FragmentWithArguments on Foo @argumentDefinitions( 164 | argumentWithTypeAsEnumValue: { type: "Bar" } 165 | argumentWithTypeAsEnumValueList: { type: "[Baz]" } 166 | argumentWithTypeAsNonEnumValue: { type: "10" } 167 | ) { 168 | bar 169 | } 170 | `) 171 | expect(errors).toContainEqual( 172 | expect.objectContaining({ 173 | message: `Unknown type "Bar" in argument definition metadata.`, 174 | }) 175 | ) 176 | expect(errors).toContainEqual( 177 | expect.objectContaining({ 178 | message: `Unknown type "Baz" in argument definition metadata.`, 179 | }) 180 | ) 181 | expect(errors).toContainEqual( 182 | expect.objectContaining({ 183 | message: `Syntax Error: Expected Name, found Int "10"`, 184 | }) 185 | ) 186 | }) 187 | 188 | it.todo("validates that the defaultValue matches type") 189 | }) 190 | 191 | describe("concerning fragment arguments", () => { 192 | it("validates that required arguments exist in list", () => { 193 | const errors = validateDocuments(` 194 | fragment FragmentWithArguments on Foo @argumentDefinitions( 195 | requiredArgument: { type: "String!" } 196 | optionalArgument: { type: "String", defaultValue: "something" } 197 | ) { 198 | bar 199 | } 200 | 201 | fragment FragmentSpreadWithMissingArgument on Foo { 202 | ...FragmentWithArguments @arguments(optionalArgument: "something") 203 | } 204 | `) 205 | expect(errors).toContainEqual( 206 | expect.objectContaining({ message: `Missing required fragment argument "requiredArgument".` }) 207 | ) 208 | }) 209 | 210 | it("considers nullable arguments as optional", () => { 211 | const errors = validateDocuments(` 212 | fragment FragmentWithArguments on Foo @argumentDefinitions( 213 | requiredArgument: { type: "String!" } 214 | optionalArgument: { type: "String" } 215 | ) { 216 | bar 217 | } 218 | 219 | fragment FragmentSpreadWithMissingArgument on Foo { 220 | ...FragmentWithArguments @arguments(requiredArgument: "something") 221 | } 222 | `) 223 | expect(errors).toEqual([]) 224 | }) 225 | 226 | it("validates required arguments when no list is given", () => { 227 | const errors = validateDocuments(` 228 | fragment FragmentWithArguments on Foo @argumentDefinitions( 229 | requiredArgument: { type: "String!" } 230 | ) { 231 | bar 232 | } 233 | 234 | fragment FragmentSpreadWithMissingArgument on Foo { 235 | ...FragmentWithArguments @arguments 236 | } 237 | `) 238 | expect(errors).toContainEqual( 239 | expect.objectContaining({ message: `Missing required fragment argument "requiredArgument".` }) 240 | ) 241 | }) 242 | 243 | it.only("validates required arguments when no @arguments directive is used", () => { 244 | const errors = validateDocuments(` 245 | fragment FragmentWithArguments on Foo @argumentDefinitions( 246 | requiredArgument: { type: "String!" } 247 | ) { 248 | bar 249 | } 250 | 251 | fragment FragmentSpreadWithMissingArgument on Foo { 252 | ...FragmentWithArguments 253 | } 254 | `) 255 | expect(errors).toContainEqual( 256 | expect.objectContaining({ message: `Missing required fragment argument "requiredArgument".` }) 257 | ) 258 | }) 259 | 260 | it("suggests alternatives when argument is unknown", () => { 261 | const errors = validateDocuments(` 262 | fragment FragmentWithArguments on Foo @argumentDefinitions( 263 | requiredArgument: { type: "String!" } 264 | ) { 265 | bar 266 | } 267 | 268 | fragment FragmentSpreadWithMissingArgument on Foo { 269 | ...FragmentWithArguments @arguments(equiredArgument: "whoops") 270 | } 271 | `) 272 | expect(errors).toContainEqual( 273 | expect.objectContaining({ 274 | message: `Unknown fragment argument "equiredArgument". Did you mean "requiredArgument"?`, 275 | }) 276 | ) 277 | }) 278 | }) 279 | }) 280 | -------------------------------------------------------------------------------- /tests/RelayCompatRequiredPageInfoFields-test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs" 2 | import { buildSchema, DocumentNode, parse, validate } from "graphql" 3 | import { generateDirectivesFile } from "../src/generateDirectivesFile" 4 | import { 5 | connectionSelectionSetPaginationInfo, 6 | RelayCompatRequiredPageInfoFields, 7 | } from "../src/RelayCompatRequiredPageInfoFields" 8 | 9 | const schema = buildSchema(` 10 | ${readFileSync(generateDirectivesFile(), "utf8")} 11 | 12 | type Foo { 13 | bar: String 14 | baz: Boolean 15 | } 16 | 17 | type FooEdge { 18 | node: Foo 19 | cursor: String! 20 | } 21 | 22 | type FooConnection { 23 | edges: [FooEdge!]! 24 | pageInfo: PageInfo! 25 | } 26 | 27 | type PageInfo { 28 | hasPreviousPage: Boolean! 29 | hasNextPage: Boolean! 30 | endCursor: String 31 | startCursor: String 32 | } 33 | 34 | type Query { 35 | fooConnection(first: Int last: Int after: String before: String): FooConnection 36 | } 37 | `) 38 | 39 | function validateDocuments(source: string) { 40 | return validate(schema, parse(source), [RelayCompatRequiredPageInfoFields]) 41 | } 42 | 43 | describe(RelayCompatRequiredPageInfoFields, () => { 44 | describe(connectionSelectionSetPaginationInfo, () => { 45 | const pageInfoStartCursor = parse(`fragment PageInfoStartCursor on PageInfo { 46 | startCursor 47 | }`) 48 | const pageInfoEndCursor = parse(`fragment PageInfoEndCursor on PageInfo { 49 | endCursor 50 | }`) 51 | const pageInfoHasNextPage = parse(`fragment PageInfoHasNextPage on PageInfo { 52 | hasNextPage 53 | }`) 54 | const pageInfoHasPreviousPage = parse(`fragment PageInfoHasPreviousPage on PageInfo { 55 | hasPreviousPage 56 | }`) 57 | 58 | const connectionStartCursor = parse(`fragment ConnectionStartCursor on FooConnection { 59 | pageInfo { 60 | startCursor 61 | } 62 | }`) 63 | const connectionEndCursor = parse(`fragment ConnectionEndCursor on FooConnection { 64 | pageInfo { 65 | endCursor 66 | } 67 | }`) 68 | const connectionHasNextPage = parse(`fragment ConnectionHasNextPage on FooConnection { 69 | pageInfo { 70 | hasNextPage 71 | } 72 | }`) 73 | const connectionHasPreviousPage = parse(`fragment ConnectionHasPreviousPage on FooConnection { 74 | pageInfo { 75 | hasPreviousPage 76 | } 77 | }`) 78 | 79 | const fragmentMap: { [key: string]: DocumentNode } = { 80 | PageInfoStartCursor: pageInfoStartCursor, 81 | PageInfoEndCursor: pageInfoEndCursor, 82 | PageInfoHasNextPage: pageInfoHasNextPage, 83 | PageInfoHasPreviousPage: pageInfoHasPreviousPage, 84 | ConnectionStartCursor: connectionStartCursor, 85 | ConnectionEndCursor: connectionEndCursor, 86 | ConnectionHasNextPage: connectionHasNextPage, 87 | ConnectionHasPreviousPage: connectionHasPreviousPage, 88 | } 89 | 90 | const getFragment = (name: string) => { 91 | const doc = fragmentMap[name] 92 | if (doc == null) { 93 | return 94 | } 95 | const fragmentDef = doc.definitions[0] 96 | if (fragmentDef.kind === "FragmentDefinition") { 97 | return fragmentDef 98 | } 99 | throw new Error("Unexpected kind: " + fragmentDef.kind) 100 | } 101 | 102 | const getSelectionSetFromFragment = (source: string) => { 103 | const doc = parse(source) 104 | 105 | const def = doc.definitions[0] 106 | if (def.kind !== "FragmentDefinition") { 107 | throw new Error("Unexpected kind: " + def.kind) 108 | } 109 | 110 | return def.selectionSet 111 | } 112 | 113 | it("Finds no fields on selection with no fields", () => { 114 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 115 | __typename 116 | }`) 117 | 118 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 119 | hasNextPage: false, 120 | hasPreviousPage: false, 121 | endCursor: false, 122 | startCursor: false, 123 | }) 124 | }) 125 | 126 | describe("Direct selections", () => { 127 | it("Finds startCursor on selection with startCursor", () => { 128 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 129 | pageInfo { 130 | startCursor 131 | } 132 | }`) 133 | 134 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 135 | hasNextPage: false, 136 | hasPreviousPage: false, 137 | endCursor: false, 138 | startCursor: true, 139 | }) 140 | }) 141 | it("Finds endCursor on selection with endCursor", () => { 142 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 143 | pageInfo { 144 | endCursor 145 | } 146 | }`) 147 | 148 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 149 | hasNextPage: false, 150 | hasPreviousPage: false, 151 | endCursor: true, 152 | startCursor: false, 153 | }) 154 | }) 155 | it("Finds hasNextPage on selection with hasNextPage", () => { 156 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 157 | pageInfo { 158 | hasNextPage 159 | } 160 | }`) 161 | 162 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 163 | hasNextPage: true, 164 | hasPreviousPage: false, 165 | endCursor: false, 166 | startCursor: false, 167 | }) 168 | }) 169 | it("Finds hasPreviousPage on selection with hasPreviousPage", () => { 170 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 171 | pageInfo { 172 | hasPreviousPage 173 | } 174 | }`) 175 | 176 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 177 | hasNextPage: false, 178 | hasPreviousPage: true, 179 | endCursor: false, 180 | startCursor: false, 181 | }) 182 | }) 183 | }) 184 | describe("Fragments spread on connection", () => { 185 | it("Finds startCursor on selection with startCursor", () => { 186 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 187 | ... ConnectionStartCursor 188 | }`) 189 | 190 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 191 | hasNextPage: false, 192 | hasPreviousPage: false, 193 | endCursor: false, 194 | startCursor: true, 195 | }) 196 | }) 197 | it("Finds endCursor on selection with endCursor", () => { 198 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 199 | ... ConnectionEndCursor 200 | }`) 201 | 202 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 203 | hasNextPage: false, 204 | hasPreviousPage: false, 205 | endCursor: true, 206 | startCursor: false, 207 | }) 208 | }) 209 | it("Finds hasNextPage on selection with hasNextPage", () => { 210 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 211 | ... ConnectionHasNextPage 212 | }`) 213 | 214 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 215 | hasNextPage: true, 216 | hasPreviousPage: false, 217 | endCursor: false, 218 | startCursor: false, 219 | }) 220 | }) 221 | it("Finds hasPreviousPage on selection with hasPreviousPage", () => { 222 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 223 | ... ConnectionHasPreviousPage 224 | }`) 225 | 226 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 227 | hasNextPage: false, 228 | hasPreviousPage: true, 229 | endCursor: false, 230 | startCursor: false, 231 | }) 232 | }) 233 | }) 234 | describe("Fragments spread on pageInfo", () => { 235 | it("Finds startCursor on selection with startCursor", () => { 236 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 237 | pageInfo { 238 | ... PageInfoStartCursor 239 | } 240 | }`) 241 | 242 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 243 | hasNextPage: false, 244 | hasPreviousPage: false, 245 | endCursor: false, 246 | startCursor: true, 247 | }) 248 | }) 249 | it("Finds endCursor on selection with endCursor", () => { 250 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 251 | pageInfo { 252 | ... PageInfoEndCursor 253 | } 254 | }`) 255 | 256 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 257 | hasNextPage: false, 258 | hasPreviousPage: false, 259 | endCursor: true, 260 | startCursor: false, 261 | }) 262 | }) 263 | it("Finds hasNextPage on selection with hasNextPage", () => { 264 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 265 | pageInfo { 266 | ... PageInfoHasNextPage 267 | } 268 | }`) 269 | 270 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 271 | hasNextPage: true, 272 | hasPreviousPage: false, 273 | endCursor: false, 274 | startCursor: false, 275 | }) 276 | }) 277 | it("Finds hasPreviousPage on selection with hasPreviousPage", () => { 278 | const fragment = getSelectionSetFromFragment(`fragment TestFragment on FooConnection { 279 | pageInfo { 280 | ... PageInfoHasPreviousPage 281 | } 282 | }`) 283 | 284 | expect(connectionSelectionSetPaginationInfo(getFragment, fragment)).toEqual({ 285 | hasNextPage: false, 286 | hasPreviousPage: true, 287 | endCursor: false, 288 | startCursor: false, 289 | }) 290 | }) 291 | }) 292 | }) 293 | it("Validates connection with no pageInfo specified", () => { 294 | const errors = validateDocuments(` 295 | query MyQuery($id: ID!) { 296 | fooConnection(first: 10, after: null) @connection(key: "MyQuery_fooConnection") { 297 | edges { 298 | cursor 299 | } 300 | } 301 | } 302 | `) 303 | 304 | expect(errors).toHaveLength(2) 305 | expect(errors).toContainEqual( 306 | expect.objectContaining({ message: 'Missing pageInfo.hasNextPage field on connection "MyQuery_fooConnection".' }) 307 | ) 308 | expect(errors).toContainEqual( 309 | expect.objectContaining({ message: 'Missing pageInfo.endCursor field on connection "MyQuery_fooConnection".' }) 310 | ) 311 | }) 312 | it("Allows valid forward connection ", () => { 313 | const errors = validateDocuments(` 314 | query MyQuery($id: ID!) { 315 | fooConnection(first: 10, after: null) @connection(key: "MyQuery_fooConnection") { 316 | pageInfo { 317 | endCursor 318 | hasNextPage 319 | } 320 | } 321 | } 322 | `) 323 | 324 | expect(errors).toHaveLength(0) 325 | }) 326 | it("Disallows forward with backward connection pageInfo connection ", () => { 327 | const errors = validateDocuments(` 328 | query MyQuery($id: ID!) { 329 | fooConnection(first: 10, after: null) @connection(key: "MyQuery_fooConnection") { 330 | pageInfo { 331 | startCursor 332 | hasPreviousPage 333 | } 334 | } 335 | } 336 | `) 337 | 338 | expect(errors).toHaveLength(2) 339 | expect(errors).toContainEqual( 340 | expect.objectContaining({ message: 'Missing pageInfo.hasNextPage field on connection "MyQuery_fooConnection".' }) 341 | ) 342 | expect(errors).toContainEqual( 343 | expect.objectContaining({ message: 'Missing pageInfo.endCursor field on connection "MyQuery_fooConnection".' }) 344 | ) 345 | }) 346 | it("Allows valid backward connection ", () => { 347 | const errors = validateDocuments(` 348 | query MyQuery($id: ID!) { 349 | fooConnection(last: 10, before: null) @connection(key: "MyQuery_fooConnection") { 350 | pageInfo { 351 | startCursor 352 | hasPreviousPage 353 | } 354 | } 355 | } 356 | `) 357 | 358 | expect(errors).toHaveLength(0) 359 | }) 360 | it("Disallows backward with forward connection pageInfo connection ", () => { 361 | const errors = validateDocuments(` 362 | query MyQuery($id: ID!) { 363 | fooConnection(last: 10, before: null) @connection(key: "MyQuery_fooConnection") { 364 | pageInfo { 365 | endCursor 366 | hasNextPage 367 | } 368 | } 369 | } 370 | `) 371 | 372 | expect(errors).toHaveLength(2) 373 | expect(errors).toContainEqual( 374 | expect.objectContaining({ 375 | message: 'Missing pageInfo.hasPreviousPage field on connection "MyQuery_fooConnection".', 376 | }) 377 | ) 378 | expect(errors).toContainEqual( 379 | expect.objectContaining({ message: 'Missing pageInfo.startCursor field on connection "MyQuery_fooConnection".' }) 380 | ) 381 | }) 382 | }) 383 | --------------------------------------------------------------------------------