UNPKG

@shopify/graphql-client

Version:

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

337 lines (334 loc) 13 kB
import { generateHttpFetch } from './http-fetch.mjs'; import { CLIENT, SDK_VARIANT_HEADER, SDK_VERSION_HEADER, DEFAULT_SDK_VARIANT, DEFAULT_CLIENT_VERSION, DEFER_OPERATION_REGEX, UNEXPECTED_CONTENT_TYPE_ERROR, CONTENT_TYPES, BOUNDARY_HEADER_REGEX, GQL_API_ERROR, NO_DATA_OR_ERRORS_ERROR, RETRY_WAIT_TIME, HEADER_SEPARATOR } from './constants.mjs'; import { validateRetries, formatErrorMessage, getErrorCause, getKeyValueIfValid, getErrorMessage, combineErrors, buildCombinedDataObject, buildDataObjectByPath } from './utilities.mjs'; 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, }; }, }; } }; } export { createGraphQLClient, generateClientLogger }; //# sourceMappingURL=graphql-client.mjs.map