diff --git a/.changeset/poor-eagles-drop.md b/.changeset/poor-eagles-drop.md new file mode 100644 index 0000000000..75430107ff --- /dev/null +++ b/.changeset/poor-eagles-drop.md @@ -0,0 +1,5 @@ +--- +'@envelop/extended-validation': patch +--- + +NEW PLUGIN! diff --git a/.changeset/purple-bugs-build.md b/.changeset/purple-bugs-build.md new file mode 100644 index 0000000000..0d5c897864 --- /dev/null +++ b/.changeset/purple-bugs-build.md @@ -0,0 +1,6 @@ +--- +'@envelop/core': patch +'@envelop/types': patch +--- + +Allow plugins to stop execution and return errors diff --git a/README.md b/README.md index 6e751cc523..26877b6aae 100644 --- a/README.md +++ b/README.md @@ -109,29 +109,30 @@ Here's a list of integrations and examples: We provide a few built-in plugins within the `@envelop/core`, and many more plugins as standalone packages. -| Name | Package | Description | -| -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| useSchema | [`@envelop/core`](./packages/core#useschema) | Simplest plugin to provide your GraphQL schema. | -| useErrorHandler | [`@envelop/core`](./packages/core#useerrorhandler) | Get notified when any execution error occurs. | -| useExtendContext | [`@envelop/core`](./packages/core#useextendcontext) | Extend execution context based on your needs. | -| useLogger | [`@envelop/core`](./packages/core#uselogger) | Simple, yet powerful logging for GraphQL execution. | -| usePayloadFormatter | [`@envelop/core`](./packages/core#usepayloadformatter) | Format, clean and customize execution result. | -| useTiming | [`@envelop/core`](./packages/core#usetiming) | Simple timing/tracing mechanism for your execution. | -| useGraphQLJit | [`@envelop/graphql-jit`](./packages/plugins/graphql-jit) | Custom executor based on GraphQL-JIT. | -| useParserCache | [`@envelop/parser-cache`](./packages/plugins/parser-cache) | Simple LRU for caching `parse` results. | -| useValidationCache | [`@envelop/validation-cache`](./packages/plugins/validation-cache) | Simple LRU for caching `validate` results. | -| useDepthLimit | [`@envelop/depth-limit`](./packages/plugins/depth-limit) | Limits the depth of your GraphQL selection sets. | -| useDataLoader | [`@envelop/dataloader`](./packages/plugins/dataloader) | Simply injects a DataLoader instance into your context. | -| useApolloTracing | [`@envelop/apollo-tracing`](./packages/plugins/apollo-tracing) | Integrates timing with Apollo-Tracing format (for GraphQL Playground) | -| useSentry | [`@envelop/sentry`](./packages/plugins/sentry) | Tracks performance, timing and errors and reports it to Sentry. | -| useOpenTelemetry | [`@envelop/opentelemetry`](./packages/plugins/opentelemetry) | Tracks performance, timing and errors and reports in OpenTelemetry structure. | -| useGenericAuth | [`@envelop/generic-auth`](./packages/plugins/generic-auth) | Super flexible authentication, also supports `@auth` directive . | -| useAuth0 | [`@envelop/auth0`](./packages/plugins/auth0) | Validates Auth0 JWT tokens and injects the authenticated user to your context. | -| useGraphQLModules | [`@envelop/graphql-modules`](./packages/plugins/graphql-modules) | Integrates the execution lifecycle of GraphQL-Modules. | -| useGraphQLMiddleware | [`@envelop/graphql-middleware`](./packages/plugins/graphql-middleware) | Integrates middlewares written for `graphql-middleware` | -| useRateLimiter | [`@envelop/rate-limiter`](./packages/plugins/rate-limiter) | Limit request rate via `@rateLimit` directive | -| useDisableIntrospection | [`@envelop/disable-introspection`](./packages/plugins/disable-introspection) | Disables introspection by adding a validation rule | -| useFilterAllowedOperations | [`@envelop/filter-operation-type`](./packages/plugins/filter-operation-type) | Only allow execution of specific operation types | +| Name | Package | Description | +| -------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| useSchema | [`@envelop/core`](./packages/core#useschema) | Simplest plugin to provide your GraphQL schema. | +| useErrorHandler | [`@envelop/core`](./packages/core#useerrorhandler) | Get notified when any execution error occurs. | +| useExtendContext | [`@envelop/core`](./packages/core#useextendcontext) | Extend execution context based on your needs. | +| useLogger | [`@envelop/core`](./packages/core#uselogger) | Simple, yet powerful logging for GraphQL execution. | +| usePayloadFormatter | [`@envelop/core`](./packages/core#usepayloadformatter) | Format, clean and customize execution result. | +| useTiming | [`@envelop/core`](./packages/core#usetiming) | Simple timing/tracing mechanism for your execution. | +| useGraphQLJit | [`@envelop/graphql-jit`](./packages/plugins/graphql-jit) | Custom executor based on GraphQL-JIT. | +| useParserCache | [`@envelop/parser-cache`](./packages/plugins/parser-cache) | Simple LRU for caching `parse` results. | +| useValidationCache | [`@envelop/validation-cache`](./packages/plugins/validation-cache) | Simple LRU for caching `validate` results. | +| useDepthLimit | [`@envelop/depth-limit`](./packages/plugins/depth-limit) | Limits the depth of your GraphQL selection sets. | +| useDataLoader | [`@envelop/dataloader`](./packages/plugins/dataloader) | Simply injects a DataLoader instance into your context. | +| useApolloTracing | [`@envelop/apollo-tracing`](./packages/plugins/apollo-tracing) | Integrates timing with Apollo-Tracing format (for GraphQL Playground) | +| useSentry | [`@envelop/sentry`](./packages/plugins/sentry) | Tracks performance, timing and errors and reports it to Sentry. | +| useOpenTelemetry | [`@envelop/opentelemetry`](./packages/plugins/opentelemetry) | Tracks performance, timing and errors and reports in OpenTelemetry structure. | +| useGenericAuth | [`@envelop/generic-auth`](./packages/plugins/generic-auth) | Super flexible authentication, also supports `@auth` directive . | +| useAuth0 | [`@envelop/auth0`](./packages/plugins/auth0) | Validates Auth0 JWT tokens and injects the authenticated user to your context. | +| useGraphQLModules | [`@envelop/graphql-modules`](./packages/plugins/graphql-modules) | Integrates the execution lifecycle of GraphQL-Modules. | +| useGraphQLMiddleware | [`@envelop/graphql-middleware`](./packages/plugins/graphql-middleware) | Integrates middlewares written for `graphql-middleware` | +| useRateLimiter | [`@envelop/rate-limiter`](./packages/plugins/rate-limiter) | Limit request rate via `@rateLimit` directive | +| useDisableIntrospection | [`@envelop/disable-introspection`](./packages/plugins/disable-introspection) | Disables introspection by adding a validation rule | +| useFilterAllowedOperations | [`@envelop/filter-operation-type`](./packages/plugins/filter-operation-type) | Only allow execution of specific operation types | +| useExtendedValidation | [`@envelop/extended-validation`](./packages/plugins/extended-validation) | Adds custom validations to the execution pipeline, with access to variables. Comes with an implementation for `@oneOf` directibe for input union. | ## Sharing `envelop`s diff --git a/packages/core/src/create.ts b/packages/core/src/create.ts index 26415ff28a..58fa9ee559 100644 --- a/packages/core/src/create.ts +++ b/packages/core/src/create.ts @@ -297,16 +297,23 @@ export function envelop(options: { plugins: Plugin[]; extends?: Envelop[]; initi const onResolversHandlers: OnResolverCalledHooks[] = []; let executeFn: typeof execute = execute; + let result: ExecutionResult; const afterCalls: ((options: { result: ExecutionResult; setResult: (newResult: ExecutionResult) => void }) => void)[] = []; let context = args.contextValue; for (const plugin of beforeExecuteCalls) { + let stopCalled = false; + const after = plugin.onExecute({ executeFn, setExecuteFn: newExecuteFn => { executeFn = newExecuteFn; }, + setResultAndStopExecution: stopResult => { + stopCalled = true; + result = stopResult; + }, extendContext: extension => { if (typeof extension === 'object') { context = { @@ -322,6 +329,10 @@ export function envelop(options: { plugins: Plugin[]; extends?: Envelop[]; initi args, }); + if (stopCalled) { + return result; + } + if (after) { if (after.onExecuteDone) { afterCalls.push(after.onExecuteDone); @@ -336,7 +347,7 @@ export function envelop(options: { plugins: Plugin[]; extends?: Envelop[]; initi context[resolversHooksSymbol] = onResolversHandlers; } - let result = await executeFn({ + result = await executeFn({ ...args, contextValue: context, }); diff --git a/packages/core/test/context.spec.ts b/packages/core/test/context.spec.ts index 128f558e96..847d2ebe56 100644 --- a/packages/core/test/context.spec.ts +++ b/packages/core/test/context.spec.ts @@ -22,7 +22,7 @@ describe('contextFactory', () => { it('Should set initial `createProxy` arguments as initial context', async () => { const spiedPlugin = createSpiedPlugin(); const teskit = createTestkit([spiedPlugin.plugin], schema); - await teskit.execute(query, { test: true }); + await teskit.execute(query, {}, { test: true }); expect(spiedPlugin.spies.beforeContextBuilding).toHaveBeenCalledTimes(1); expect(spiedPlugin.spies.beforeContextBuilding).toHaveBeenCalledWith({ context: expect.objectContaining({ @@ -52,7 +52,7 @@ describe('contextFactory', () => { schema ); - await teskit.execute(query, {}); + await teskit.execute(query, {}, {}); expect(afterContextSpy).toHaveBeenCalledWith({ context: { test: true, @@ -106,12 +106,14 @@ describe('contextFactory', () => { ], schema ); - await teskit.execute(query, {}); - expect(afterContextSpy).toHaveBeenCalledWith({ - context: { - test: true, - }, - }); + await teskit.execute(query, {}, {}); + expect(afterContextSpy).toHaveBeenCalledWith( + expect.objectContaining({ + context: { + test: true, + }, + }) + ); expect(onExecuteSpy).toHaveBeenCalledWith( expect.objectContaining({ args: expect.objectContaining({ diff --git a/packages/core/test/execute.spec.ts b/packages/core/test/execute.spec.ts index 8e9155e9f7..f6b78822d4 100644 --- a/packages/core/test/execute.spec.ts +++ b/packages/core/test/execute.spec.ts @@ -6,13 +6,14 @@ describe('execute', () => { it('Should wrap and trigger events correctly', async () => { const spiedPlugin = createSpiedPlugin(); const teskit = createTestkit([spiedPlugin.plugin], schema); - await teskit.execute(query, { test: 1 }); + await teskit.execute(query, {}, { test: 1 }); expect(spiedPlugin.spies.beforeExecute).toHaveBeenCalledTimes(1); expect(spiedPlugin.spies.beforeResolver).toHaveBeenCalledTimes(3); expect(spiedPlugin.spies.beforeExecute).toHaveBeenCalledWith({ executeFn: expect.any(Function), setExecuteFn: expect.any(Function), extendContext: expect.any(Function), + setResultAndStopExecution: expect.any(Function), args: { contextValue: expect.objectContaining({ test: 1 }), rootValue: {}, @@ -20,7 +21,7 @@ describe('execute', () => { operationName: undefined, fieldResolver: undefined, typeResolver: undefined, - variableValues: undefined, + variableValues: {}, document: expect.objectContaining({ definitions: expect.any(Array), }), @@ -58,7 +59,7 @@ describe('execute', () => { expect(altExecute).toHaveBeenCalledTimes(1); }); - it.skip('Should allow to write async functions for before execute', async () => { + it('Should allow to write async functions for before execute', async () => { const altExecute = jest.fn(execute); const teskit = createTestkit( [ diff --git a/packages/plugins/extended-validation/README.md b/packages/plugins/extended-validation/README.md new file mode 100644 index 0000000000..5f7cddc877 --- /dev/null +++ b/packages/plugins/extended-validation/README.md @@ -0,0 +1,89 @@ +## `@envelop/extended-validation` + +Extended validation plugin adds support for writing GraphQL validation rules, that has access to all `execute` parameters, including variables. + +While GraphQL supports fair amount of built-in validations, and validations could be extended, it's doesn't expose `variables` to the validation rules, since operation variables are not available during `validate` flow (it's only available through execution of the operation, after input/variables coercion is done). + +This plugin runs before `validate` but allow developers to write their validation rules in the same way GraphQL `ValidationRule` is defined (based on a GraphQL visitor). + +## Getting Started + +Start by installing the plugin: + +``` +yarn add @envelop/extended-validation +``` + +Then, use the plugin with your validation rules: + +```ts +import { useExtendedValidation } from '@envelop/extended-validation'; + +const getEnveloped = evelop({ + plugins: [ + useExtendedValidation({ + rules: [ ... ] // your rules here + }) + ] +}) +``` + +To create your custom rules, implement the `ExtendedValidationRule` interface and return your GraphQL AST visitor. + +For example: + +```ts +import { ExtendedValidationRule } from '@envelop/extended-validation'; + +export const MyRule: ExtendedValidationRule = (validationContext, executionArgs) => { + return { + OperationDefinition: node => { + // This will run for every executed Query/Mutation/Subscription + // And now you also have access to the execution params like variables, context and so on. + // If you wish to report an error, use validationContext.reportError or throw an exception. + }, + }; +}; +``` + +## Built-in Rules + +### Union Inputs: `@oneOf` + +This directive provides validation for input types and implements the concept of union inputs. You can find the [complete spec RFC here](https://github.com/graphql/graphql-spec/pull/825). + +To use that validation rule, make sure to include the following directive in your schema: + +```graphql +directive @oneOf on INPUT_OBJECT | FIELD_DEFINITION +``` + +Then, apply it to field definitions, or to a complete `input` type: + +```graphql +## Apply to entire input type +input FindUserInput @oneOf { + id: ID + organizationAndRegistrationNumber: OrganizationAndRegistrationNumberInput +} + +## Or, apply to a set of input arguments + +type Query { + foo(id: ID, str1: String, str2: String): String @oneOf +} +``` + +Then, make sure to add that rule to your plugin usage: + +```ts +import { useExtendedValidation, OneOfInputObjectsRule } from '@envelop/extended-validation'; + +const getEnveloped = evelop({ + plugins: [ + useExtendedValidation({ + rules: [OneOfInputObjectsRule], + }), + ], +}); +``` diff --git a/packages/plugins/extended-validation/package.json b/packages/plugins/extended-validation/package.json new file mode 100644 index 0000000000..085ca809db --- /dev/null +++ b/packages/plugins/extended-validation/package.json @@ -0,0 +1,38 @@ +{ + "name": "@envelop/extended-validation", + "version": "0.0.0", + "author": "Dotan Simha ", + "license": "MIT", + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/dotansimha/envelop.git", + "directory": "packages/plugins/extended-validation" + }, + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "typings": "dist/index.d.ts", + "typescript": { + "definition": "dist/index.d.ts" + }, + "scripts": { + "test": "jest", + "prepack": "bob prepack" + }, + "dependencies": {}, + "devDependencies": { + "bob-the-bundler": "1.2.0", + "graphql": "15.5.0", + "typescript": "4.2.4" + }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0" + }, + "buildOptions": { + "input": "./src/index.ts" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + } +} diff --git a/packages/plugins/extended-validation/src/common.ts b/packages/plugins/extended-validation/src/common.ts new file mode 100644 index 0000000000..141940d894 --- /dev/null +++ b/packages/plugins/extended-validation/src/common.ts @@ -0,0 +1,35 @@ +import { + ASTVisitor, + DirectiveNode, + ExecutionArgs, + GraphQLNamedType, + GraphQLType, + isListType, + isNonNullType, + ValidationContext, +} from 'graphql'; + +export type ExtendedValidationRule = (context: ValidationContext, executionArgs: ExecutionArgs) => ASTVisitor; + +export function getDirectiveFromAstNode( + astNode: { directives?: ReadonlyArray }, + names: string | string[] +): null | DirectiveNode { + if (!astNode) { + return null; + } + + const directives = astNode.directives || []; + const namesArr = Array.isArray(names) ? names : [names]; + const authDirective = directives.find(d => namesArr.includes(d.name.value)); + + return authDirective || null; +} + +export function unwrapType(type: GraphQLType): GraphQLNamedType { + if (isNonNullType(type) || isListType(type)) { + return unwrapType(type.ofType); + } + + return type; +} diff --git a/packages/plugins/extended-validation/src/index.ts b/packages/plugins/extended-validation/src/index.ts new file mode 100644 index 0000000000..64b7917049 --- /dev/null +++ b/packages/plugins/extended-validation/src/index.ts @@ -0,0 +1,4 @@ +export * from './plugin'; +export * from './common'; + +export * from './rules/one-of'; diff --git a/packages/plugins/extended-validation/src/plugin.ts b/packages/plugins/extended-validation/src/plugin.ts new file mode 100644 index 0000000000..27c1077fd2 --- /dev/null +++ b/packages/plugins/extended-validation/src/plugin.ts @@ -0,0 +1,34 @@ +import { Plugin } from '@envelop/types'; +import { GraphQLError, TypeInfo, ValidationContext, visit, visitInParallel, visitWithTypeInfo } from 'graphql'; +import { ExtendedValidationRule } from './common'; + +export const useExtendedValidation = (options: { rules: ExtendedValidationRule[] }): Plugin => { + let schemaTypeInfo: TypeInfo; + + return { + onSchemaChange({ schema }) { + schemaTypeInfo = new TypeInfo(schema); + }, + onExecute({ args, setResultAndStopExecution }) { + const errors: GraphQLError[] = []; + const typeInfo = schemaTypeInfo || new TypeInfo(args.schema); + const validationContext = new ValidationContext(args.schema, args.document, typeInfo, e => { + errors.push(e); + }); + + const visitor = visitInParallel(options.rules.map(rule => rule(validationContext, args))); + visit(args.document, visitWithTypeInfo(typeInfo, visitor)); + + for (const rule of options.rules) { + rule(validationContext, args); + } + + if (errors.length > 0) { + setResultAndStopExecution({ + data: null, + errors, + }); + } + }, + }; +}; diff --git a/packages/plugins/extended-validation/src/rules/one-of.ts b/packages/plugins/extended-validation/src/rules/one-of.ts new file mode 100644 index 0000000000..18ab5b3adf --- /dev/null +++ b/packages/plugins/extended-validation/src/rules/one-of.ts @@ -0,0 +1,52 @@ +import { GraphQLError } from 'graphql'; +import { getArgumentValues } from 'graphql/execution/values'; +import { ExtendedValidationRule, getDirectiveFromAstNode, unwrapType } from '../common'; + +export const ONE_OF_DIRECTIVE_SDL = /* GraphQL */ ` + directive @oneOf on INPUT_OBJECT | FIELD_DEFINITION +`; + +export const OneOfInputObjectsRule: ExtendedValidationRule = (validationContext, executionArgs) => { + return { + Field: node => { + if (node.arguments?.length) { + const fieldType = validationContext.getFieldDef(); + const values = getArgumentValues(fieldType, node, executionArgs.variableValues); + + if (fieldType) { + const fieldTypeDirective = getDirectiveFromAstNode(fieldType.astNode, 'oneOf'); + + if (fieldTypeDirective) { + if (Object.keys(values).length !== 1) { + validationContext.reportError( + new GraphQLError( + `Exactly one key must be specified for input for field "${fieldType.type.toString()}.${node.name.value}"`, + [node] + ) + ); + } + } + } + + for (const arg of node.arguments) { + const argType = fieldType.args.find(typeArg => typeArg.name === arg.name.value); + + if (argType) { + const inputType = unwrapType(argType.type); + const inputTypeDirective = getDirectiveFromAstNode(inputType.astNode, 'oneOf'); + + if (inputTypeDirective) { + const argValue = values[arg.name.value] || {}; + + if (Object.keys(argValue).length !== 1) { + validationContext.reportError( + new GraphQLError(`Exactly one key must be specified for input type "${inputType.name}"`, [arg]) + ); + } + } + } + } + } + }, + }; +}; diff --git a/packages/plugins/extended-validation/test/one-of.spec.ts b/packages/plugins/extended-validation/test/one-of.spec.ts new file mode 100644 index 0000000000..7e5897b30b --- /dev/null +++ b/packages/plugins/extended-validation/test/one-of.spec.ts @@ -0,0 +1,255 @@ +import { buildSchema } from 'graphql'; +import { createTestkit } from '@envelop/testing'; +import { useExtendedValidation, ONE_OF_DIRECTIVE_SDL, OneOfInputObjectsRule } from '../src'; + +describe('oneOf', () => { + const testSchema = buildSchema(/* GraphQL */ ` + ${ONE_OF_DIRECTIVE_SDL} + + type Query { + user(input: UserUniqueCondition): User + findUser(byID: ID, byUsername: String, byEmail: String, byRegistrationNumber: Int): User @oneOf + } + + type User { + id: ID! + } + + input UserUniqueCondition @oneOf { + id: ID + username: String + } + `); + + describe('INPUT_OBJECT', () => { + const DOCUMENT_WITH_WHOLE_INPUT = /* GraphQL */ ` + query user($input: UserUniqueCondition!) { + user(input: $input) { + id + } + } + `; + + it.each([ + [ + 'Valid: Exactly one key is specified through literal', + { + document: `query user { user(input: { id: 1 }) { id }}`, + variables: {}, + expectedError: null, + }, + ], + [ + 'Valid: Exactly one key is specified through variables', + { + document: DOCUMENT_WITH_WHOLE_INPUT, + variables: { + input: { + id: 1, + }, + }, + expectedError: null, + }, + ], + [ + 'Valid: Mixed variables resolved into a single value', + { + document: `query user($username: String) { user(input: { id: 1, username: $username }) { id }}`, + variables: {}, + expectedError: null, + }, + ], + [ + 'Valid: Mixed variables resolved into a single value - separate variables', + { + document: `query user($id: ID, $username: String) { user(input: { id: $id, username: $username }) { id }}`, + variables: { + id: 1, + }, + expectedError: null, + }, + ], + [ + 'Invalid: Mixed variables leading to multiple values', + { + document: `query user($username: String) { user(input: { id: 1, username: $username }) { id }}`, + variables: { + username: 'test', + }, + expectedError: 'Exactly one key must be specified for input type "UserUniqueCondition"', + }, + ], + [ + 'Invalid: More than one value specified through literals', + { + document: `query user { user(input: { id: 1, username: "t" }) { id }}`, + variables: {}, + expectedError: 'Exactly one key must be specified for input type "UserUniqueCondition"', + }, + ], + [ + 'Invalid: More than one value specified through literals with null value', + { + document: `query user { user(input: { id: null, username: "t" }) { id }}`, + variables: {}, + expectedError: 'Exactly one key must be specified for input type "UserUniqueCondition"', + }, + ], + [ + 'Invalid: All values specified explicity with null values', + { + document: `query user { user(input: { id: null, username: null }) { id }}`, + variables: {}, + expectedError: 'Exactly one key must be specified for input type "UserUniqueCondition"', + }, + ], + [ + `Invalid: When variables are empty`, + { + document: DOCUMENT_WITH_WHOLE_INPUT, + variables: {}, + expectedError: 'Exactly one key must be specified for input type "UserUniqueCondition"', + }, + ], + [ + `Invalid: When specific variable is empty and provided as input type variable`, + { + document: DOCUMENT_WITH_WHOLE_INPUT, + variables: { + input: {}, + }, + expectedError: 'Exactly one key must be specified for input type "UserUniqueCondition"', + }, + ], + [ + 'Invalid: More than one value is specific', + { + document: DOCUMENT_WITH_WHOLE_INPUT, + variables: { + input: { + id: 1, + username: 'test', + }, + }, + expectedError: 'Exactly one key must be specified for input type "UserUniqueCondition"', + }, + ], + ])('%s', async (title, { document, variables, expectedError }) => { + const testInstance = createTestkit( + [ + useExtendedValidation({ + rules: [OneOfInputObjectsRule], + }), + ], + testSchema + ); + + const result = await testInstance.execute(document, variables); + + if (expectedError) { + expect(result.errors).toBeDefined(); + expect(result.errors.length).toBe(1); + expect(result.errors[0].message).toBe(expectedError); + } else { + expect(result.errors).toBeUndefined(); + } + }); + }); + + describe('FIELD_DEFINITION', () => { + const DOCUMENT = /* GraphQL */ ` + query user($byID: ID, $byUsername: String, $byEmail: String, $byRegistrationNumber: Int) { + findUser(byID: $byID, byUsername: $byUsername, byEmail: $byEmail, byRegistrationNumber: $byRegistrationNumber) { + id + } + } + `; + + it.each([ + [ + 'Valid: One value specified correctly through variables', + { + document: DOCUMENT, + variables: { + byID: 1, + }, + expectedError: null, + }, + ], + [ + 'Valid: Mixed values of variables and literal without variables specified', + { + document: /* GraphQL */ ` + query user($username: String) { + findUser(byID: 1, byUsername: $username) { + id + } + } + `, + variables: {}, + expectedError: null, + }, + ], + [ + 'Invalid: Multiple values specified through variables', + { + document: DOCUMENT, + variables: { + byID: 1, + byUsername: 'test', + }, + expectedError: 'Exactly one key must be specified for input for field "User.findUser"', + }, + ], + [ + 'Invalid: Multiple values specified through literal', + { + document: /* GraphQL */ ` + query user { + findUser(byID: 1, byUsername: "test") { + id + } + } + `, + variables: {}, + expectedError: 'Exactly one key must be specified for input for field "User.findUser"', + }, + ], + [ + 'Invalid: Mixed values of variables and literal', + { + document: /* GraphQL */ ` + query user($username: String) { + findUser(byID: 1, byUsername: $username) { + id + } + } + `, + variables: { + username: 'test', + }, + expectedError: 'Exactly one key must be specified for input for field "User.findUser"', + }, + ], + ])('%s', async (title, { document, variables, expectedError }) => { + const testInstance = createTestkit( + [ + useExtendedValidation({ + rules: [OneOfInputObjectsRule], + }), + ], + testSchema + ); + + const result = await testInstance.execute(document, variables); + + if (expectedError) { + expect(result.errors).toBeDefined(); + expect(result.errors.length).toBe(1); + expect(result.errors[0].message).toBe(expectedError); + } else { + expect(result.errors).toBeUndefined(); + } + }); + }); +}); diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index ff06d24e5f..d39ecd275f 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -49,7 +49,11 @@ export function createTestkit( pluginsOrEnvelop: Envelop | Plugin[], schema?: GraphQLSchema ): { - execute: (operation: DocumentNode | string, initialContext?: any) => Promise>; + execute: ( + operation: DocumentNode | string, + variables?: Record, + initialContext?: any + ) => Promise>; replaceSchema: (schema: GraphQLSchema) => void; wait: (ms: number) => Promise; } { @@ -71,13 +75,14 @@ export function createTestkit( return { wait: ms => new Promise(resolve => setTimeout(resolve, ms)), replaceSchema, - execute: async (operation, initialContext = null) => { + execute: async (operation, rawVariables = {}, initialContext = null) => { const request = { headers: {}, method: 'POST', query: '', body: { query: typeof operation === 'string' ? operation : print(operation), + variables: rawVariables, }, }; const proxy = initRequest(); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a56908c227..4a4796cce0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -58,6 +58,7 @@ export interface Plugin { executeFn: typeof execute; args: ExecutionArgs; setExecuteFn: (newExecute: typeof execute) => void; + setResultAndStopExecution: (newResult: ExecutionResult) => void; extendContext: (contextExtension: Partial) => void; }) => void | OnExecuteHookResult; onSubscribe?: (options: {