msw
Version:
386 lines (334 loc) • 11.6 kB
text/typescript
import {
parse,
type DocumentNode,
type GraphQLError,
type 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,
ParsedGraphQLQuery,
} from '../utils/internal/parseGraphQLRequest'
import { toPublicUrl } from '../utils/request/toPublicUrl'
import { devUtils } from '../utils/internal/devUtils'
import { getAllRequestCookies } from '../utils/request/getRequestCookies'
import { invariant } from 'outvariant'
export interface DocumentTypeDecoration<
Result = { [key: string]: any },
Variables = { [key: string]: any },
> {
__apiType?: (variables: Variables) => Result
__resultType?: Result
__variablesType?: Variables
}
export type GraphQLOperationType = 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: GraphQLOperationType
operationName: GraphQLHandlerNameSelector | GraphQLCustomPredicate
}
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 type GraphQLCustomPredicate = (args: {
request: Request
query: string
operationType: GraphQLOperationType
operationName: string
variables: GraphQLVariables
cookies: Record<string, string>
}) => GraphQLCustomPredicateResult | Promise<GraphQLCustomPredicateResult>
export type GraphQLCustomPredicateResult = boolean | { matches: boolean }
export type GraphQLPredicate<Query = any, Variables = any> =
| GraphQLHandlerNameSelector
| DocumentTypeDecoration<Query, Variables>
| GraphQLCustomPredicate
export function isDocumentNode(
value: DocumentNode | any,
): value is DocumentNode {
if (value == null) {
return false
}
return typeof value === 'object' && 'kind' in value && 'definitions' in value
}
function isDocumentTypeDecoration(
value: any,
): value is DocumentTypeDecoration<any, any> {
return value instanceof String
}
export class GraphQLHandler extends RequestHandler<
GraphQLHandlerInfo,
GraphQLRequestParsedResult,
GraphQLResolverExtras<any>
> {
private endpoint: Path
static parsedRequestCache = new WeakMap<
Request,
ParsedGraphQLRequest<GraphQLVariables>
>()
static #parseOperationName(
predicate: GraphQLPredicate,
operationType: GraphQLOperationType,
): GraphQLHandlerInfo['operationName'] {
const getOperationName = (node: ParsedGraphQLQuery): string => {
invariant(
node.operationType === operationType,
'Failed to create a GraphQL handler: provided a DocumentNode with a mismatched operation type (expected "%s" but got "%s").',
operationType,
node.operationType,
)
invariant(
node.operationName,
'Failed to create a GraphQL handler: provided a DocumentNode without operation name',
)
return node.operationName
}
if (isDocumentNode(predicate)) {
return getOperationName(parseDocumentNode(predicate))
}
if (isDocumentTypeDecoration(predicate)) {
const documentNode = parse(predicate.toString())
invariant(
isDocumentNode(documentNode),
'Failed to create a GraphQL handler: given TypedDocumentString (%s) does not produce a valid DocumentNode',
predicate,
)
return getOperationName(parseDocumentNode(documentNode))
}
return predicate
}
constructor(
operationType: GraphQLOperationType,
predicate: GraphQLPredicate,
endpoint: Path,
resolver: ResponseResolver<GraphQLResolverExtras<any>, any, any>,
options?: RequestHandlerOptions,
) {
const operationName = GraphQLHandler.#parseOperationName(
predicate,
operationType,
)
const displayOperationName =
typeof operationName === 'function' ? '[custom predicate]' : operationName
const header =
operationType === 'all'
? `${operationType} (origin: ${endpoint.toString()})`
: `${operationType}${displayOperationName ? ` ${displayOperationName}` : ''} (origin: ${endpoint.toString()})`
super({
info: {
header,
operationType,
operationName: GraphQLHandler.#parseOperationName(
predicate,
operationType,
),
},
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) => {
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,
}
}
async predicate(args: {
request: Request
parsedResult: GraphQLRequestParsedResult
}): Promise<boolean> {
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
/**
* Check if the operation name matches the outgoing GraphQL request.
* @note Unlike the HTTP handler, the custom predicate functions are invoked
* during predicate, not parsing, because GraphQL request parsing happens first,
* and non-GraphQL requests are filtered out automatically.
*/
const hasMatchingOperationName = await this.matchOperationName({
request: args.request,
parsedResult: args.parsedResult,
})
return (
args.parsedResult.match.matches &&
hasMatchingOperationType &&
hasMatchingOperationName
)
}
private async matchOperationName(args: {
request: Request
parsedResult: GraphQLRequestParsedResult
}): Promise<boolean> {
if (typeof this.info.operationName === 'function') {
const customPredicateResult = await this.info.operationName({
request: args.request,
...this.extendResolverArgs({
request: args.request,
parsedResult: args.parsedResult,
}),
})
/**
* @note Keep the { matches } signature in case we decide to support path parameters
* in GraphQL handlers. If that happens, the custom predicate would have to be moved
* to the parsing phase, the same as we have for the HttpHandler, and the user will
* have a possibility to return parsed path parameters from the custom predicate.
*/
return typeof customPredicateResult === 'boolean'
? customPredicateResult
: customPredicateResult.matches
}
if (this.info.operationName instanceof RegExp) {
return this.info.operationName.test(args.parsedResult.operationName || '')
}
return args.parsedResult.operationName === this.info.operationName
}
protected extendResolverArgs(args: {
request: Request
parsedResult: GraphQLRequestParsedResult
}) {
return {
query: args.parsedResult.query || '',
operationType: args.parsedResult.operationType!,
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}`
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)
console.groupEnd()
}
}