1 |
|
2 | (function (global, factory) {
|
3 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
4 | typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
5 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ShopifyGraphQLClient = {}));
|
6 | })(this, (function (exports) { 'use strict';
|
7 |
|
8 | const CLIENT = 'GraphQL Client';
|
9 | const MIN_RETRIES = 0;
|
10 | const MAX_RETRIES = 3;
|
11 | const GQL_API_ERROR = "An error occurred while fetching from the API. Review 'graphQLErrors' for details.";
|
12 | const UNEXPECTED_CONTENT_TYPE_ERROR = 'Response returned unexpected Content-Type:';
|
13 | 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.';
|
14 | const CONTENT_TYPES = {
|
15 | json: 'application/json',
|
16 | multipart: 'multipart/mixed',
|
17 | };
|
18 | const SDK_VARIANT_HEADER = 'X-SDK-Variant';
|
19 | const SDK_VERSION_HEADER = 'X-SDK-Version';
|
20 | const DEFAULT_SDK_VARIANT = 'shopify-graphql-client';
|
21 |
|
22 | const DEFAULT_CLIENT_VERSION = '0.10.4';
|
23 | const RETRY_WAIT_TIME = 1000;
|
24 | const RETRIABLE_STATUS_CODES = [429, 503];
|
25 | const DEFER_OPERATION_REGEX = /@(defer)\b/i;
|
26 | const NEWLINE_SEPARATOR = '\r\n';
|
27 | const BOUNDARY_HEADER_REGEX = /boundary="?([^=";]+)"?/i;
|
28 | const HEADER_SEPARATOR = NEWLINE_SEPARATOR + NEWLINE_SEPARATOR;
|
29 |
|
30 | function formatErrorMessage(message, client = CLIENT) {
|
31 | return message.startsWith(`${client}`) ? message : `${client}: ${message}`;
|
32 | }
|
33 | function getErrorMessage(error) {
|
34 | return error instanceof Error ? error.message : JSON.stringify(error);
|
35 | }
|
36 | function getErrorCause(error) {
|
37 | return error instanceof Error && error.cause ? error.cause : undefined;
|
38 | }
|
39 | function combineErrors(dataArray) {
|
40 | return dataArray.flatMap(({ errors }) => {
|
41 | return errors ?? [];
|
42 | });
|
43 | }
|
44 | function validateRetries({ client, retries, }) {
|
45 | if (retries !== undefined &&
|
46 | (typeof retries !== 'number' ||
|
47 | retries < MIN_RETRIES ||
|
48 | retries > MAX_RETRIES)) {
|
49 | throw new Error(`${client}: The provided "retries" value (${retries}) is invalid - it cannot be less than ${MIN_RETRIES} or greater than ${MAX_RETRIES}`);
|
50 | }
|
51 | }
|
52 | function getKeyValueIfValid(key, value) {
|
53 | return value &&
|
54 | (typeof value !== 'object' ||
|
55 | Array.isArray(value) ||
|
56 | (typeof value === 'object' && Object.keys(value).length > 0))
|
57 | ? { [key]: value }
|
58 | : {};
|
59 | }
|
60 | function buildDataObjectByPath(path, data) {
|
61 | if (path.length === 0) {
|
62 | return data;
|
63 | }
|
64 | const key = path.pop();
|
65 | const newData = {
|
66 | [key]: data,
|
67 | };
|
68 | if (path.length === 0) {
|
69 | return newData;
|
70 | }
|
71 | return buildDataObjectByPath(path, newData);
|
72 | }
|
73 | function combineObjects(baseObject, newObject) {
|
74 | return Object.keys(newObject || {}).reduce((acc, key) => {
|
75 | if ((typeof newObject[key] === 'object' || Array.isArray(newObject[key])) &&
|
76 | baseObject[key]) {
|
77 | acc[key] = combineObjects(baseObject[key], newObject[key]);
|
78 | return acc;
|
79 | }
|
80 | acc[key] = newObject[key];
|
81 | return acc;
|
82 | }, Array.isArray(baseObject) ? [...baseObject] : { ...baseObject });
|
83 | }
|
84 | function buildCombinedDataObject([initialDatum, ...remainingData]) {
|
85 | return remainingData.reduce(combineObjects, { ...initialDatum });
|
86 | }
|
87 |
|
88 | function generateHttpFetch({ clientLogger, customFetchApi = fetch, client = CLIENT, defaultRetryWaitTime = RETRY_WAIT_TIME, retriableCodes = RETRIABLE_STATUS_CODES, }) {
|
89 | const httpFetch = async (requestParams, count, maxRetries) => {
|
90 | const nextCount = count + 1;
|
91 | const maxTries = maxRetries + 1;
|
92 | let response;
|
93 | try {
|
94 | response = await customFetchApi(...requestParams);
|
95 | clientLogger({
|
96 | type: 'HTTP-Response',
|
97 | content: {
|
98 | requestParams,
|
99 | response,
|
100 | },
|
101 | });
|
102 | if (!response.ok &&
|
103 | retriableCodes.includes(response.status) &&
|
104 | nextCount <= maxTries) {
|
105 | throw new Error();
|
106 | }
|
107 | return response;
|
108 | }
|
109 | catch (error) {
|
110 | if (nextCount <= maxTries) {
|
111 | const retryAfter = response?.headers.get('Retry-After');
|
112 | await sleep(retryAfter ? parseInt(retryAfter, 10) : defaultRetryWaitTime);
|
113 | clientLogger({
|
114 | type: 'HTTP-Retry',
|
115 | content: {
|
116 | requestParams,
|
117 | lastResponse: response,
|
118 | retryAttempt: count,
|
119 | maxRetries,
|
120 | },
|
121 | });
|
122 | return httpFetch(requestParams, nextCount, maxRetries);
|
123 | }
|
124 | throw new Error(formatErrorMessage(`${maxRetries > 0
|
125 | ? `Attempted maximum number of ${maxRetries} network retries. Last message - `
|
126 | : ''}${getErrorMessage(error)}`, client));
|
127 | }
|
128 | };
|
129 | return httpFetch;
|
130 | }
|
131 | async function sleep(waitTime) {
|
132 | return new Promise((resolve) => setTimeout(resolve, waitTime));
|
133 | }
|
134 |
|
135 | function createGraphQLClient({ headers, url, customFetchApi = fetch, retries = 0, logger, }) {
|
136 | validateRetries({ client: CLIENT, retries });
|
137 | const config = {
|
138 | headers,
|
139 | url,
|
140 | retries,
|
141 | };
|
142 | const clientLogger = generateClientLogger(logger);
|
143 | const httpFetch = generateHttpFetch({
|
144 | customFetchApi,
|
145 | clientLogger,
|
146 | defaultRetryWaitTime: RETRY_WAIT_TIME,
|
147 | });
|
148 | const fetch = generateFetch(httpFetch, config);
|
149 | const request = generateRequest(fetch);
|
150 | const requestStream = generateRequestStream(fetch);
|
151 | return {
|
152 | config,
|
153 | fetch,
|
154 | request,
|
155 | requestStream,
|
156 | };
|
157 | }
|
158 | function generateClientLogger(logger) {
|
159 | return (logContent) => {
|
160 | if (logger) {
|
161 | logger(logContent);
|
162 | }
|
163 | };
|
164 | }
|
165 | async function processJSONResponse(response) {
|
166 | const { errors, data, extensions } = await response.json();
|
167 | return {
|
168 | ...getKeyValueIfValid('data', data),
|
169 | ...getKeyValueIfValid('extensions', extensions),
|
170 | ...(errors || !data
|
171 | ? {
|
172 | errors: {
|
173 | networkStatusCode: response.status,
|
174 | message: formatErrorMessage(errors ? GQL_API_ERROR : NO_DATA_OR_ERRORS_ERROR),
|
175 | ...getKeyValueIfValid('graphQLErrors', errors),
|
176 | response,
|
177 | },
|
178 | }
|
179 | : {}),
|
180 | };
|
181 | }
|
182 | function generateFetch(httpFetch, { url, headers, retries }) {
|
183 | return async (operation, options = {}) => {
|
184 | const { variables, headers: overrideHeaders, url: overrideUrl, retries: overrideRetries, } = options;
|
185 | const body = JSON.stringify({
|
186 | query: operation,
|
187 | variables,
|
188 | });
|
189 | validateRetries({ client: CLIENT, retries: overrideRetries });
|
190 | const flatHeaders = Object.entries({
|
191 | ...headers,
|
192 | ...overrideHeaders,
|
193 | }).reduce((headers, [key, value]) => {
|
194 | headers[key] = Array.isArray(value) ? value.join(', ') : value.toString();
|
195 | return headers;
|
196 | }, {});
|
197 | if (!flatHeaders[SDK_VARIANT_HEADER] && !flatHeaders[SDK_VERSION_HEADER]) {
|
198 | flatHeaders[SDK_VARIANT_HEADER] = DEFAULT_SDK_VARIANT;
|
199 | flatHeaders[SDK_VERSION_HEADER] = DEFAULT_CLIENT_VERSION;
|
200 | }
|
201 | const fetchParams = [
|
202 | overrideUrl ?? url,
|
203 | {
|
204 | method: 'POST',
|
205 | headers: flatHeaders,
|
206 | body,
|
207 | },
|
208 | ];
|
209 | return httpFetch(fetchParams, 1, overrideRetries ?? retries);
|
210 | };
|
211 | }
|
212 | function generateRequest(fetch) {
|
213 | return async (...props) => {
|
214 | if (DEFER_OPERATION_REGEX.test(props[0])) {
|
215 | throw new Error(formatErrorMessage('This operation will result in a streamable response - use requestStream() instead.'));
|
216 | }
|
217 | try {
|
218 | const response = await fetch(...props);
|
219 | const { status, statusText } = response;
|
220 | const contentType = response.headers.get('content-type') || '';
|
221 | if (!response.ok) {
|
222 | return {
|
223 | errors: {
|
224 | networkStatusCode: status,
|
225 | message: formatErrorMessage(statusText),
|
226 | response,
|
227 | },
|
228 | };
|
229 | }
|
230 | if (!contentType.includes(CONTENT_TYPES.json)) {
|
231 | return {
|
232 | errors: {
|
233 | networkStatusCode: status,
|
234 | message: formatErrorMessage(`${UNEXPECTED_CONTENT_TYPE_ERROR} ${contentType}`),
|
235 | response,
|
236 | },
|
237 | };
|
238 | }
|
239 | return processJSONResponse(response);
|
240 | }
|
241 | catch (error) {
|
242 | return {
|
243 | errors: {
|
244 | message: getErrorMessage(error),
|
245 | },
|
246 | };
|
247 | }
|
248 | };
|
249 | }
|
250 | async function* getStreamBodyIterator(response) {
|
251 | const decoder = new TextDecoder();
|
252 |
|
253 | if (response.body[Symbol.asyncIterator]) {
|
254 | for await (const chunk of response.body) {
|
255 | yield decoder.decode(chunk);
|
256 | }
|
257 | }
|
258 | else {
|
259 | const reader = response.body.getReader();
|
260 | let readResult;
|
261 | try {
|
262 | while (!(readResult = await reader.read()).done) {
|
263 | yield decoder.decode(readResult.value);
|
264 | }
|
265 | }
|
266 | finally {
|
267 | reader.cancel();
|
268 | }
|
269 | }
|
270 | }
|
271 | function readStreamChunk(streamBodyIterator, boundary) {
|
272 | return {
|
273 | async *[Symbol.asyncIterator]() {
|
274 | try {
|
275 | let buffer = '';
|
276 | for await (const textChunk of streamBodyIterator) {
|
277 | buffer += textChunk;
|
278 | if (buffer.indexOf(boundary) > -1) {
|
279 | const lastBoundaryIndex = buffer.lastIndexOf(boundary);
|
280 | const fullResponses = buffer.slice(0, lastBoundaryIndex);
|
281 | const chunkBodies = fullResponses
|
282 | .split(boundary)
|
283 | .filter((chunk) => chunk.trim().length > 0)
|
284 | .map((chunk) => {
|
285 | const body = chunk
|
286 | .slice(chunk.indexOf(HEADER_SEPARATOR) + HEADER_SEPARATOR.length)
|
287 | .trim();
|
288 | return body;
|
289 | });
|
290 | if (chunkBodies.length > 0) {
|
291 | yield chunkBodies;
|
292 | }
|
293 | buffer = buffer.slice(lastBoundaryIndex + boundary.length);
|
294 | if (buffer.trim() === `--`) {
|
295 | buffer = '';
|
296 | }
|
297 | }
|
298 | }
|
299 | }
|
300 | catch (error) {
|
301 | throw new Error(`Error occured while processing stream payload - ${getErrorMessage(error)}`);
|
302 | }
|
303 | },
|
304 | };
|
305 | }
|
306 | function createJsonResponseAsyncIterator(response) {
|
307 | return {
|
308 | async *[Symbol.asyncIterator]() {
|
309 | const processedResponse = await processJSONResponse(response);
|
310 | yield {
|
311 | ...processedResponse,
|
312 | hasNext: false,
|
313 | };
|
314 | },
|
315 | };
|
316 | }
|
317 | function getResponseDataFromChunkBodies(chunkBodies) {
|
318 | return chunkBodies
|
319 | .map((value) => {
|
320 | try {
|
321 | return JSON.parse(value);
|
322 | }
|
323 | catch (error) {
|
324 | throw new Error(`Error in parsing multipart response - ${getErrorMessage(error)}`);
|
325 | }
|
326 | })
|
327 | .map((payload) => {
|
328 | const { data, incremental, hasNext, extensions, errors } = payload;
|
329 |
|
330 | if (!incremental) {
|
331 | return {
|
332 | data: data || {},
|
333 | ...getKeyValueIfValid('errors', errors),
|
334 | ...getKeyValueIfValid('extensions', extensions),
|
335 | hasNext,
|
336 | };
|
337 | }
|
338 |
|
339 | const incrementalArray = incremental.map(({ data, path, errors }) => {
|
340 | return {
|
341 | data: data && path ? buildDataObjectByPath(path, data) : {},
|
342 | ...getKeyValueIfValid('errors', errors),
|
343 | };
|
344 | });
|
345 | return {
|
346 | data: incrementalArray.length === 1
|
347 | ? incrementalArray[0].data
|
348 | : buildCombinedDataObject([
|
349 | ...incrementalArray.map(({ data }) => data),
|
350 | ]),
|
351 | ...getKeyValueIfValid('errors', combineErrors(incrementalArray)),
|
352 | hasNext,
|
353 | };
|
354 | });
|
355 | }
|
356 | function validateResponseData(responseErrors, combinedData) {
|
357 | if (responseErrors.length > 0) {
|
358 | throw new Error(GQL_API_ERROR, {
|
359 | cause: {
|
360 | graphQLErrors: responseErrors,
|
361 | },
|
362 | });
|
363 | }
|
364 | if (Object.keys(combinedData).length === 0) {
|
365 | throw new Error(NO_DATA_OR_ERRORS_ERROR);
|
366 | }
|
367 | }
|
368 | function createMultipartResponseAsyncInterator(response, responseContentType) {
|
369 | const boundaryHeader = (responseContentType ?? '').match(BOUNDARY_HEADER_REGEX);
|
370 | const boundary = `--${boundaryHeader ? boundaryHeader[1] : '-'}`;
|
371 | if (!response.body?.getReader &&
|
372 | !response.body[Symbol.asyncIterator]) {
|
373 | throw new Error('API multipart response did not return an iterable body', {
|
374 | cause: response,
|
375 | });
|
376 | }
|
377 | const streamBodyIterator = getStreamBodyIterator(response);
|
378 | let combinedData = {};
|
379 | let responseExtensions;
|
380 | return {
|
381 | async *[Symbol.asyncIterator]() {
|
382 | try {
|
383 | let streamHasNext = true;
|
384 | for await (const chunkBodies of readStreamChunk(streamBodyIterator, boundary)) {
|
385 | const responseData = getResponseDataFromChunkBodies(chunkBodies);
|
386 | responseExtensions =
|
387 | responseData.find((datum) => datum.extensions)?.extensions ??
|
388 | responseExtensions;
|
389 | const responseErrors = combineErrors(responseData);
|
390 | combinedData = buildCombinedDataObject([
|
391 | combinedData,
|
392 | ...responseData.map(({ data }) => data),
|
393 | ]);
|
394 | streamHasNext = responseData.slice(-1)[0].hasNext;
|
395 | validateResponseData(responseErrors, combinedData);
|
396 | yield {
|
397 | ...getKeyValueIfValid('data', combinedData),
|
398 | ...getKeyValueIfValid('extensions', responseExtensions),
|
399 | hasNext: streamHasNext,
|
400 | };
|
401 | }
|
402 | if (streamHasNext) {
|
403 | throw new Error(`Response stream terminated unexpectedly`);
|
404 | }
|
405 | }
|
406 | catch (error) {
|
407 | const cause = getErrorCause(error);
|
408 | yield {
|
409 | ...getKeyValueIfValid('data', combinedData),
|
410 | ...getKeyValueIfValid('extensions', responseExtensions),
|
411 | errors: {
|
412 | message: formatErrorMessage(getErrorMessage(error)),
|
413 | networkStatusCode: response.status,
|
414 | ...getKeyValueIfValid('graphQLErrors', cause?.graphQLErrors),
|
415 | response,
|
416 | },
|
417 | hasNext: false,
|
418 | };
|
419 | }
|
420 | },
|
421 | };
|
422 | }
|
423 | function generateRequestStream(fetch) {
|
424 | return async (...props) => {
|
425 | if (!DEFER_OPERATION_REGEX.test(props[0])) {
|
426 | throw new Error(formatErrorMessage('This operation does not result in a streamable response - use request() instead.'));
|
427 | }
|
428 | try {
|
429 | const response = await fetch(...props);
|
430 | const { statusText } = response;
|
431 | if (!response.ok) {
|
432 | throw new Error(statusText, { cause: response });
|
433 | }
|
434 | const responseContentType = response.headers.get('content-type') || '';
|
435 | switch (true) {
|
436 | case responseContentType.includes(CONTENT_TYPES.json):
|
437 | return createJsonResponseAsyncIterator(response);
|
438 | case responseContentType.includes(CONTENT_TYPES.multipart):
|
439 | return createMultipartResponseAsyncInterator(response, responseContentType);
|
440 | default:
|
441 | throw new Error(`${UNEXPECTED_CONTENT_TYPE_ERROR} ${responseContentType}`, { cause: response });
|
442 | }
|
443 | }
|
444 | catch (error) {
|
445 | return {
|
446 | async *[Symbol.asyncIterator]() {
|
447 | const response = getErrorCause(error);
|
448 | yield {
|
449 | errors: {
|
450 | message: formatErrorMessage(getErrorMessage(error)),
|
451 | ...getKeyValueIfValid('networkStatusCode', response?.status),
|
452 | ...getKeyValueIfValid('response', response),
|
453 | },
|
454 | hasNext: false,
|
455 | };
|
456 | },
|
457 | };
|
458 | }
|
459 | };
|
460 | }
|
461 |
|
462 | exports.createGraphQLClient = createGraphQLClient;
|
463 |
|
464 | }));
|
465 |
|