UNPKG

@shopify/graphql-client

Version:

Shopify GraphQL Client - A lightweight generic GraphQL JS client to interact with Shopify GraphQL APIs

479 lines (472 loc) 20.1 kB
/*! shopify/graphql-client@1.4.0 -- Copyright (c) 2023-present, Shopify Inc. -- license (MIT): https://github.com/Shopify/shopify-app-js/blob/main/LICENSE.md */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ShopifyGraphQLClient = {})); })(this, (function (exports) { 'use strict'; const CLIENT = 'GraphQL Client'; const MIN_RETRIES = 0; const MAX_RETRIES = 3; const GQL_API_ERROR = "An error occurred while fetching from the API. Review 'graphQLErrors' for details."; const UNEXPECTED_CONTENT_TYPE_ERROR = 'Response returned unexpected Content-Type:'; const NO_DATA_OR_ERRORS_ERROR = 'An unknown error has occurred. The API did not return a data object or any errors in its response.'; const CONTENT_TYPES = { json: 'application/json', multipart: 'multipart/mixed', }; const SDK_VARIANT_HEADER = 'X-SDK-Variant'; const SDK_VERSION_HEADER = 'X-SDK-Version'; const DEFAULT_SDK_VARIANT = 'shopify-graphql-client'; // This is value is replaced with package.json version during rollup build process const DEFAULT_CLIENT_VERSION = '1.4.0'; const RETRY_WAIT_TIME = 1000; const RETRIABLE_STATUS_CODES = [429, 503]; const DEFER_OPERATION_REGEX = /@(defer)\b/i; const NEWLINE_SEPARATOR = '\r\n'; const BOUNDARY_HEADER_REGEX = /boundary="?([^=";]+)"?/i; const HEADER_SEPARATOR = NEWLINE_SEPARATOR + NEWLINE_SEPARATOR; function formatErrorMessage(message, client = CLIENT) { return message.startsWith(`${client}`) ? message : `${client}: ${message}`; } function getErrorMessage(error) { return error instanceof Error ? error.message : JSON.stringify(error); } function getErrorCause(error) { return error instanceof Error && error.cause ? error.cause : undefined; } function combineErrors(dataArray) { return dataArray.flatMap(({ errors }) => { return errors ?? []; }); } function validateRetries({ client, retries, }) { if (retries !== undefined && (typeof retries !== 'number' || retries < MIN_RETRIES || retries > MAX_RETRIES)) { throw new Error(`${client}: The provided "retries" value (${retries}) is invalid - it cannot be less than ${MIN_RETRIES} or greater than ${MAX_RETRIES}`); } } function getKeyValueIfValid(key, value) { return value && (typeof value !== 'object' || Array.isArray(value) || (typeof value === 'object' && Object.keys(value).length > 0)) ? { [key]: value } : {}; } function buildDataObjectByPath(path, data) { if (path.length === 0) { return data; } const key = path.pop(); const newData = { [key]: data, }; if (path.length === 0) { return newData; } return buildDataObjectByPath(path, newData); } function combineObjects(baseObject, newObject) { return Object.keys(newObject || {}).reduce((acc, key) => { if ((typeof newObject[key] === 'object' || Array.isArray(newObject[key])) && baseObject[key]) { acc[key] = combineObjects(baseObject[key], newObject[key]); return acc; } acc[key] = newObject[key]; return acc; }, Array.isArray(baseObject) ? [...baseObject] : { ...baseObject }); } function buildCombinedDataObject([initialDatum, ...remainingData]) { return remainingData.reduce(combineObjects, { ...initialDatum }); } function generateHttpFetch({ clientLogger, customFetchApi = fetch, client = CLIENT, defaultRetryWaitTime = RETRY_WAIT_TIME, retriableCodes = RETRIABLE_STATUS_CODES, }) { const httpFetch = async (requestParams, count, maxRetries) => { const nextCount = count + 1; const maxTries = maxRetries + 1; let response; try { response = await customFetchApi(...requestParams); clientLogger({ type: 'HTTP-Response', content: { requestParams, response, }, }); if (!response.ok && retriableCodes.includes(response.status) && nextCount <= maxTries) { throw new Error(); } const deprecationNotice = response?.headers.get('X-Shopify-API-Deprecated-Reason') || ''; if (deprecationNotice) { clientLogger({ type: 'HTTP-Response-GraphQL-Deprecation-Notice', content: { requestParams, deprecationNotice, }, }); } return response; } catch (error) { if (nextCount <= maxTries) { const retryAfter = response?.headers.get('Retry-After'); await sleep(retryAfter ? parseInt(retryAfter, 10) : defaultRetryWaitTime); clientLogger({ type: 'HTTP-Retry', content: { requestParams, lastResponse: response, retryAttempt: count, maxRetries, }, }); return httpFetch(requestParams, nextCount, maxRetries); } throw new Error(formatErrorMessage(`${maxRetries > 0 ? `Attempted maximum number of ${maxRetries} network retries. Last message - ` : ''}${getErrorMessage(error)}`, client)); } }; return httpFetch; } async function sleep(waitTime) { return new Promise((resolve) => setTimeout(resolve, waitTime)); } function createGraphQLClient({ headers, url, customFetchApi = fetch, retries = 0, logger, }) { validateRetries({ client: CLIENT, retries }); const config = { headers, url, retries, }; const clientLogger = generateClientLogger(logger); const httpFetch = generateHttpFetch({ customFetchApi, clientLogger, defaultRetryWaitTime: RETRY_WAIT_TIME, }); const fetchFn = generateFetch(httpFetch, config); const request = generateRequest(fetchFn); const requestStream = generateRequestStream(fetchFn); return { config, fetch: fetchFn, request, requestStream, }; } function generateClientLogger(logger) { return (logContent) => { if (logger) { logger(logContent); } }; } async function processJSONResponse(response) { const { errors, data, extensions } = await response.json(); return { ...getKeyValueIfValid('data', data), ...getKeyValueIfValid('extensions', extensions), headers: response.headers, ...(errors || !data ? { errors: { networkStatusCode: response.status, message: formatErrorMessage(errors ? GQL_API_ERROR : NO_DATA_OR_ERRORS_ERROR), ...getKeyValueIfValid('graphQLErrors', errors), response, }, } : {}), }; } function generateFetch(httpFetch, { url, headers, retries }) { return async (operation, options = {}) => { const { variables, headers: overrideHeaders, url: overrideUrl, retries: overrideRetries, keepalive, signal, } = options; const body = JSON.stringify({ query: operation, variables, }); validateRetries({ client: CLIENT, retries: overrideRetries }); const flatHeaders = Object.entries({ ...headers, ...overrideHeaders, }).reduce((headers, [key, value]) => { headers[key] = Array.isArray(value) ? value.join(', ') : value.toString(); return headers; }, {}); if (!flatHeaders[SDK_VARIANT_HEADER] && !flatHeaders[SDK_VERSION_HEADER]) { flatHeaders[SDK_VARIANT_HEADER] = DEFAULT_SDK_VARIANT; flatHeaders[SDK_VERSION_HEADER] = DEFAULT_CLIENT_VERSION; } const fetchParams = [ overrideUrl ?? url, { method: 'POST', headers: flatHeaders, body, signal, keepalive, }, ]; return httpFetch(fetchParams, 1, overrideRetries ?? retries); }; } function generateRequest(fetchFn) { return async (...props) => { if (DEFER_OPERATION_REGEX.test(props[0])) { throw new Error(formatErrorMessage('This operation will result in a streamable response - use requestStream() instead.')); } try { const response = await fetchFn(...props); const { status, statusText } = response; const contentType = response.headers.get('content-type') || ''; if (!response.ok) { return { errors: { networkStatusCode: status, message: formatErrorMessage(statusText), response, }, }; } if (!contentType.includes(CONTENT_TYPES.json)) { return { errors: { networkStatusCode: status, message: formatErrorMessage(`${UNEXPECTED_CONTENT_TYPE_ERROR} ${contentType}`), response, }, }; } return processJSONResponse(response); } catch (error) { return { errors: { message: getErrorMessage(error), }, }; } }; } async function* getStreamBodyIterator(response) { const decoder = new TextDecoder(); // Response body is an async iterator if (response.body[Symbol.asyncIterator]) { for await (const chunk of response.body) { yield decoder.decode(chunk); } } else { const reader = response.body.getReader(); let readResult; try { while (!(readResult = await reader.read()).done) { yield decoder.decode(readResult.value); } } finally { reader.cancel(); } } } function readStreamChunk(streamBodyIterator, boundary) { return { async *[Symbol.asyncIterator]() { try { let buffer = ''; for await (const textChunk of streamBodyIterator) { buffer += textChunk; if (buffer.indexOf(boundary) > -1) { const lastBoundaryIndex = buffer.lastIndexOf(boundary); const fullResponses = buffer.slice(0, lastBoundaryIndex); const chunkBodies = fullResponses .split(boundary) .filter((chunk) => chunk.trim().length > 0) .map((chunk) => { const body = chunk .slice(chunk.indexOf(HEADER_SEPARATOR) + HEADER_SEPARATOR.length) .trim(); return body; }); if (chunkBodies.length > 0) { yield chunkBodies; } buffer = buffer.slice(lastBoundaryIndex + boundary.length); if (buffer.trim() === `--`) { buffer = ''; } } } } catch (error) { throw new Error(`Error occured while processing stream payload - ${getErrorMessage(error)}`); } }, }; } function createJsonResponseAsyncIterator(response) { return { async *[Symbol.asyncIterator]() { const processedResponse = await processJSONResponse(response); yield { ...processedResponse, hasNext: false, }; }, }; } function getResponseDataFromChunkBodies(chunkBodies) { return chunkBodies .map((value) => { try { return JSON.parse(value); } catch (error) { throw new Error(`Error in parsing multipart response - ${getErrorMessage(error)}`); } }) .map((payload) => { const { data, incremental, hasNext, extensions, errors } = payload; // initial data chunk if (!incremental) { return { data: data || {}, ...getKeyValueIfValid('errors', errors), ...getKeyValueIfValid('extensions', extensions), hasNext, }; } // subsequent data chunks const incrementalArray = incremental.map(({ data, path, errors }) => { return { data: data && path ? buildDataObjectByPath(path, data) : {}, ...getKeyValueIfValid('errors', errors), }; }); return { data: incrementalArray.length === 1 ? incrementalArray[0].data : buildCombinedDataObject([ ...incrementalArray.map(({ data }) => data), ]), ...getKeyValueIfValid('errors', combineErrors(incrementalArray)), hasNext, }; }); } function validateResponseData(responseErrors, combinedData) { if (responseErrors.length > 0) { throw new Error(GQL_API_ERROR, { cause: { graphQLErrors: responseErrors, }, }); } if (Object.keys(combinedData).length === 0) { throw new Error(NO_DATA_OR_ERRORS_ERROR); } } function createMultipartResponseAsyncInterator(response, responseContentType) { const boundaryHeader = (responseContentType ?? '').match(BOUNDARY_HEADER_REGEX); const boundary = `--${boundaryHeader ? boundaryHeader[1] : '-'}`; if (!response.body?.getReader && !response.body?.[Symbol.asyncIterator]) { throw new Error('API multipart response did not return an iterable body', { cause: response, }); } const streamBodyIterator = getStreamBodyIterator(response); let combinedData = {}; let responseExtensions; return { async *[Symbol.asyncIterator]() { try { let streamHasNext = true; for await (const chunkBodies of readStreamChunk(streamBodyIterator, boundary)) { const responseData = getResponseDataFromChunkBodies(chunkBodies); responseExtensions = responseData.find((datum) => datum.extensions)?.extensions ?? responseExtensions; const responseErrors = combineErrors(responseData); combinedData = buildCombinedDataObject([ combinedData, ...responseData.map(({ data }) => data), ]); streamHasNext = responseData.slice(-1)[0].hasNext; validateResponseData(responseErrors, combinedData); yield { ...getKeyValueIfValid('data', combinedData), ...getKeyValueIfValid('extensions', responseExtensions), hasNext: streamHasNext, }; } if (streamHasNext) { throw new Error(`Response stream terminated unexpectedly`); } } catch (error) { const cause = getErrorCause(error); yield { ...getKeyValueIfValid('data', combinedData), ...getKeyValueIfValid('extensions', responseExtensions), errors: { message: formatErrorMessage(getErrorMessage(error)), networkStatusCode: response.status, ...getKeyValueIfValid('graphQLErrors', cause?.graphQLErrors), response, }, hasNext: false, }; } }, }; } function generateRequestStream(fetchFn) { return async (...props) => { if (!DEFER_OPERATION_REGEX.test(props[0])) { throw new Error(formatErrorMessage('This operation does not result in a streamable response - use request() instead.')); } try { const response = await fetchFn(...props); const { statusText } = response; if (!response.ok) { throw new Error(statusText, { cause: response }); } const responseContentType = response.headers.get('content-type') || ''; switch (true) { case responseContentType.includes(CONTENT_TYPES.json): return createJsonResponseAsyncIterator(response); case responseContentType.includes(CONTENT_TYPES.multipart): return createMultipartResponseAsyncInterator(response, responseContentType); default: throw new Error(`${UNEXPECTED_CONTENT_TYPE_ERROR} ${responseContentType}`, { cause: response }); } } catch (error) { return { async *[Symbol.asyncIterator]() { const response = getErrorCause(error); yield { errors: { message: formatErrorMessage(getErrorMessage(error)), ...getKeyValueIfValid('networkStatusCode', response?.status), ...getKeyValueIfValid('response', response), }, hasNext: false, }; }, }; } }; } exports.createGraphQLClient = createGraphQLClient; })); //# sourceMappingURL=graphql-client.js.map