@graphql-tools/executor-http
Version:
A set of utils for faster development of GraphQL tools
618 lines (608 loc) • 19.1 kB
JavaScript
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 };