@shopify/graphql-client
Version:
Shopify GraphQL Client - A lightweight generic GraphQL JS client to interact with Shopify GraphQL APIs
510 lines (450 loc) • 13 kB
text/typescript
import {generateHttpFetch} from './http-fetch';
import {
ClientOptions,
CustomFetchApi,
GraphQLClient,
ClientResponse,
ClientConfig,
Logger,
LogContentTypes,
DataChunk,
} from './types';
import {
CLIENT,
GQL_API_ERROR,
UNEXPECTED_CONTENT_TYPE_ERROR,
NO_DATA_OR_ERRORS_ERROR,
CONTENT_TYPES,
RETRY_WAIT_TIME,
HEADER_SEPARATOR,
DEFER_OPERATION_REGEX,
BOUNDARY_HEADER_REGEX,
SDK_VARIANT_HEADER,
SDK_VERSION_HEADER,
DEFAULT_SDK_VARIANT,
DEFAULT_CLIENT_VERSION,
} from './constants';
import {
formatErrorMessage,
getErrorMessage,
validateRetries,
getKeyValueIfValid,
buildDataObjectByPath,
buildCombinedDataObject,
getErrorCause,
combineErrors,
} from './utilities';
export function createGraphQLClient({
headers,
url,
customFetchApi = fetch,
retries = 0,
logger,
}: ClientOptions): GraphQLClient {
validateRetries({client: CLIENT, retries});
const config: ClientConfig = {
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,
};
}
export function generateClientLogger(logger?: Logger): Logger {
return (logContent: LogContentTypes) => {
if (logger) {
logger(logContent);
}
};
}
async function processJSONResponse<TData = any>(
response: Response,
): Promise<ClientResponse<TData>> {
const {errors, data, extensions} = await response.json<any>();
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: ReturnType<typeof generateHttpFetch>,
{url, headers, retries}: ClientConfig,
): GraphQLClient['fetch'] {
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: Record<string, string>, [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: Parameters<CustomFetchApi> = [
overrideUrl ?? url,
{
method: 'POST',
headers: flatHeaders,
body,
signal,
keepalive,
},
];
return httpFetch(fetchParams, 1, overrideRetries ?? retries);
};
}
function generateRequest(
fetchFn: ReturnType<typeof generateFetch>,
): GraphQLClient['request'] {
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.',
),
);
}
let response: Response | null = null;
try {
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 await processJSONResponse(response);
} catch (error) {
return {
errors: {
message: getErrorMessage(error),
...(response == null
? {}
: {
networkStatusCode: response.status,
response,
}),
},
};
}
};
}
async function* getStreamBodyIterator(
response: Response,
): AsyncIterableIterator<string> {
const decoder = new TextDecoder();
// Response body is an async iterator
if ((response.body as any)![Symbol.asyncIterator]) {
for await (const chunk of response.body! as any) {
yield decoder.decode(chunk);
}
} else {
const reader = response.body!.getReader();
let readResult: ReadableStreamReadResult<DataChunk>;
try {
while (!(readResult = await reader.read()).done) {
yield decoder.decode(readResult.value);
}
} finally {
reader.cancel();
}
}
}
function readStreamChunk(
streamBodyIterator: AsyncIterableIterator<string>,
boundary: string,
) {
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: Response) {
return {
async *[Symbol.asyncIterator]() {
try {
const processedResponse = await processJSONResponse(response);
yield {
...processedResponse,
hasNext: false,
};
} catch (error) {
yield {
errors: {
message: formatErrorMessage(getErrorMessage(error)),
networkStatusCode: response.status,
response,
},
hasNext: false,
};
}
},
};
}
function getResponseDataFromChunkBodies(chunkBodies: string[]): {
data: any;
errors?: any;
extensions?: any;
hasNext: boolean;
}[] {
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: {data: any; errors?: any}[] = incremental.map(
({data, path, errors}: any) => {
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: any[],
combinedData: ReturnType<typeof buildCombinedDataObject>,
) {
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: Response,
responseContentType: string,
) {
const boundaryHeader = (responseContentType ?? '').match(
BOUNDARY_HEADER_REGEX,
);
const boundary = `--${boundaryHeader ? boundaryHeader[1] : '-'}`;
if (
!response.body?.getReader &&
!(response.body as any)?.[Symbol.asyncIterator]
) {
throw new Error('API multipart response did not return an iterable body', {
cause: response,
});
}
const streamBodyIterator = getStreamBodyIterator(response);
let combinedData: Record<string, any> = {};
let responseExtensions: Record<string, any> | undefined;
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: ReturnType<typeof generateFetch>,
): GraphQLClient['requestStream'] {
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,
};
},
};
}
};
}