UNPKG

@graphql-tools/executor-http

Version:

A set of utils for faster development of GraphQL tools

618 lines (608 loc) • 19.1 kB
import { defaultPrintFn, serializeExecutionRequest } from '@graphql-tools/executor-common'; import { isAsyncIterable, isPromise, mapMaybePromise, createGraphQLError, inspect, mapAsyncIterator, mergeIncrementalResult, memoize1, getOperationASTFromRequest } from '@graphql-tools/utils'; import { DisposableSymbols } from '@whatwg-node/disposablestack'; import { FormData, File, TextEncoder, crypto, TextDecoder, fetch } from '@whatwg-node/fetch'; import { ValueOrPromise } from 'value-or-promise'; import { extractFiles, isExtractableFile } from 'extract-files'; import { Repeater } from '@repeaterjs/repeater'; import { GraphQLError } from 'graphql'; import { meros as meros$1 } from 'meros/browser'; import { meros } from 'meros/node'; function isGraphQLUpload(upload) { return typeof upload.createReadStream === "function"; } function collectAsyncIterableValues(asyncIterable) { const values = []; const iterator = asyncIterable[Symbol.asyncIterator](); function iterate() { return mapMaybePromise(iterator.next(), ({ value, done }) => { if (value != null) { values.push(value); } if (done) { return values; } return iterate(); }); } return iterate(); } function createFormDataFromVariables(body, { File: FileCtor = File, FormData: FormDataCtor = FormData }) { if (!body.variables) { return JSON.stringify(body); } const vars = Object.assign({}, body.variables); const { clone, files } = extractFiles( vars, "variables", (v) => isExtractableFile(v) || v?.promise || isAsyncIterable(v) || v?.then || typeof v?.arrayBuffer === "function" ); if (files.size === 0) { return JSON.stringify(body); } const map = {}; const uploads = []; let currIndex = 0; for (const [file, curr] of files) { map[currIndex] = curr; uploads[currIndex] = file; currIndex++; } const form = new FormDataCtor(); form.append( "operations", JSON.stringify({ ...body, variables: clone }) ); form.append("map", JSON.stringify(map)); function handleUpload(upload, i) { const indexStr = i.toString(); if (upload != null) { return mapMaybePromise( upload?.promise || upload, (upload2) => { const filename = upload2.filename || upload2.name || upload2.path || `blob-${indexStr}`; if (isBlob(upload2)) { form.append(indexStr, upload2, filename); } else if (isAsyncIterable(upload2)) { return mapMaybePromise( collectAsyncIterableValues(upload2), (chunks) => { const blobPart = new Uint8Array(chunks); form.append( indexStr, new FileCtor([blobPart], filename), filename ); } ); } else if (isGraphQLUpload(upload2)) { return mapMaybePromise( collectAsyncIterableValues(upload2.createReadStream()), (chunks) => { const blobPart = new Uint8Array(chunks); form.append( indexStr, new FileCtor([blobPart], filename, { type: upload2.mimetype }), filename ); } ); } else { form.append(indexStr, new FileCtor([upload2], filename), filename); } } ); } } const jobs = []; for (const i in uploads) { const upload = uploads[i]; const job = handleUpload(upload, Number(i)); if (isPromise(job)) { jobs.push(job); } } if (jobs.length > 0) { return Promise.all(jobs).then(() => form); } return form; } function isBlob(obj) { return typeof obj.arrayBuffer === "function"; } function createGraphQLErrorForAbort(reason, extensions) { if (reason instanceof GraphQLError) { return reason; } if (reason?.name === "TimeoutError") { return createGraphQLError(reason.message, { extensions: { http: { status: 504, ...extensions?.["http"] || {} }, code: "TIMEOUT_ERROR", ...extensions || {} }, originalError: reason }); } return createGraphQLError(reason.message, { extensions, originalError: reason }); } function createResultForAbort(reason, extensions) { return { errors: [createGraphQLErrorForAbort(reason, extensions)] }; } function hashSHA256(str) { const textEncoder = new TextEncoder(); const utf8 = textEncoder.encode(str); return mapMaybePromise( crypto.subtle.digest("SHA-256", utf8), (hashBuffer) => { let hashHex = ""; for (const bytes of new Uint8Array(hashBuffer)) { hashHex += bytes.toString(16).padStart(2, "0"); } return hashHex; } ); } const DELIM = "\n\n"; function isReadableStream(value) { return value && typeof value.getReader === "function"; } function handleEventStreamResponse(response, signal) { const body = response.body; if (!isReadableStream(body)) { throw new Error( "Response body is expected to be a readable stream but got; " + inspect(body) ); } return new Repeater((push, stop) => { const decoder = new TextDecoder(); const reader = body.getReader(); reader.closed.then(stop).catch(stop); stop.then(() => reader.releaseLock()).catch((err) => { reader.cancel(err); }); let currChunk = ""; async function pump() { if (signal?.aborted) { await push(createResultForAbort(signal.reason)); return stop(); } if (!body?.locked) { return stop(); } const { done, value: chunk } = await reader.read(); if (done) { return stop(); } currChunk += typeof chunk === "string" ? chunk : decoder.decode(chunk); for (; ; ) { const delimIndex = currChunk.indexOf(DELIM); if (delimIndex === -1) { break; } const msg = currChunk.slice(0, delimIndex); currChunk = currChunk.slice(delimIndex + DELIM.length); const dataStr = msg.split("data:")[1]?.trim(); if (dataStr) { const data = JSON.parse(dataStr); await push(data.payload || data); } const event = msg.split("event:")[1]?.trim(); if (event === "complete") { return stop(); } } return pump(); } return pump(); }); } function isIncomingMessage(body) { return body != null && typeof body === "object" && "pipe" in body; } async function handleMultipartMixedResponse(response) { const body = response.body; const contentType = response.headers.get("content-type") || ""; let asyncIterator; if (isIncomingMessage(body)) { body.headers = { "content-type": contentType }; const result = await meros(body); if ("next" in result) { asyncIterator = result; } } else { const result = await meros$1(response); if ("next" in result) { asyncIterator = result; } } const executionResult = {}; if (asyncIterator == null) { return executionResult; } const resultStream = mapAsyncIterator(asyncIterator, (part) => { if (part.json) { const incrementalResult = part.body; mergeIncrementalResult({ incrementalResult, executionResult }); } return executionResult; }); return resultStream; } const isLiveQueryOperationDefinitionNode = memoize1( function isLiveQueryOperationDefinitionNode2(node) { return node.operation === "query" && node.directives?.some((directive) => directive.name.value === "live"); } ); function prepareGETUrl({ baseUrl = "", body }) { const dummyHostname = "https://dummyhostname.com"; const validUrl = baseUrl.startsWith("http") ? baseUrl : baseUrl?.startsWith("/") ? `${dummyHostname}${baseUrl}` : `${dummyHostname}/${baseUrl}`; const urlObj = new URL(validUrl); if (body.query) { urlObj.searchParams.set("query", body.query); } if (body.variables && Object.keys(body.variables).length > 0) { urlObj.searchParams.set("variables", JSON.stringify(body.variables)); } if (body.operationName) { urlObj.searchParams.set("operationName", body.operationName); } if (body.extensions) { urlObj.searchParams.set("extensions", JSON.stringify(body.extensions)); } const finalUrl = urlObj.toString().replace(dummyHostname, ""); return finalUrl; } function buildHTTPExecutor(options) { const printFn = options?.print ?? defaultPrintFn; const disposeCtrl = new AbortController(); const baseExecutor = (request, excludeQuery) => { if (disposeCtrl.signal.aborted) { return createResultForAbort(disposeCtrl.signal.reason); } const fetchFn = request.extensions?.fetch ?? options?.fetch ?? fetch; let method = request.extensions?.method || options?.method; const operationAst = getOperationASTFromRequest(request); const operationType = operationAst.operation; if ((options?.useGETForQueries || request.extensions?.useGETForQueries) && operationType === "query") { method = "GET"; } let accept = "application/graphql-response+json, application/json, multipart/mixed"; if (operationType === "subscription" || isLiveQueryOperationDefinitionNode(operationAst)) { method ||= "GET"; accept = "text/event-stream"; } else { method ||= "POST"; } const endpoint = request.extensions?.endpoint || options?.endpoint || "/graphql"; const headers = { accept }; if (options?.headers) { Object.assign( headers, typeof options?.headers === "function" ? options.headers(request) : options?.headers ); } if (request.extensions?.headers) { const { headers: headersFromExtensions, ...restExtensions } = request.extensions; Object.assign(headers, headersFromExtensions); request.extensions = restExtensions; } const signals = [disposeCtrl.signal]; const signalFromRequest = request.signal || request.info?.signal; if (signalFromRequest) { if (signalFromRequest.aborted) { return createResultForAbort(signalFromRequest.reason); } signals.push(signalFromRequest); } if (options?.timeout) { signals.push(AbortSignal.timeout(options.timeout)); } const signal = AbortSignal.any(signals); const upstreamErrorExtensions = { request: { method } }; const query = printFn(request.document); let serializeFn = function serialize() { return serializeExecutionRequest({ executionRequest: request, excludeQuery, printFn }); }; if (options?.apq) { serializeFn = function serializeWithAPQ() { return mapMaybePromise(hashSHA256(query), (sha256Hash) => { const extensions = request.extensions || {}; extensions["persistedQuery"] = { version: 1, sha256Hash }; return serializeExecutionRequest({ executionRequest: { ...request, extensions }, excludeQuery, printFn }); }); }; } return mapMaybePromise( serializeFn(), (body) => new ValueOrPromise(() => { switch (method) { case "GET": { const finalUrl = prepareGETUrl({ baseUrl: endpoint, body }); const fetchOptions = { method: "GET", headers, signal }; if (options?.credentials != null) { fetchOptions.credentials = options.credentials; } upstreamErrorExtensions.request.url = finalUrl; return fetchFn( finalUrl, fetchOptions, request.context, request.info ); } case "POST": { upstreamErrorExtensions.request.body = body; return mapMaybePromise( createFormDataFromVariables(body, { File: options?.File, FormData: options?.FormData }), (body2) => { if (typeof body2 === "string" && !headers["content-type"]) { upstreamErrorExtensions.request.body = body2; headers["content-type"] = "application/json"; } const fetchOptions = { method: "POST", body: body2, headers, signal }; if (options?.credentials != null) { fetchOptions.credentials = options.credentials; } return fetchFn( endpoint, fetchOptions, request.context, request.info ); } ); } } }).then((fetchResult) => { upstreamErrorExtensions.response ||= {}; upstreamErrorExtensions.response.status = fetchResult.status; upstreamErrorExtensions.response.statusText = fetchResult.statusText; Object.defineProperty(upstreamErrorExtensions.response, "headers", { get() { return Object.fromEntries(fetchResult.headers.entries()); } }); if (options?.retry != null && !fetchResult.status.toString().startsWith("2")) { throw new Error( fetchResult.statusText || `Upstream HTTP Error: ${fetchResult.status}` ); } const contentType = fetchResult.headers.get("content-type"); if (contentType?.includes("text/event-stream")) { return handleEventStreamResponse(fetchResult, signal); } else if (contentType?.includes("multipart/mixed")) { return handleMultipartMixedResponse(fetchResult); } return fetchResult.text(); }).then((result) => { if (typeof result === "string") { upstreamErrorExtensions.response ||= {}; upstreamErrorExtensions.response.body = result; if (result) { try { const parsedResult = JSON.parse(result); upstreamErrorExtensions.response.body = parsedResult; if (parsedResult.data == null && (parsedResult.errors == null || parsedResult.errors.length === 0)) { return { errors: [ createGraphQLError( 'Unexpected empty "data" and "errors" fields in result: ' + result, { extensions: upstreamErrorExtensions } ) ] }; } if (Array.isArray(parsedResult.errors)) { return { ...parsedResult, errors: parsedResult.errors.map( ({ message, ...options2 }) => createGraphQLError(message, { ...options2, extensions: { code: "DOWNSTREAM_SERVICE_ERROR", ...options2.extensions || {} } }) ) }; } return parsedResult; } catch (e) { return { errors: [ createGraphQLError( `Unexpected response: ${JSON.stringify(result)}`, { extensions: upstreamErrorExtensions, originalError: e } ) ] }; } } } else { return result; } }).catch((e) => { if (e.name === "AggregateError") { return { errors: e.errors.map( (e2) => coerceFetchError(e2, { signal, endpoint, upstreamErrorExtensions }) ) }; } return { errors: [ coerceFetchError(e, { signal, endpoint, upstreamErrorExtensions }) ] }; }).resolve() ); }; let executor = baseExecutor; if (options?.apq != null) { executor = function apqExecutor(request) { return mapMaybePromise( baseExecutor(request, true), (res) => { if (res.errors?.some( (error) => error.extensions["code"] === "PERSISTED_QUERY_NOT_FOUND" || error.message === "PersistedQueryNotFound" )) { return baseExecutor(request, false); } return res; } ); }; } if (options?.retry != null) { const prevExecutor = executor; executor = function retryExecutor(request) { let result; let attempt = 0; function retryAttempt() { if (disposeCtrl.signal.aborted) { return createResultForAbort(disposeCtrl.signal.reason); } attempt++; if (attempt > options.retry) { if (result != null) { return result; } return { errors: [createGraphQLError("No response returned from fetch")] }; } return mapMaybePromise(prevExecutor(request), (res) => { result = res; if (result?.errors?.length) { return retryAttempt(); } return result; }); } return retryAttempt(); }; } Object.defineProperties(executor, { [DisposableSymbols.dispose]: { get() { return function dispose() { return disposeCtrl.abort(options?.getDisposeReason?.()); }; } }, [DisposableSymbols.asyncDispose]: { get() { return function asyncDispose() { return disposeCtrl.abort(options?.getDisposeReason?.()); }; } } }); return executor; } function coerceFetchError(e, { signal, endpoint, upstreamErrorExtensions }) { if (typeof e === "string") { return createGraphQLError(e, { extensions: upstreamErrorExtensions }); } else if (e.name === "GraphQLError") { return e; } else if (e.name === "TypeError" && e.message === "fetch failed") { return createGraphQLError(`fetch failed to ${endpoint}`, { extensions: upstreamErrorExtensions, originalError: e }); } else if (e.name === "AbortError") { return createGraphQLErrorForAbort( signal?.reason || e, upstreamErrorExtensions ); } else if (e.message) { return createGraphQLError(e.message, { extensions: upstreamErrorExtensions, originalError: e }); } else { return createGraphQLError("Unknown error", { extensions: upstreamErrorExtensions, originalError: e }); } } export { buildHTTPExecutor, isLiveQueryOperationDefinitionNode };