@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
JavaScript
/*! 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