@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
JavaScript
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