msw
Version:
242 lines (207 loc) • 6.61 kB
text/typescript
import {
parse,
type DocumentNode,
type OperationDefinitionNode,
type OperationTypeNode,
} from 'graphql'
import type { GraphQLVariables } from './graphql-handler'
import { toPublicUrl } from '#core/utils/request/to-public-url'
import { devUtils } from '#core/utils/internal/dev-utils'
import { jsonParse } from '#core/utils/internal/json-parse'
import { parseMultipartData } from '#core/utils/internal/parse-multipart-data'
interface GraphQLInput {
query: string | null
variables?: GraphQLVariables
operationName?: string | null
}
export interface ParsedGraphQLQuery {
/**
* Undefined if the document has no operation matching the
* requested operation name.
*/
operationType?: OperationTypeNode
operationName?: string
}
export type ParsedGraphQLRequest<
VariablesType extends GraphQLVariables = GraphQLVariables,
> =
| (ParsedGraphQLQuery & {
query: string
variables?: VariablesType
})
| undefined
export function parseDocumentNode(
node: DocumentNode,
operationName?: string | null,
): ParsedGraphQLQuery {
const operationDefs = node.definitions.filter((definition) => {
return definition.kind === 'OperationDefinition'
}) as Array<OperationDefinitionNode>
/**
* @note A document may bundle multiple operations (e.g. the ones
* emitted by GraphQL Code Generator). Honor the requested operation
* name, and resolve nothing if the document has no such operation.
* Falling back to the first operation would silently resolve one the
* client never asked for.
*/
const operationDef = operationName
? operationDefs.find((definition) => {
return definition.name?.value === operationName
})
: operationDefs[0]
return {
operationType: operationDef?.operation,
/**
* @note Echo the requested operation name even when the document
* has no such operation. It makes the "unhandled operation" warnings
* name the operation the client actually asked for.
*/
operationName: operationDef?.name?.value || operationName || undefined,
}
}
async function parseQuery(
query: string,
operationName?: string | null,
): Promise<ParsedGraphQLQuery | Error> {
try {
const ast = parse(query)
return parseDocumentNode(ast, operationName)
} catch (error) {
return error as Error
}
}
type GraphQLParsedOperationsMap = Record<string, string[]>
export type GraphQLMultipartRequestBody = {
operations: string
map?: string
} & {
[fileName: string]: File
}
function extractMultipartVariables<VariablesType extends GraphQLVariables>(
variables: VariablesType,
map: GraphQLParsedOperationsMap,
files: Record<string, File>,
) {
const operations = { variables }
for (const [key, pathArray] of Object.entries(map)) {
if (!(key in files)) {
throw new Error(`Given files do not have a key '${key}' .`)
}
for (const dotPath of pathArray) {
const [lastPath, ...reversedPaths] = dotPath.split('.').reverse()
const paths = reversedPaths.reverse()
let target: Record<string, any> = operations
for (const path of paths) {
if (!(path in target)) {
throw new Error(`Property '${path}' is not in operations.`)
}
target = target[path]
}
target[lastPath] = files[key]
}
}
return operations.variables
}
async function getGraphQLInput(request: Request): Promise<GraphQLInput | null> {
switch (request.method) {
case 'GET': {
const url = new URL(request.url)
const query = url.searchParams.get('query')
const variables = url.searchParams.get('variables') || ''
return {
query,
variables: jsonParse(variables),
operationName: url.searchParams.get('operationName'),
}
}
case 'POST': {
// Clone the request so we could read its body without locking
// the body stream to the downward consumers.
const requestClone = request.clone()
// Handle multipart body GraphQL operations.
if (
request.headers.get('content-type')?.includes('multipart/form-data')
) {
const responseJson = parseMultipartData<GraphQLMultipartRequestBody>(
await requestClone.text(),
request.headers,
)
if (!responseJson) {
return null
}
const { operations, map, ...files } = responseJson
const parsedOperations =
jsonParse<{
query?: string
variables?: GraphQLVariables
operationName?: string | null
}>(operations) || {}
if (!parsedOperations.query) {
return null
}
const parsedMap = jsonParse<GraphQLParsedOperationsMap>(map || '') || {}
const variables = parsedOperations.variables
? extractMultipartVariables(
parsedOperations.variables,
parsedMap,
files,
)
: {}
return {
query: parsedOperations.query,
variables,
operationName: parsedOperations.operationName,
}
}
// Handle plain POST GraphQL operations.
const requestJson: {
query: string
variables?: GraphQLVariables
operationName?: string | null
operations?: any /** @todo Annotate this */
} = await requestClone.json().catch(() => null)
if (requestJson?.query) {
const { query, variables, operationName } = requestJson
return {
query,
variables,
operationName,
}
}
return null
}
default:
return null
}
}
/**
* Determines if a given request can be considered a GraphQL request.
* Does not parse the query and does not guarantee its validity.
*/
export async function parseGraphQLRequest(
request: Request,
): Promise<ParsedGraphQLRequest> {
const input = await getGraphQLInput(request)
if (!input || !input.query) {
return
}
const { query, variables, operationName } = input
const parsedResult = await parseQuery(query, operationName)
if (parsedResult instanceof Error) {
const requestPublicUrl = toPublicUrl(request.url)
throw new Error(
devUtils.formatMessage(
'Failed to intercept a GraphQL request to "%s %s": cannot parse query. See the error message from the parser below.\n\n%s',
request.method,
requestPublicUrl,
parsedResult.message,
),
)
}
return {
query: input.query,
operationType: parsedResult.operationType,
operationName: parsedResult.operationName,
variables,
}
}