From d2a4a2aad30bbb8cdacc3e2d0b15f4249b8dc4a9 Mon Sep 17 00:00:00 2001 From: Long Mai Date: Mon, 7 Jun 2021 17:54:01 -0500 Subject: [PATCH 1/2] feat(gql): support TypedDocumentNode Uses DocumentNode in GraphQL tests feat: remove @graphql-typed-document-node/core and duplicate `TypedDocumentNode` interface fix: eslint issues test: TypedDocumentNode expected typings Adds positive assertions to "graphql.test-d.ts" --- src/graphql.ts | 14 ++++- src/handlers/GraphQLHandler.test.ts | 69 +++++++++++++++++++++++ src/handlers/GraphQLHandler.ts | 39 +++++++++++-- src/utils/internal/parseGraphQLRequest.ts | 28 +++++---- test/typings/graphql.test-d.ts | 59 +++++++++++++++++++ 5 files changed, 194 insertions(+), 15 deletions(-) diff --git a/src/graphql.ts b/src/graphql.ts index 04a7dd3f7..119a4f9d4 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -1,3 +1,4 @@ +import { DocumentNode } from 'graphql' import { Path } from 'node-match-path' import { ResponseResolver } from './handlers/RequestHandler' import { @@ -9,6 +10,14 @@ import { GraphQLHandlerNameSelector, } from './handlers/GraphQLHandler' +export interface TypedDocumentNode< + Result = { [key: string]: any }, + Variables = { [key: string]: any }, +> extends DocumentNode { + __resultType?: Result + __variablesType?: Variables +} + function createScopedGraphQLHandler( operationType: ExpectedOperationTypeNode, url: Path, @@ -17,7 +26,10 @@ function createScopedGraphQLHandler( Query extends Record, Variables extends GraphQLVariables = GraphQLVariables, >( - operationName: GraphQLHandlerNameSelector, + operationName: + | GraphQLHandlerNameSelector + | DocumentNode + | TypedDocumentNode, resolver: ResponseResolver< GraphQLRequest, GraphQLContext diff --git a/src/handlers/GraphQLHandler.test.ts b/src/handlers/GraphQLHandler.test.ts index 102fc8b5a..82732b667 100644 --- a/src/handlers/GraphQLHandler.test.ts +++ b/src/handlers/GraphQLHandler.test.ts @@ -1,6 +1,7 @@ /** * @jest-environment jsdom */ +import { parse } from 'graphql' import { Headers } from 'headers-utils/lib' import { context } from '..' import { createMockedRequest } from '../../test/support/utils' @@ -10,6 +11,7 @@ import { GraphQLHandler, GraphQLRequest, GraphQLRequestBody, + isDocumentNode, } from './GraphQLHandler' import { MockedRequest, ResponseResolver } from './RequestHandler' @@ -82,6 +84,51 @@ describe('info', () => { expect(handler.info.operationType).toEqual('mutation') expect(handler.info.operationName).toEqual('Login') }) + + test('parses a query operation name from a given DocumentNode', () => { + const node = parse(` + query GetUser { + user { + firstName + } + } + `) + + const handler = new GraphQLHandler('query', node, '*', resolver) + + expect(handler.info).toHaveProperty('header', 'query GetUser (origin: *)') + expect(handler.info).toHaveProperty('operationType', 'query') + expect(handler.info).toHaveProperty('operationName', 'GetUser') + }) + + test('parses a mutation operation name from a given DocumentNode', () => { + const node = parse(` + mutation Login { + user { + id + } + } + `) + const handler = new GraphQLHandler('mutation', node, '*', resolver) + + expect(handler.info).toHaveProperty('header', 'mutation Login (origin: *)') + expect(handler.info).toHaveProperty('operationType', 'mutation') + expect(handler.info).toHaveProperty('operationName', 'Login') + }) + + test('throws an exception given a DocumentNode with a mismatched operation type', () => { + const node = parse(` + mutation CreateUser { + user { + firstName + } + } + `) + + expect(() => new GraphQLHandler('query', node, '*', resolver)).toThrow( + 'Failed to create a GraphQL handler: provided a DocumentNode with a mismatched operation type (expected "query", but got "mutation").', + ) + }) }) describe('parse', () => { @@ -374,3 +421,25 @@ describe('run', () => { expect(result).toBeNull() }) }) + +describe('isDocumentNode', () => { + it('returns true given a valid DocumentNode', () => { + const node = parse(` + query GetUser { + user { + login + } + } + `) + + expect(isDocumentNode(node)).toEqual(true) + }) + + it('returns false given an arbitrary input', () => { + expect(isDocumentNode(null)).toEqual(false) + expect(isDocumentNode(undefined)).toEqual(false) + expect(isDocumentNode('')).toEqual(false) + expect(isDocumentNode('value')).toEqual(false) + expect(isDocumentNode(/value/)).toEqual(false) + }) +}) diff --git a/src/handlers/GraphQLHandler.ts b/src/handlers/GraphQLHandler.ts index e427e66af..34481b65f 100644 --- a/src/handlers/GraphQLHandler.ts +++ b/src/handlers/GraphQLHandler.ts @@ -1,4 +1,4 @@ -import { OperationTypeNode } from 'graphql' +import { DocumentNode, OperationTypeNode } from 'graphql' import { Path } from 'node-match-path' import { SerializedResponse } from '../setupWorker/glossary' import { set } from '../context/set' @@ -22,13 +22,14 @@ import { ParsedGraphQLRequest, GraphQLMultipartRequestBody, parseGraphQLRequest, + parseDocumentNode, } from '../utils/internal/parseGraphQLRequest' import { getPublicUrlFromRequest } from '../utils/request/getPublicUrlFromRequest' import { tryCatch } from '../utils/internal/tryCatch' import { devUtils } from '../utils/internal/devUtils' export type ExpectedOperationTypeNode = OperationTypeNode | 'all' -export type GraphQLHandlerNameSelector = RegExp | string +export type GraphQLHandlerNameSelector = DocumentNode | RegExp | string // GraphQL related context should contain utility functions // useful for GraphQL. Functions like `xml()` bear no value @@ -76,6 +77,16 @@ export interface GraphQLRequest variables: Variables } +export function isDocumentNode( + value: DocumentNode | any, +): value is DocumentNode { + if (value == null) { + return false + } + + return typeof value === 'object' && 'kind' in value && 'definitions' in value +} + export class GraphQLHandler< Request extends GraphQLRequest = GraphQLRequest, > extends RequestHandler< @@ -92,16 +103,36 @@ export class GraphQLHandler< endpoint: Path, resolver: ResponseResolver, ) { + let resolvedOperationName = operationName + + if (isDocumentNode(operationName)) { + const parsedNode = parseDocumentNode(operationName) + + if (parsedNode.operationType !== operationType) { + throw new Error( + `Failed to create a GraphQL handler: provided a DocumentNode with a mismatched operation type (expected "${operationType}", but got "${parsedNode.operationType}").`, + ) + } + + if (!parsedNode.operationName) { + throw new Error( + `Failed to create a GraphQL handler: provided a DocumentNode with no operation name.`, + ) + } + + resolvedOperationName = parsedNode.operationName + } + const header = operationType === 'all' ? `${operationType} (origin: ${endpoint.toString()})` - : `${operationType} ${operationName} (origin: ${endpoint.toString()})` + : `${operationType} ${resolvedOperationName} (origin: ${endpoint.toString()})` super({ info: { header, operationType, - operationName, + operationName: resolvedOperationName, }, ctx: graphqlContext, resolver, diff --git a/src/utils/internal/parseGraphQLRequest.ts b/src/utils/internal/parseGraphQLRequest.ts index 492e5df1d..55a57e37c 100644 --- a/src/utils/internal/parseGraphQLRequest.ts +++ b/src/utils/internal/parseGraphQLRequest.ts @@ -1,4 +1,9 @@ -import { OperationDefinitionNode, OperationTypeNode, parse } from 'graphql' +import { + DocumentNode, + OperationDefinitionNode, + OperationTypeNode, + parse, +} from 'graphql' import { GraphQLVariables } from '../../handlers/GraphQLHandler' import { MockedRequest } from '../../handlers/RequestHandler' import { getPublicUrlFromRequest } from '../request/getPublicUrlFromRequest' @@ -23,18 +28,21 @@ export type ParsedGraphQLRequest< }) | undefined +export function parseDocumentNode(node: DocumentNode): ParsedGraphQLQuery { + const operationDef = node.definitions.find((def) => { + return def.kind === 'OperationDefinition' + }) as OperationDefinitionNode + + return { + operationType: operationDef?.operation, + operationName: operationDef?.name?.value, + } +} + function parseQuery(query: string): ParsedGraphQLQuery | Error { try { const ast = parse(query) - - const operationDef = ast.definitions.find((def) => { - return def.kind === 'OperationDefinition' - }) as OperationDefinitionNode - - return { - operationType: operationDef?.operation, - operationName: operationDef?.name?.value, - } + return parseDocumentNode(ast) } catch (error) { return error } diff --git a/test/typings/graphql.test-d.ts b/test/typings/graphql.test-d.ts index 2b3dc2a9a..3ec3564fa 100644 --- a/test/typings/graphql.test-d.ts +++ b/test/typings/graphql.test-d.ts @@ -1,3 +1,4 @@ +import { parse } from 'graphql' import { graphql } from 'msw' graphql.query<{ key: string }>('', (req, res, ctx) => { @@ -50,3 +51,61 @@ graphql.operation< >((req, res, ctx) => { return res(ctx.data({ key: 'pass' })) }) + +/** + * Supports `DocumentNode` as the GraphQL operation name. + */ +const getUser = parse(` + query GetUser { + user { + firstName + } + } +`) +graphql.query(getUser, (req, res, ctx) => + res( + ctx.data({ + // Cannot extract query type from the runtime `DocumentNode`. + arbitrary: true, + }), + ), +) + +const getUserById = parse(` + query GetUserById($userId: String!) { + user(id: $userId) { + firstName + } + } +`) +graphql.query(getUserById, (req, res, ctx) => { + req.variables.userId + + // Extracting variables from the native "DocumentNode" is impossible. + req.variables.foo + + return res( + ctx.data({ + user: { + firstName: 'John', + // Extracting a query body type from the "DocumentNode" is impossible. + lastName: 'Maverick', + }, + }), + ) +}) + +const createUser = parse(` + mutation CreateUser { + user { + id + } + } +`) +graphql.mutation(createUser, (req, res, ctx) => + res( + ctx.data({ + arbitrary: true, + }), + ), +) From f473514038d990f97f848355be6bf0347c2d07a4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 29 Jul 2021 15:32:00 +0200 Subject: [PATCH 2/2] GraphQL: Adds integration test for the "DocumentNode" as input --- test/graphql-api/document-node.mocks.ts | 70 +++++++++++++++++ test/graphql-api/document-node.test.ts | 100 ++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 test/graphql-api/document-node.mocks.ts create mode 100644 test/graphql-api/document-node.test.ts diff --git a/test/graphql-api/document-node.mocks.ts b/test/graphql-api/document-node.mocks.ts new file mode 100644 index 000000000..399118d0c --- /dev/null +++ b/test/graphql-api/document-node.mocks.ts @@ -0,0 +1,70 @@ +import { parse } from 'graphql' +import { setupWorker, graphql } from 'msw' + +const GetUser = parse(` + query GetUser { + user { + firstName + } + } +`) + +const Login = parse(` + mutation Login($username: String!) { + session { + id + } + user { + username + } + } +`) + +const GetSubscription = parse(` + query GetSubscription { + subscription { + id + } + } +`) + +const github = graphql.link('https://api.github.com/graphql') + +const worker = setupWorker( + // "DocumentNode" can be used as the expected query/mutation. + graphql.query(GetUser, (req, res, ctx) => { + return res( + ctx.data({ + // Note that inferring the query body and variables + // is impossible with the native "DocumentNode". + // Consider using tools like GraphQL Code Generator. + user: { + firstName: 'John', + }, + }), + ) + }), + graphql.mutation(Login, (req, res, ctx) => { + return res( + ctx.data({ + session: { + id: 'abc-123', + }, + user: { + username: req.variables.username, + }, + }), + ) + }), + github.query(GetSubscription, (req, res, ctx) => { + return res( + ctx.data({ + subscription: { + id: 123, + }, + }), + ) + }), +) + +worker.start() diff --git a/test/graphql-api/document-node.test.ts b/test/graphql-api/document-node.test.ts new file mode 100644 index 000000000..9e908a876 --- /dev/null +++ b/test/graphql-api/document-node.test.ts @@ -0,0 +1,100 @@ +import * as path from 'path' +import { pageWith } from 'page-with' +import { executeGraphQLQuery } from './utils/executeGraphQLQuery' +import { gql } from '../support/graphql' + +function prepareRuntime() { + return pageWith({ + example: path.resolve(__dirname, 'document-node.mocks.ts'), + }) +} + +test('intercepts a GraphQL query based on its DocumentNode', async () => { + const runtime = await prepareRuntime() + + const res = await executeGraphQLQuery(runtime.page, { + query: gql` + query GetUser { + user { + firstName + } + } + `, + }) + + expect(res.status()).toEqual(200) + expect(res.headers()).toHaveProperty('x-powered-by', 'msw') + + const json = await res.json() + expect(json).toEqual({ + data: { + user: { + firstName: 'John', + }, + }, + }) +}) + +test('intercepts a GraphQL mutation based on its DocumentNode', async () => { + const runtime = await prepareRuntime() + + const res = await executeGraphQLQuery(runtime.page, { + query: gql` + mutation Login { + session { + id + } + } + `, + variables: { + username: 'octocat', + }, + }) + + expect(res.status()).toEqual(200) + expect(res.headers()).toHaveProperty('x-powered-by', 'msw') + + const json = await res.json() + expect(json).toEqual({ + data: { + session: { + id: 'abc-123', + }, + user: { + username: 'octocat', + }, + }, + }) +}) + +test('intercepts a scoped GraphQL query based on its DocumentNode', async () => { + const runtime = await prepareRuntime() + + const res = await executeGraphQLQuery( + runtime.page, + { + query: gql` + query GetSubscription { + subscription { + id + } + } + `, + }, + { + uri: 'https://api.github.com/graphql', + }, + ) + + expect(res.status()).toEqual(200) + expect(res.headers()).toHaveProperty('x-powered-by', 'msw') + + const json = await res.json() + expect(json).toEqual({ + data: { + subscription: { + id: 123, + }, + }, + }) +})