UNPKG

msw

Version:

Seamless REST/GraphQL API mocking library for browser and Node.js.

277 lines (239 loc) 8.49 kB
import type { DocumentNode, GraphQLError, OperationTypeNode } from 'graphql' import { DefaultBodyType, RequestHandler, RequestHandlerDefaultInfo, RequestHandlerOptions, ResponseResolver, } from './RequestHandler' import { getTimestamp } from '../utils/logging/getTimestamp' import { getStatusCodeColor } from '../utils/logging/getStatusCodeColor' import { serializeRequest } from '../utils/logging/serializeRequest' import { serializeResponse } from '../utils/logging/serializeResponse' import { Match, matchRequestUrl, Path } from '../utils/matching/matchRequestUrl' import { ParsedGraphQLRequest, GraphQLMultipartRequestBody, parseGraphQLRequest, parseDocumentNode, } from '../utils/internal/parseGraphQLRequest' import { toPublicUrl } from '../utils/request/toPublicUrl' import { devUtils } from '../utils/internal/devUtils' import { getAllRequestCookies } from '../utils/request/getRequestCookies' export type ExpectedOperationTypeNode = OperationTypeNode | 'all' export type GraphQLHandlerNameSelector = DocumentNode | RegExp | string export type GraphQLQuery = Record<string, any> | null export type GraphQLVariables = Record<string, any> export interface GraphQLHandlerInfo extends RequestHandlerDefaultInfo { operationType: ExpectedOperationTypeNode operationName: GraphQLHandlerNameSelector } export type GraphQLRequestParsedResult = { match: Match cookies: Record<string, string> } & ( | ParsedGraphQLRequest<GraphQLVariables> /** * An empty version of the ParsedGraphQLRequest * which simplifies the return type of the resolver * when the request is to a non-matching endpoint */ | { operationType?: undefined operationName?: undefined query?: undefined variables?: undefined } ) export type GraphQLResolverExtras<Variables extends GraphQLVariables> = { query: string operationName: string variables: Variables cookies: Record<string, string> } export type GraphQLRequestBody<VariablesType extends GraphQLVariables> = | GraphQLJsonRequestBody<VariablesType> | GraphQLMultipartRequestBody | Record<string, any> | undefined export interface GraphQLJsonRequestBody<Variables extends GraphQLVariables> { query: string variables?: Variables } export type GraphQLResponseBody<BodyType extends DefaultBodyType> = | { data?: BodyType | null errors?: readonly Partial<GraphQLError>[] | null extensions?: Record<string, any> } | null | undefined 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 extends RequestHandler< GraphQLHandlerInfo, GraphQLRequestParsedResult, GraphQLResolverExtras<any> > { private endpoint: Path static parsedRequestCache = new WeakMap< Request, ParsedGraphQLRequest<GraphQLVariables> >() constructor( operationType: ExpectedOperationTypeNode, operationName: GraphQLHandlerNameSelector, endpoint: Path, resolver: ResponseResolver<GraphQLResolverExtras<any>, any, any>, options?: RequestHandlerOptions, ) { 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} ${resolvedOperationName} (origin: ${endpoint.toString()})` super({ info: { header, operationType, operationName: resolvedOperationName, }, resolver, options, }) this.endpoint = endpoint } /** * Parses the request body, once per request, cached across all * GraphQL handlers. This is done to avoid multiple parsing of the * request body, which each requires a clone of the request. */ async parseGraphQLRequestOrGetFromCache( request: Request, ): Promise<ParsedGraphQLRequest<GraphQLVariables>> { if (!GraphQLHandler.parsedRequestCache.has(request)) { GraphQLHandler.parsedRequestCache.set( request, await parseGraphQLRequest(request).catch((error) => { // eslint-disable-next-line no-console console.error(error) return undefined }), ) } return GraphQLHandler.parsedRequestCache.get(request) } async parse(args: { request: Request }): Promise<GraphQLRequestParsedResult> { /** * If the request doesn't match a specified endpoint, there's no * need to parse it since there's no case where we would handle this */ const match = matchRequestUrl(new URL(args.request.url), this.endpoint) const cookies = getAllRequestCookies(args.request) if (!match.matches) { return { match, cookies } } const parsedResult = await this.parseGraphQLRequestOrGetFromCache( args.request, ) if (typeof parsedResult === 'undefined') { return { match, cookies } } return { match, cookies, query: parsedResult.query, operationType: parsedResult.operationType, operationName: parsedResult.operationName, variables: parsedResult.variables, } } predicate(args: { request: Request parsedResult: GraphQLRequestParsedResult }) { if (args.parsedResult.operationType === undefined) { return false } if (!args.parsedResult.operationName && this.info.operationType !== 'all') { const publicUrl = toPublicUrl(args.request.url) devUtils.warn(`\ Failed to intercept a GraphQL request at "${args.request.method} ${publicUrl}": anonymous GraphQL operations are not supported. Consider naming this operation or using "graphql.operation()" request handler to intercept GraphQL requests regardless of their operation name/type. Read more: https://mswjs.io/docs/api/graphql/#graphqloperationresolver`) return false } const hasMatchingOperationType = this.info.operationType === 'all' || args.parsedResult.operationType === this.info.operationType const hasMatchingOperationName = this.info.operationName instanceof RegExp ? this.info.operationName.test(args.parsedResult.operationName || '') : args.parsedResult.operationName === this.info.operationName return ( args.parsedResult.match.matches && hasMatchingOperationType && hasMatchingOperationName ) } protected extendResolverArgs(args: { request: Request parsedResult: GraphQLRequestParsedResult }) { return { query: args.parsedResult.query || '', operationName: args.parsedResult.operationName || '', variables: args.parsedResult.variables || {}, cookies: args.parsedResult.cookies, } } async log(args: { request: Request response: Response parsedResult: GraphQLRequestParsedResult }) { const loggedRequest = await serializeRequest(args.request) const loggedResponse = await serializeResponse(args.response) const statusColor = getStatusCodeColor(loggedResponse.status) const requestInfo = args.parsedResult.operationName ? `${args.parsedResult.operationType} ${args.parsedResult.operationName}` : `anonymous ${args.parsedResult.operationType}` // eslint-disable-next-line no-console console.groupCollapsed( devUtils.formatMessage( `${getTimestamp()} ${requestInfo} (%c${loggedResponse.status} ${ loggedResponse.statusText }%c)`, ), `color:${statusColor}`, 'color:inherit', ) // eslint-disable-next-line no-console console.log('Request:', loggedRequest) // eslint-disable-next-line no-console console.log('Handler:', this) // eslint-disable-next-line no-console console.log('Response:', loggedResponse) // eslint-disable-next-line no-console console.groupEnd() } }