From 9df4bacfbf3161f6982575cf139dc5e78a45b0a6 Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Mon, 20 Nov 2023 16:41:36 -0500 Subject: [PATCH] Add code for streamed API responses Add unit tests for responseStream() related functionalities and updated error messages Add tests for requestStream and refactor tests for efficiency Update README to include requestStream info Add requestStream() to Storefront API Client Add tests for requestStream in Storefront Api Client Reorganize and split graphql-client tests Refactor unit tests and update api version validation Fix rebase issues Add requestStream info to SFAPI README Rebase to main and fix linting and test errors Update READMEs for correct header type Update ClientStreamResponse.data type Update client to support 2022 stream response format Fix Admin API client README Add changelog entry use .reduce instead of Object.fromEntries for browser support Update tests to test array style custom headers Refactor domain validation logic for clarity Address PR review comments and consolidated a few tests As per PR review comments, reorg the code to make it more readable Clean up chunked response data types Address PR review comments in utilities --- .changeset/orange-rats-juggle.md | 7 + packages/admin-api-client/README.md | 10 +- .../src/graphql/tests/client.test.ts | 1 + packages/graphql-client/README.md | 59 +- .../src/api-client-utilities/types.ts | 11 + .../src/api-client-utilities/validations.ts | 7 +- .../src/graphql-client/constants.ts | 4 + .../src/graphql-client/graphql-client.ts | 322 +++- .../tests/graphql-client.test.ts | 1003 ------------ .../client-fetch-request.test.ts | 845 ++++++++++ .../graphql-client/client-init-config.test.ts | 70 + .../client-requestStream.test.ts | 1417 +++++++++++++++++ .../tests/graphql-client/common-tests.ts | 166 ++ .../tests/graphql-client/fixtures.ts | 106 ++ .../graphql-client/tests/utilities.test.ts | 196 +++ .../src/graphql-client/types.ts | 15 + .../src/graphql-client/utilities.ts | 55 + packages/storefront-api-client/README.md | 60 +- .../src/storefront-api-client.ts | 3 + .../tests/storefront-api-client/fixtures.ts | 1 + .../storefront-api-client.test.ts | 327 ++-- packages/storefront-api-client/src/types.ts | 5 +- 22 files changed, 3453 insertions(+), 1237 deletions(-) create mode 100644 .changeset/orange-rats-juggle.md delete mode 100644 packages/graphql-client/src/graphql-client/tests/graphql-client.test.ts create mode 100644 packages/graphql-client/src/graphql-client/tests/graphql-client/client-fetch-request.test.ts create mode 100644 packages/graphql-client/src/graphql-client/tests/graphql-client/client-init-config.test.ts create mode 100644 packages/graphql-client/src/graphql-client/tests/graphql-client/client-requestStream.test.ts create mode 100644 packages/graphql-client/src/graphql-client/tests/graphql-client/common-tests.ts create mode 100644 packages/graphql-client/src/graphql-client/tests/graphql-client/fixtures.ts diff --git a/.changeset/orange-rats-juggle.md b/.changeset/orange-rats-juggle.md new file mode 100644 index 000000000..944b0955c --- /dev/null +++ b/.changeset/orange-rats-juggle.md @@ -0,0 +1,7 @@ +--- +"@shopify/storefront-api-client": minor +"@shopify/graphql-client": minor +"@shopify/admin-api-client": patch +--- + +Add new `requestStream()` function to support streamed responses from Storefront API diff --git a/packages/admin-api-client/README.md b/packages/admin-api-client/README.md index 4759ff49a..0bb4b78e1 100644 --- a/packages/admin-api-client/README.md +++ b/packages/admin-api-client/README.md @@ -65,7 +65,7 @@ const {data, errors, extensions} = await client.request(operation, { | Property | Type | Description | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | config | [`AdminApiClientConfig`](#adminapiclientconfig-properties) | Configuration for the client | -| getHeaders | `(headers?: {[key: string]: string}) => {[key: string]: string` | Returns Admin API specific headers needed to interact with the API. If additional `headers` are provided, the custom headers will be included in the returned headers object. | +| getHeaders | `(headers?: Record) => Record` | Returns Admin API specific headers needed to interact with the API. If additional `headers` are provided, the custom headers will be included in the returned headers object. | | getApiUrl | `(apiVersion?: string) => string` | Returns the shop specific API url. If an API version is provided, the returned URL will include the provided version, else the URL will include the API version set at client initialization. | | fetch | `(operation: string, options?:`[`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties)`) => Promise` | Fetches data from Admin API using the provided GQL `operation` string and [`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties) object and returns the network response. | | request | `(operation: string, options?:`[`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties)`) => Promise<`[`ClientResponse`](#clientresponsetdata)`>` | Requests data from Admin API using the provided GQL `operation` string and [`AdminAPIClientRequestOptions`](#adminapiclientrequestoptions-properties) object and returns a normalized response object. | @@ -77,7 +77,7 @@ const {data, errors, extensions} = await client.request(operation, { | storeDomain | `string` | The `myshopify.com` domain | | apiVersion | `string` | The Admin API version to use in the API request | | accessToken | `string` | The provided public access token. If `privateAccessToken` was provided, `publicAccessToken` will not be available. | -| headers | `{[key: string]: string}` | The headers generated by the client during initialization | +| headers | `Record` | The headers generated by the client during initialization | | apiUrl | `string` | The API URL generated from the provided store domain and api version | | retries? | `number` | The number of retries the client will attempt when the API responds with a `Too Many Requests (429)` or `Service Unavailable (503)` response | @@ -85,9 +85,9 @@ const {data, errors, extensions} = await client.request(operation, { | Name | Type | Description | | -------------- | ------------------------ | ---------------------------------------------------- | -| variables? | `{[key: string]: any}` | Variable values needed in the graphQL operation | +| variables? | `Record` | Variable values needed in the graphQL operation | | apiVersion? | `string` | The Admin API version to use in the API request | -| headers? | `{[key: string]: string}`| Customized headers to be included in the API request | +| headers? | `Record`| Customized headers to be included in the API request | | retries? | `number` | Alternative number of retries for the request. Retries only occur for requests that were abandoned or if the server responds with a `Too Many Request (429)` or `Service Unavailable (503)` response. Minimum value is `0` and maximum value is `3`. | ### `ClientResponse` @@ -96,7 +96,7 @@ const {data, errors, extensions} = await client.request(operation, { | ----------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | data? | `TData \| any` | Data returned from the Admin API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. | | errors? | [`ResponseErrors`](#responseerrors) | Error object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. | -| extensions? | `{[key: string]: any}` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the localization context information used to generate the returned API response. | +| extensions? | `Record` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the localization context information used to generate the returned API response. | ### `ResponseErrors` diff --git a/packages/admin-api-client/src/graphql/tests/client.test.ts b/packages/admin-api-client/src/graphql/tests/client.test.ts index b96c9004e..3b65f5eb8 100644 --- a/packages/admin-api-client/src/graphql/tests/client.test.ts +++ b/packages/admin-api-client/src/graphql/tests/client.test.ts @@ -38,6 +38,7 @@ describe("Admin API Client", () => { }, fetch: jest.fn(), request: jest.fn(), + requestStream: jest.fn(), }; beforeEach(() => { diff --git a/packages/graphql-client/README.md b/packages/graphql-client/README.md index 7378b9044..7e2d1d4ab 100644 --- a/packages/graphql-client/README.md +++ b/packages/graphql-client/README.md @@ -68,7 +68,7 @@ const client = createGraphQLClient({ | Property | Type | Description | | -------- | ------------------------ | ---------------------------------- | | url | `string` | The GraphQL API URL | -| headers | `{[key: string]: string}` | Headers to be included in requests | +| headers | `Record` | Headers to be included in requests | | retries? | `number` | The number of HTTP request retries if the request was abandoned or the server responded with a `Too Many Requests (429)` or `Service Unavailable (503)` response. Default value is `0`. Maximum value is `3`. | | customFetchApi? | `(url: string, init?: {method?: string, headers?: HeaderInit, body?: string}) => Promise` | A replacement `fetch` function that will be used in all client network requests. By default, the client uses `window.fetch()`. | | logger? | `(logContent: `[`HTTPResponseLog`](#httpresponselog)`\|`[`HTTPRetryLog`](#httpretrylog)`) => void` | A logger function that accepts [log content objects](#log-content-types). This logger will be called in certain conditions with contextual information. | @@ -77,17 +77,18 @@ const client = createGraphQLClient({ | Property | Type | Description | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| config | `{url: string, headers: {[key: string]: string}, retries: number}` | Configuration for the client | +| config | `{url: string, headers: Record, retries: number}` | Configuration for the client | | fetch | `(operation: string, options?: `[`RequestOptions`](#requestoptions-properties)`) => Promise` | Fetches data from the GraphQL API using the provided GQL operation string and [`RequestOptions`](#requestoptions-properties) object and returns the network response | | request | `(operation: string, options?: `[`RequestOptions`](#requestoptions-properties)`) => Promise<`[`ClientResponse`](#ClientResponsetdata)`>` | Fetches data from the GraphQL API using the provided GQL operation string and [`RequestOptions`](#requestoptions-properties) object and returns a [normalized response object](#clientresponsetdata) | +| requestStream | `(operation: string, options?: `[`RequestOptions`](#requestoptions-properties)`) => Promise `](#clientstreamresponsetdata)`>>` | Fetches GQL operations that can result in a streamed response from the API. The function returns an async iterator and the iterator will return [normalized stream response objects](#clientstreamresponsetdata) as data becomes available through the stream. | ## `RequestOptions` properties | Name | Type | Description | | ---------- | --------------------- | ---------------------------------------------------------------- | -| variables? | `{[key: string]: any}` | Variable values needed in the graphQL operation | +| variables? | `Record` | Variable values needed in the graphQL operation | | url? | `string` | Alternative request API URL | -| headers? | `{[key: string]: string}` | Additional and/or replacement headers to be used in the request | +| headers? | `Record` | Additional and/or replacement headers to be used in the request | | retries? | `number` | Alternative number of retries for the request. Retries only occur for requests that were abandoned or if the server responds with a `Too Many Request (429)` or `Service Unavailable (503)` response. Minimum value is `0` and maximum value is `3`.| ## `ClientResponse` @@ -96,7 +97,16 @@ const client = createGraphQLClient({ | ----------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | data? | `TData \| any` | Data returned from the GraphQL API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. | | errors? | [`ResponseErrors`](#responseerrors) | Errors object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. | -| extensions? | `{[key: string]: any}` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. | +| extensions? | `Record` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. | + +## `ClientStreamResponse` + +| Name | Type | Description | +| ----------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| data? | `Partial \| any` | Currently available data returned from the GraphQL API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. | +| errors? | [`ResponseErrors`](#responseerrors) | Errors object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. | +| extensions? | `Record` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. | +| hasNext | `boolean` | Flag to indicate whether the response stream has more incoming data | ## `ResponseErrors` @@ -107,7 +117,6 @@ const client = createGraphQLClient({ | graphQLErrors? | `any[]` | The GraphQL API errors returned by the server | | response? | `Response` | The raw response object from the network fetch call | - ## Usage examples ### Query for a product @@ -130,6 +139,32 @@ const {data, errors, extensions} = await client.request(productQuery, { }); ``` +### Query for product info using the `@defer` directive + +```typescript +const productQuery = ` + query ProductQuery($handle: String) { + product(handle: $handle) { + id + handle + ... @defer(label: "deferredFields") { + title + description + } + } + } +`; + +const responseStream = await client.requestStream(productQuery, { + variables: {handle: 'sample-product'}, +}); + +// await available data from the async iterator +for await (const response of responseStream) { + const {data, errors, extensions, hasNext} = response; +} +``` + ### Add additional custom headers to the API request ```typescript @@ -192,14 +227,15 @@ const {data, errors, extensions} = await client.request(shopQuery, { }); ``` -### Provide GQL query type to `client.request()` +### Provide GQL query type to `client.request()` and `client.requestStream()` ```typescript import {print} from 'graphql/language'; // GQL operation types are usually auto generated during the application build -import {CollectionQuery} from 'types/appTypes'; +import {CollectionQuery, CollectionDeferredQuery} from 'types/appTypes'; import collectionQuery from './collectionQuery.graphql'; +import collectionDeferredQuery from './collectionDeferredQuery.graphql'; const {data, errors, extensions} = await client.request( print(collectionQuery), @@ -209,6 +245,13 @@ const {data, errors, extensions} = await client.request( }, } ); + +const responseStream = await client.requestStream( + print(collectionDeferredQuery), + { + variables: {handle: 'sample-collection'}, + } +); ``` ### Using `client.fetch()` to get API data diff --git a/packages/graphql-client/src/api-client-utilities/types.ts b/packages/graphql-client/src/api-client-utilities/types.ts index 79fb7a74d..6e75906a3 100644 --- a/packages/graphql-client/src/api-client-utilities/types.ts +++ b/packages/graphql-client/src/api-client-utilities/types.ts @@ -5,6 +5,7 @@ import { Headers, ClientResponse, FetchResponseBody, + ClientStreamIterator, } from "../graphql-client/types"; import { @@ -81,6 +82,16 @@ export type ApiClientRequest = > >; +export type ApiClientRequestStream< + Operations extends AllOperations = AllOperations, +> = ( + ...params: ApiClientRequestParams +) => Promise< + ClientStreamIterator< + TData extends undefined ? ReturnData : TData + > +>; + export interface ApiClient< TClientConfig extends ApiClientConfig = ApiClientConfig, Operations extends AllOperations = AllOperations, diff --git a/packages/graphql-client/src/api-client-utilities/validations.ts b/packages/graphql-client/src/api-client-utilities/validations.ts index 39ceec23c..e3a3f0cd0 100644 --- a/packages/graphql-client/src/api-client-utilities/validations.ts +++ b/packages/graphql-client/src/api-client-utilities/validations.ts @@ -14,10 +14,9 @@ export function validateDomainAndGetStoreUrl({ const trimmedDomain = storeDomain.trim(); - const protocolUrl = - trimmedDomain.startsWith("http:") || trimmedDomain.startsWith("https:") - ? trimmedDomain - : `https://${trimmedDomain}`; + const protocolUrl = trimmedDomain.match(/^https?:/) + ? trimmedDomain + : `https://${trimmedDomain}`; const url = new URL(protocolUrl); url.protocol = "https"; diff --git a/packages/graphql-client/src/graphql-client/constants.ts b/packages/graphql-client/src/graphql-client/constants.ts index 73a220ffc..3ad1fdc80 100644 --- a/packages/graphql-client/src/graphql-client/constants.ts +++ b/packages/graphql-client/src/graphql-client/constants.ts @@ -16,3 +16,7 @@ export const CONTENT_TYPES = { export const RETRY_WAIT_TIME = 1000; export const RETRIABLE_STATUS_CODES = [429, 503]; +export const DEFER_OPERATION_REGEX = /@(defer)\b/i; +export const NEWLINE_SEPARATOR = "\r\n"; +export const BOUNDARY_HEADER_REGEX = /boundary="?([^=";]+)"?/i; +export const HEADER_SEPARATOR = NEWLINE_SEPARATOR + NEWLINE_SEPARATOR; diff --git a/packages/graphql-client/src/graphql-client/graphql-client.ts b/packages/graphql-client/src/graphql-client/graphql-client.ts index 179b8d87d..ada2027ac 100644 --- a/packages/graphql-client/src/graphql-client/graphql-client.ts +++ b/packages/graphql-client/src/graphql-client/graphql-client.ts @@ -7,6 +7,7 @@ import { ClientConfig, Logger, LogContentTypes, + DataChunk, } from "./types"; import { CLIENT, @@ -15,12 +16,19 @@ import { NO_DATA_OR_ERRORS_ERROR, CONTENT_TYPES, RETRY_WAIT_TIME, + HEADER_SEPARATOR, + DEFER_OPERATION_REGEX, + BOUNDARY_HEADER_REGEX, } from "./constants"; import { + formatErrorMessage, getErrorMessage, validateRetries, getKeyValueIfValid, - formatErrorMessage, + buildDataObjectByPath, + buildCombinedDataObject, + getErrorCause, + combineErrors, } from "./utilities"; export function createGraphQLClient({ @@ -46,11 +54,13 @@ export function createGraphQLClient({ }); const fetch = generateFetch(httpFetch, config); const request = generateRequest(fetch); + const requestStream = generateRequestStream(fetch); return { config, fetch, request, + requestStream, }; } @@ -104,12 +114,13 @@ function generateFetch( validateRetries({ client: CLIENT, retries: overrideRetries }); - const flatHeaders = Object.fromEntries( - Object.entries({ ...headers, ...overrideHeaders }).map(([key, value]) => [ - key, - Array.isArray(value) ? value.join(", ") : value.toString(), - ]), - ); + const flatHeaders = Object.entries({ + ...headers, + ...overrideHeaders, + }).reduce((headers: Record, [key, value]) => { + headers[key] = Array.isArray(value) ? value.join(", ") : value.toString(); + return headers; + }, {}); const fetchParams: Parameters = [ overrideUrl ?? url, @@ -128,6 +139,14 @@ function generateRequest( fetch: ReturnType, ): 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.", + ), + ); + } + try { const response = await fetch(...props); const { status, statusText } = response; @@ -165,3 +184,292 @@ function generateRequest( } }; } + +async function* getStreamBodyIterator( + response: Response, +): AsyncIterableIterator { + // Support node-fetch format + if ((response.body as any)![Symbol.asyncIterator]) { + for await (const chunk of response.body! as any) { + yield (chunk as Buffer).toString(); + } + } else { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + + let readResult: ReadableStreamReadResult; + try { + while (!(readResult = await reader.read()).done) { + yield decoder.decode(readResult.value); + } + } finally { + reader.cancel(); + } + } +} + +function readStreamChunk( + streamBodyIterator: AsyncIterableIterator, + 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]() { + const processedResponse = await processJSONResponse(response); + + yield { + ...processedResponse, + 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, +) { + 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 = {}; + let responseExtensions: Record | 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( + fetch: ReturnType, +): 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 fetch(...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, + }; + }, + }; + } + }; +} diff --git a/packages/graphql-client/src/graphql-client/tests/graphql-client.test.ts b/packages/graphql-client/src/graphql-client/tests/graphql-client.test.ts deleted file mode 100644 index 9b935399e..000000000 --- a/packages/graphql-client/src/graphql-client/tests/graphql-client.test.ts +++ /dev/null @@ -1,1003 +0,0 @@ -import fetchMock from "jest-fetch-mock"; - -import { createGraphQLClient } from "../graphql-client"; -import { - GraphQLClient, - RequestOptions, - LogContentTypes, - ClientOptions, -} from "../types"; - -export const globalFetchMock = JSON.stringify({ data: {} }); - -export const config = { - url: "http://test-store.myshopify.com/api/2023-10/graphql.json", - headers: { - "Content-Type": "application/json", - "X-Shopify-Storefront-Access-Token": "public-token", - }, -}; - -export function getValidClient({ - retries, - logger, -}: { - retries?: number; - logger?: (logContent: LogContentTypes) => void; -} = {}) { - const updatedConfig: ClientOptions = { ...config }; - - if (typeof retries === "number") { - updatedConfig.retries = retries; - } - - if (logger !== undefined) { - updatedConfig.logger = logger; - } - - return createGraphQLClient(updatedConfig); -} - -export const operation = ` -query { - shop { - name - } -} -`; - -export const variables = {}; - -describe("GraphQL Client", () => { - let mockLogger: jest.Mock; - - fetchMock.enableMocks(); - - beforeEach(() => { - jest - .spyOn(global, "setTimeout") - .mockImplementation(jest.fn((resolve) => resolve() as any)); - fetchMock.mockResponse(() => Promise.resolve(globalFetchMock)); - mockLogger = jest.fn(); - }); - - afterEach(() => { - fetchMock.resetMocks(); - jest.restoreAllMocks(); - }); - - describe("createGraphQLClient()", () => { - describe("client initialization", () => { - it("returns a client object that contains a config object and request and fetch function", () => { - const client = getValidClient(); - expect(client).toHaveProperty("config"); - expect(client).toMatchObject({ - request: expect.any(Function), - fetch: expect.any(Function), - }); - }); - - it("throws an error when the retries config value is less than 0", () => { - const retries = -1; - expect(() => getValidClient({ retries })).toThrowError( - `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, - ); - }); - - it("throws an error when the retries config value is greater than 3", () => { - const retries = 4; - expect(() => getValidClient({ retries })).toThrowError( - `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, - ); - }); - }); - - describe("config object", () => { - it("returns a config object that includes the url", () => { - const client = getValidClient(); - expect(client.config.url).toBe(config.url); - }); - - it("returns a config object that includes the headers", () => { - const client = getValidClient(); - expect(client.config.headers).toBe(config.headers); - }); - - it("returns a config object that includes the default retries value when it is not provided at initialization", () => { - const client = getValidClient(); - expect(client.config.retries).toBe(0); - }); - - it("returns a config object that includes the provided retries value", () => { - const retries = 3; - const client = getValidClient({ retries }); - expect(client.config.retries).toBe(retries); - }); - }); - - describe("fetch()", () => { - it("uses the global fetch when a custom fetch API is not provided at initialization ", () => { - const client = getValidClient(); - - client.fetch(operation, { - variables, - }); - - expect(fetch).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - variables, - }), - }); - }); - - it("uses the provided custom fetch when a custom fetch API is provided at initialization ", () => { - const customFetchApi = jest - .fn() - .mockResolvedValue(new Response(JSON.stringify({ data: {} }))) as any; - - const client = createGraphQLClient({ - ...config, - customFetchApi, - }); - - const props: [string, RequestOptions] = [ - operation, - { - variables, - }, - ]; - - client.fetch(...props); - - expect(customFetchApi).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - variables, - }), - }); - expect(fetch).not.toHaveBeenCalled(); - }); - - describe("calling the function", () => { - let client: GraphQLClient; - - beforeEach(() => { - client = getValidClient(); - }); - - it("returns the HTTP response", async () => { - const response = await client.fetch(operation); - expect(response.status).toBe(200); - }); - - it("logs the request and response info if a logger is provided", async () => { - const client = getValidClient({ logger: mockLogger }); - - const response = await client.fetch(operation); - expect(response.status).toBe(200); - expect(mockLogger).toBeCalledWith({ - type: "HTTP-Response", - content: { - response, - requestParams: [ - config.url, - { - method: "POST", - body: JSON.stringify({ query: operation }), - headers: config.headers, - }, - ], - }, - }); - }); - - describe("fetch parameters", () => { - it("calls fetch API with provided operation", async () => { - await client.fetch(operation); - expect(fetch).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - }), - }); - }); - - it("calls fetch API with provided variables", async () => { - await client.fetch(operation, { variables }); - expect(fetch).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - variables, - }), - }); - }); - - it("calls fetch API with provided url override", async () => { - const url = - "http://test-store.myshopify.com/api/2023-07/graphql.json"; - await client.fetch(operation, { url }); - expect(fetch).toHaveBeenCalledWith(url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - }), - }); - }); - - it("calls fetch API with provided headers override", async () => { - const headers = { - "Content-Type": "application/graphql", - "custom-header": "custom-headers", - }; - - await client.fetch(operation, { headers }); - expect(fetch).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: { ...config.headers, ...headers }, - body: JSON.stringify({ - query: operation, - }), - }); - }); - }); - }); - }); - - describe("request()", () => { - it("uses the global fetch when a custom fetch API is not provided at initialization", () => { - const client = getValidClient(); - - client.request(operation, { - variables, - }); - - expect(fetch).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - variables, - }), - }); - }); - - it("uses the provided custom fetch when a custom fetch API is provided at initialization", () => { - const customFetchApi = jest - .fn() - .mockResolvedValue(new Response(JSON.stringify({ data: {} }))) as any; - - const client = createGraphQLClient({ - ...config, - customFetchApi, - }); - - const props: [string, RequestOptions] = [ - operation, - { - variables, - }, - ]; - - client.request(...props); - - expect(customFetchApi).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - variables, - }), - }); - expect(fetch).not.toHaveBeenCalled(); - }); - - describe("calling the function", () => { - let client: GraphQLClient; - - beforeEach(() => { - client = getValidClient(); - }); - - describe("fetch parameters", () => { - it("calls fetch API with provided operation", async () => { - await client.request(operation); - expect(fetch).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - }), - }); - }); - - it("calls fetch API with provided variables", async () => { - await client.request(operation, { variables }); - expect(fetch).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - variables, - }), - }); - }); - - it("calls fetch API with provided url override", async () => { - const url = - "http://test-store.myshopify.com/api/2023-07/graphql.json"; - await client.request(operation, { url }); - expect(fetch).toHaveBeenCalledWith(url, { - method: "POST", - headers: config.headers, - body: JSON.stringify({ - query: operation, - }), - }); - }); - - it("calls fetch API with provided headers override", async () => { - const headers = { - "Content-Type": "application/graphql", - "custom-header": "custom-headers", - }; - - await client.request(operation, { headers }); - expect(fetch).toHaveBeenCalledWith(config.url, { - method: "POST", - headers: { ...config.headers, ...headers }, - body: JSON.stringify({ - query: operation, - }), - }); - }); - }); - - describe("returned object", () => { - it("includes a data object if the data object is included in the response", async () => { - const mockResponseData = { data: { shop: { name: "Test shop" } } }; - const mockedSuccessResponse = new Response( - JSON.stringify(mockResponseData), - { - status: 200, - headers: new Headers({ - "Content-Type": "application/json", - }), - }, - ); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - - const response = await client.request(operation, { variables }); - expect(response).toHaveProperty("data", mockResponseData.data); - }); - - it("includes an API extensions object if it is included in the response", async () => { - const extensions = { - context: { - country: "JP", - language: "ja", - }, - }; - - const mockedSuccessResponse = new Response( - JSON.stringify({ data: {}, extensions }), - { - status: 200, - headers: new Headers({ - "Content-Type": "application/json", - }), - }, - ); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - - const response = await client.request(operation, { variables }); - expect(response).toHaveProperty("extensions", extensions); - expect(response).not.toHaveProperty("error"); - }); - - it("includes an error object if the response is not ok", async () => { - const responseConfig = { - status: 400, - statusText: "Bad request", - ok: false, - headers: new Headers({ - "Content-Type": "application/json", - }), - }; - - const mockedSuccessResponse = new Response("", responseConfig); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - - const response = await client.request(operation, { variables }); - expect(response).toHaveProperty("errors", { - networkStatusCode: responseConfig.status, - message: `GraphQL Client: ${responseConfig.statusText}`, - response: mockedSuccessResponse, - }); - }); - - it("includes an error object if the fetch promise fails", async () => { - const errorMessage = "Async error message"; - - fetchMock.mockRejectedValue(new Error(errorMessage)); - - const response = await client.request(operation, { variables }); - expect(response).toHaveProperty("errors", { - message: `GraphQL Client: ${errorMessage}`, - }); - }); - - it("includes an error object if the response content type is not application/json", async () => { - const contentType = "multipart/mixed"; - const responseConfig = { - status: 200, - headers: new Headers({ - "Content-Type": contentType, - }), - }; - - const mockedSuccessResponse = new Response( - JSON.stringify({ data: {} }), - responseConfig, - ); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - - const response = await client.request(operation, { variables }); - expect(response).toHaveProperty("errors", { - networkStatusCode: responseConfig.status, - message: `GraphQL Client: Response returned unexpected Content-Type: ${contentType}`, - response: mockedSuccessResponse, - }); - }); - - it("includes an error object if the API response contains errors", async () => { - const gqlError = ["GQL error"]; - const responseConfig = { - status: 200, - headers: new Headers({ - "Content-Type": "application/json", - }), - }; - - const mockedSuccessResponse = new Response( - JSON.stringify({ errors: gqlError }), - responseConfig, - ); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - - const response = await client.request(operation, { variables }); - expect(response).toHaveProperty("errors", { - networkStatusCode: responseConfig.status, - message: - "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", - graphQLErrors: gqlError, - response: mockedSuccessResponse, - }); - }); - - it("includes an error object if the API does not throw or return an error and does not include a data object in its response", async () => { - const responseConfig = { - status: 200, - headers: new Headers({ - "Content-Type": "application/json", - }), - }; - - const mockedSuccessResponse = new Response( - JSON.stringify({}), - responseConfig, - ); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - const response = await client.request(operation, { variables }); - expect(response).toHaveProperty("errors", { - networkStatusCode: mockedSuccessResponse.status, - message: - "GraphQL Client: An unknown error has occurred. The API did not return a data object or any errors in its response.", - response: mockedSuccessResponse, - }); - }); - - it("includes an error object and a data object if the API returns both errors and data in the response", async () => { - const gqlError = ["GQL error"]; - const data = { product: { title: "product title" } }; - - const responseConfig = { - status: 200, - headers: new Headers({ - "Content-Type": "application/json", - }), - }; - - const mockedSuccessResponse = new Response( - JSON.stringify({ errors: gqlError, data }), - responseConfig, - ); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - const response = await client.request(operation, { variables }); - - expect(response).toHaveProperty("data", data); - expect(response).toHaveProperty("errors", { - networkStatusCode: responseConfig.status, - message: - "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", - graphQLErrors: gqlError, - response: mockedSuccessResponse, - }); - }); - }); - - describe("retries", () => { - describe("Aborted fetch responses", () => { - it("calls the global fetch 1 time and returns a response object with a plain error when the client default retries value is 0 ", async () => { - fetchMock.mockAbort(); - - const { errors } = await client.request(operation); - - expect(errors?.message?.startsWith("GraphQL Client: ")).toBe( - true, - ); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("calls the global fetch 2 times and returns a response object with an error when the client was initialized with 1 retries and all fetches were aborted", async () => { - fetchMock.mockAbort(); - - const client = getValidClient({ retries: 1 }); - - const { errors } = await client.request(operation); - - expect( - errors?.message?.startsWith( - "GraphQL Client: Attempted maximum number of 1 network retries. Last message - ", - ), - ).toBe(true); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("calls the global fetch 3 times and returns a response object with an error when the function is provided with 2 retries and all fetches were aborted", async () => { - fetchMock.mockAbort(); - - const { errors } = await client.request(operation, { - retries: 2, - }); - - expect( - errors?.message?.startsWith( - "GraphQL Client: Attempted maximum number of 2 network retries. Last message - ", - ), - ).toBe(true); - expect(fetch).toHaveBeenCalledTimes(3); - }); - - it("returns a valid response object without an error property after an aborted fetch and the next response is valid", async () => { - const mockResponseData = { - data: { shop: { name: "Test shop" } }, - }; - const mockedSuccessResponse = new Response( - JSON.stringify(mockResponseData), - { - status: 200, - headers: new Headers({ - "Content-Type": "application/json", - }), - }, - ); - - fetchMock.mockAbortOnce(); - fetchMock.mockResolvedValue(mockedSuccessResponse); - - const response = await client.request(operation, { retries: 2 }); - - expect(response.errors).toBeUndefined(); - expect(response.data).toEqual(mockResponseData.data); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("delays a retry by 1000ms", async () => { - const client = getValidClient({ retries: 1 }); - fetchMock.mockAbort(); - - await client.request(operation); - - expect(setTimeout).toHaveBeenCalledTimes(1); - expect(setTimeout).toHaveBeenCalledWith( - expect.any(Function), - 1000, - ); - }); - - it("logs each retry attempt if a logger is provided", async () => { - const client = getValidClient({ retries: 2, logger: mockLogger }); - fetchMock.mockAbort(); - - await client.request(operation); - - const requestParams = [ - config.url, - { - method: "POST", - body: JSON.stringify({ query: operation }), - headers: config.headers, - }, - ]; - - expect(mockLogger).toHaveBeenCalledTimes(2); - expect(mockLogger).toHaveBeenNthCalledWith(1, { - type: "HTTP-Retry", - content: { - requestParams, - lastResponse: undefined, - retryAttempt: 1, - maxRetries: 2, - }, - }); - - expect(mockLogger).toHaveBeenNthCalledWith(2, { - type: "HTTP-Retry", - content: { - requestParams, - lastResponse: undefined, - retryAttempt: 2, - maxRetries: 2, - }, - }); - }); - }); - - describe("429 responses", () => { - const status = 429; - const mockedFailedResponse = new Response(JSON.stringify({}), { - status, - headers: new Headers({ - "Content-Type": "application/json", - }), - }); - - it("calls the global fetch 1 time and returns a response object with an error when the client default retries value is 0", async () => { - fetchMock.mockResolvedValue(mockedFailedResponse); - const response = await client.request(operation); - - expect(response.errors?.message).toBe( - "GraphQL Client: Too Many Requests", - ); - expect(response.errors?.networkStatusCode).toBe(status); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("calls the global fetch 2 times and returns a response object with an error when the client was initialized with 1 retries and all fetches returned 429 responses", async () => { - fetchMock.mockResolvedValue(mockedFailedResponse); - const client = getValidClient({ retries: 1 }); - - const response = await client.request(operation); - - expect(response.errors?.message).toBe( - "GraphQL Client: Too Many Requests", - ); - expect(response.errors?.networkStatusCode).toBe(status); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("calls the global fetch 3 times and returns a response object with an error when the function is provided with 2 retries and all fetches returned 429 responses", async () => { - fetchMock.mockResolvedValue(mockedFailedResponse); - const response = await client.request(operation, { retries: 2 }); - - expect(response.errors?.message).toBe( - "GraphQL Client: Too Many Requests", - ); - expect(response.errors?.networkStatusCode).toBe(status); - expect(fetch).toHaveBeenCalledTimes(3); - }); - - it("returns a valid response after an a failed 429 fetch response and the next response is valid", async () => { - const mockedSuccessData = { data: { shop: { name: "shop1" } } }; - fetchMock.mockResponses( - ["", { status }], - [ - JSON.stringify(mockedSuccessData), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ], - ); - - const response = await client.request(operation, { retries: 2 }); - - expect(response.data).toEqual(mockedSuccessData.data); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("returns a failed non 429/503 response after an a failed 429 fetch response and the next response has failed", async () => { - fetchMock.mockResponses(["", { status }], ["", { status: 500 }]); - - const response = await client.request(operation, { retries: 2 }); - - expect(response.errors?.networkStatusCode).toBe(500); - expect(response.errors?.message).toEqual( - "GraphQL Client: Internal Server Error", - ); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("delays a retry by 1000ms", async () => { - const client = getValidClient({ retries: 1 }); - fetchMock.mockResolvedValue(mockedFailedResponse); - - const response = await client.request(operation); - - expect(response.errors?.networkStatusCode).toBe(status); - - expect(setTimeout).toHaveBeenCalledTimes(1); - expect(setTimeout).toHaveBeenCalledWith( - expect.any(Function), - 1000, - ); - }); - - it("logs each retry attempt if a logger is provided", async () => { - const client = getValidClient({ retries: 2, logger: mockLogger }); - fetchMock.mockResolvedValue(mockedFailedResponse); - await client.request(operation); - - const retryLogs = mockLogger.mock.calls.filter( - (args) => args[0].type === "HTTP-Retry", - ); - - expect(retryLogs.length).toBe(2); - - const requestParams = [ - config.url, - { - method: "POST", - body: JSON.stringify({ query: operation }), - headers: config.headers, - }, - ]; - - const firstLogContent = retryLogs[0][0].content; - expect(firstLogContent.requestParams).toEqual(requestParams); - expect(firstLogContent.lastResponse.status).toBe(status); - expect(firstLogContent.retryAttempt).toBe(1); - expect(firstLogContent.maxRetries).toBe(2); - - const secondLogContent = retryLogs[1][0].content; - expect(secondLogContent.requestParams).toEqual(requestParams); - expect(secondLogContent.lastResponse.status).toBe(status); - expect(secondLogContent.retryAttempt).toBe(2); - expect(secondLogContent.maxRetries).toBe(2); - }); - }); - - describe("503 responses", () => { - const status = 503; - const mockedFailedResponse = new Response(JSON.stringify({}), { - status, - headers: new Headers({ - "Content-Type": "application/json", - }), - }); - - it("calls the global fetch 1 time and returns a response object with an error when the client default retries value is 0", async () => { - fetchMock.mockResolvedValue(mockedFailedResponse); - const response = await client.request(operation); - - expect(response.errors?.message).toBe( - "GraphQL Client: Service Unavailable", - ); - expect(response.errors?.networkStatusCode).toBe(status); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("calls the global fetch 2 times and returns a response object with an error when the client was initialized with 1 retries and all fetches returned 503 responses", async () => { - fetchMock.mockResolvedValue(mockedFailedResponse); - const client = getValidClient({ retries: 1 }); - - const response = await client.request(operation); - - expect(response.errors?.message).toBe( - "GraphQL Client: Service Unavailable", - ); - expect(response.errors?.networkStatusCode).toBe(status); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("calls the global fetch 3 times and returns a response object with an error when the function is provided with 2 retries and all fetches returned 503 responses", async () => { - fetchMock.mockResolvedValue(mockedFailedResponse); - const response = await client.request(operation, { retries: 2 }); - - expect(response.errors?.message).toBe( - "GraphQL Client: Service Unavailable", - ); - expect(response.errors?.networkStatusCode).toBe(status); - expect(fetch).toHaveBeenCalledTimes(3); - }); - - it("returns a valid response after an a failed 503 fetch response and the next response is valid", async () => { - const mockedSuccessData = { data: { shop: { name: "shop1" } } }; - fetchMock.mockResponses( - ["", { status }], - [ - JSON.stringify(mockedSuccessData), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ], - ); - - const response = await client.request(operation, { retries: 2 }); - - expect(response.data).toEqual(mockedSuccessData.data); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("returns a failed non 429/503 response after an a failed 503 fetch response and the next response has failed", async () => { - fetchMock.mockResponses(["", { status }], ["", { status: 500 }]); - - const response = await client.request(operation, { retries: 2 }); - - expect(response.errors?.networkStatusCode).toBe(500); - expect(response.errors?.message).toEqual( - "GraphQL Client: Internal Server Error", - ); - expect(fetch).toHaveBeenCalledTimes(2); - }); - - it("delays a retry by 1000ms", async () => { - const client = getValidClient({ retries: 1 }); - fetchMock.mockResolvedValue(mockedFailedResponse); - - const response = await client.request(operation); - - expect(response.errors?.networkStatusCode).toBe(status); - - expect(setTimeout).toHaveBeenCalledTimes(1); - expect(setTimeout).toHaveBeenCalledWith( - expect.any(Function), - 1000, - ); - }); - - it("logs each retry attempt if a logger is provided", async () => { - const client = getValidClient({ retries: 2, logger: mockLogger }); - fetchMock.mockResolvedValue(mockedFailedResponse); - await client.request(operation); - - const retryLogs = mockLogger.mock.calls.filter( - (args) => args[0].type === "HTTP-Retry", - ); - - expect(retryLogs.length).toBe(2); - - const requestParams = [ - config.url, - { - method: "POST", - body: JSON.stringify({ query: operation }), - headers: config.headers, - }, - ]; - - const firstLogContent = retryLogs[0][0].content; - expect(firstLogContent.requestParams).toEqual(requestParams); - expect(firstLogContent.lastResponse.status).toBe(status); - expect(firstLogContent.retryAttempt).toBe(1); - expect(firstLogContent.maxRetries).toBe(2); - - const secondLogContent = retryLogs[1][0].content; - expect(secondLogContent.requestParams).toEqual(requestParams); - expect(secondLogContent.lastResponse.status).toBe(status); - expect(secondLogContent.retryAttempt).toBe(2); - expect(secondLogContent.maxRetries).toBe(2); - }); - }); - - it("does not retry additional network requests if the initial response is successful", async () => { - const mockedSuccessData = { data: { shop: { name: "shop1" } } }; - const mockedSuccessResponse = new Response( - JSON.stringify(mockedSuccessData), - { - status: 200, - headers: new Headers({ - "Content-Type": "application/json", - }), - }, - ); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - const response = await client.request(operation); - - expect(response.data).toEqual(mockedSuccessData.data); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("does not retry additional network requests on a failed response that is not a 429 or 503", async () => { - const mockedFailedResponse = new Response( - JSON.stringify({ data: {} }), - { - status: 500, - headers: new Headers({ - "Content-Type": "application/json", - }), - }, - ); - - fetchMock.mockResolvedValue(mockedFailedResponse); - const response = await client.request(operation); - - expect(response.errors?.networkStatusCode).toBe(500); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("returns a response object with an error when the retries config value is less than 0", async () => { - const retries = -1; - - const response = await client.request(operation, { retries }); - - expect(response.errors?.message).toEqual( - `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, - ); - }); - - it("returns a response object with an error when the retries config value is greater than 3", async () => { - const retries = 4; - const response = await client.request(operation, { retries }); - - expect(response.errors?.message).toEqual( - `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, - ); - }); - }); - - it("logs the request and response info if a logger is provided", async () => { - const mockResponseData = { data: { shop: { name: "Test shop" } } }; - const mockedSuccessResponse = new Response( - JSON.stringify(mockResponseData), - { - status: 200, - headers: new Headers({ - "Content-Type": "application/json", - }), - }, - ); - - fetchMock.mockResolvedValue(mockedSuccessResponse); - - const client = getValidClient({ logger: mockLogger }); - - await client.request(operation); - - expect(mockLogger).toBeCalledWith({ - type: "HTTP-Response", - content: { - response: mockedSuccessResponse, - requestParams: [ - config.url, - { - method: "POST", - body: JSON.stringify({ query: operation }), - headers: config.headers, - }, - ], - }, - }); - }); - }); - }); - }); -}); diff --git a/packages/graphql-client/src/graphql-client/tests/graphql-client/client-fetch-request.test.ts b/packages/graphql-client/src/graphql-client/tests/graphql-client/client-fetch-request.test.ts new file mode 100644 index 000000000..88ec242c9 --- /dev/null +++ b/packages/graphql-client/src/graphql-client/tests/graphql-client/client-fetch-request.test.ts @@ -0,0 +1,845 @@ +import fetchMock from "jest-fetch-mock"; + +import { GraphQLClient } from "../../types"; + +import { operation, variables, clientConfig, getValidClient } from "./fixtures"; +import { fetchApiTests, parametersTests, retryTests } from "./common-tests"; + +describe("GraphQL Client", () => { + let mockLogger: jest.Mock; + let client: GraphQLClient; + + fetchMock.enableMocks(); + + beforeEach(() => { + jest + .spyOn(global, "setTimeout") + .mockImplementation(jest.fn((resolve) => resolve() as any)); + fetchMock.mockResponse(() => Promise.resolve(JSON.stringify({ data: {} }))); + mockLogger = jest.fn(); + client = getValidClient(); + }); + + afterEach(() => { + fetchMock.resetMocks(); + jest.restoreAllMocks(); + }); + + describe("fetch()", () => { + const functionName = "fetch"; + + fetchApiTests(functionName); + + describe("calling the function", () => { + describe("function parameters", () => { + parametersTests(functionName); + }); + + describe("returned object", () => { + it("returns the HTTP response", async () => { + const response = await client.fetch(operation); + expect(response.status).toBe(200); + }); + }); + + describe("retries", () => { + retryTests(functionName); + + describe("Aborted fetch responses", () => { + it("calls the global fetch 1 time and throws a plain error when the client retries value is 0", async () => { + fetchMock.mockAbort(); + + await expect(async () => { + await client.fetch(operation); + }).rejects.toThrow(new RegExp(/^GraphQL Client: /)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("calls the global fetch 2 times and throws a retry error when the client was initialized with 1 retries and all fetches were aborted", async () => { + fetchMock.mockAbort(); + + const client = getValidClient({ retries: 1 }); + + await expect(async () => { + await client.fetch(operation); + }).rejects.toThrow( + new RegExp( + /^GraphQL Client: Attempted maximum number of 1 network retries. Last message - /, + ), + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("calls the global fetch 3 times and throws a retry error when the function is provided with 2 retries and all fetches were aborted", async () => { + fetchMock.mockAbort(); + + await expect(async () => { + await client.fetch(operation, { retries: 2 }); + }).rejects.toThrow( + new RegExp( + /^GraphQL Client: Attempted maximum number of 2 network retries. Last message - /, + ), + ); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("returns a valid http response after an aborted fetch and the next response is valid", async () => { + fetchMock.mockAbortOnce(); + + const response = await client.fetch(operation, { retries: 2 }); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("delays a retry by 1000ms", async () => { + const client = getValidClient({ retries: 1 }); + fetchMock.mockAbort(); + + await expect(async () => { + await client.fetch(operation); + }).rejects.toThrow(); + + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it("logs each retry attempt if a logger is provided", async () => { + const client = getValidClient({ + retries: 2, + logger: mockLogger, + }); + fetchMock.mockAbort(); + + await expect(async () => { + await client.fetch(operation); + }).rejects.toThrow(); + + const requestParams = [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ]; + + expect(mockLogger).toHaveBeenCalledTimes(2); + expect(mockLogger).toHaveBeenNthCalledWith(1, { + type: "HTTP-Retry", + content: { + requestParams, + lastResponse: undefined, + retryAttempt: 1, + maxRetries: 2, + }, + }); + + expect(mockLogger).toHaveBeenNthCalledWith(2, { + type: "HTTP-Retry", + content: { + requestParams, + lastResponse: undefined, + retryAttempt: 2, + maxRetries: 2, + }, + }); + }); + }); + + describe.each([ + [429, "Too Many Requests"], + [503, "Service Unavailable"], + ])("%i responses", (status, statusText) => { + const mockedFailedResponse = new Response(JSON.stringify({}), { + status, + headers: new Headers({ + "Content-Type": "application/json", + }), + }); + + it("calls the global fetch 1 time and returns the failed http response when the client default retries value is 0", async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const response = await client.fetch(operation); + + expect(response.status).toBe(status); + expect(response.statusText).toBe(statusText); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it(`calls the global fetch 2 times and returns the failed http response when the client was initialized with 1 retries and all fetches returned ${status} responses`, async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const client = getValidClient({ retries: 1 }); + + const response = await client.fetch(operation); + + expect(response.status).toBe(status); + expect(response.statusText).toBe(statusText); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it(`calls the global fetch 3 times and returns the failed http response when the function is provided with 2 retries and all fetches returned ${status} responses`, async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const response = await client.fetch(operation, { retries: 2 }); + + expect(response.status).toBe(status); + expect(response.statusText).toBe(statusText); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it(`returns a valid response after an a failed ${status} fetch response and the next response is valid`, async () => { + const mockedSuccessData = { data: {} }; + fetchMock.mockResponses( + ["", { status }], + [JSON.stringify(mockedSuccessData), { status: 200 }], + ); + + const response = await client.fetch(operation, { retries: 2 }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(mockedSuccessData); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it(`returns a failed non 429/503 response after an a failed ${status} fetch response and the next response has failed`, async () => { + const mockedSuccessData = { data: {} }; + fetchMock.mockResponses( + ["", { status }], + [JSON.stringify(mockedSuccessData), { status: 500 }], + ); + + const response = await client.fetch(operation, { retries: 2 }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual(mockedSuccessData); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("delays a retry by 1000ms", async () => { + const client = getValidClient({ retries: 1 }); + fetchMock.mockResolvedValue(mockedFailedResponse); + + const response = await client.request(operation); + + expect(response.errors?.networkStatusCode).toBe(status); + + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it("logs each retry attempt if a logger is provided", async () => { + const client = getValidClient({ + retries: 2, + logger: mockLogger, + }); + fetchMock.mockResolvedValue(mockedFailedResponse); + await client.fetch(operation); + + const retryLogs = mockLogger.mock.calls.filter( + (args) => args[0].type === "HTTP-Retry", + ); + + expect(retryLogs.length).toBe(2); + + const requestParams = [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ]; + + const firstLogContent = retryLogs[0][0].content; + expect(firstLogContent.requestParams).toEqual(requestParams); + expect(firstLogContent.lastResponse.status).toBe(status); + expect(firstLogContent.retryAttempt).toBe(1); + expect(firstLogContent.maxRetries).toBe(2); + + const secondLogContent = retryLogs[1][0].content; + expect(secondLogContent.requestParams).toEqual(requestParams); + expect(secondLogContent.lastResponse.status).toBe(status); + expect(secondLogContent.retryAttempt).toBe(2); + expect(secondLogContent.maxRetries).toBe(2); + }); + }); + + it("throws an error when the retries config value is less than 0", async () => { + const retries = -1; + await expect(async () => { + await client.fetch(operation, { retries }); + }).rejects.toThrow( + `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, + ); + }); + + it("throws an error when the retries config value is greater than 3", async () => { + const retries = 4; + await expect(async () => { + await client.fetch(operation, { retries }); + }).rejects.toThrow( + `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, + ); + }); + }); + + describe("request/response logging", () => { + it("logs the request and response info if a logger is provided", async () => { + const mockResponseData = { data: { shop: { name: "Test shop" } } }; + const mockedSuccessResponse = new Response( + JSON.stringify(mockResponseData), + { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const client = getValidClient({ logger: mockLogger }); + + await client.fetch(operation); + + expect(mockLogger).toBeCalledWith({ + type: "HTTP-Response", + content: { + response: mockedSuccessResponse, + requestParams: [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ], + }, + }); + }); + }); + }); + }); + + describe("request()", () => { + const functionName = "request"; + + fetchApiTests(functionName); + + describe("calling the function", () => { + describe("function parameters", () => { + parametersTests(functionName); + + it("throws an error when the operation includes a @defer directive", async () => { + const customOperation = ` + query { + shop { + id + ... @defer { + name + } + } + } + `; + + await expect(() => client.request(customOperation)).rejects.toThrow( + new Error( + "GraphQL Client: This operation will result in a streamable response - use requestStream() instead.", + ), + ); + }); + }); + + describe("returned object", () => { + it("includes a data object if the data object is included in the response", async () => { + const mockResponseData = { + data: { shop: { name: "Test shop" } }, + }; + const mockedSuccessResponse = new Response( + JSON.stringify(mockResponseData), + { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const response = await client.request(operation, { variables }); + expect(response).toHaveProperty("data", mockResponseData.data); + }); + + it("includes an API extensions object if it is included in the response", async () => { + const extensions = { + context: { + country: "JP", + language: "ja", + }, + }; + + const mockedSuccessResponse = new Response( + JSON.stringify({ data: {}, extensions }), + { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const response = await client.request(operation, { variables }); + expect(response).toHaveProperty("extensions", extensions); + expect(response).not.toHaveProperty("errors"); + }); + + it("includes an error object if the response is not ok", async () => { + const responseConfig = { + status: 400, + statusText: "Bad request", + ok: false, + headers: new Headers({ + "Content-Type": "application/json", + }), + }; + + const mockedSuccessResponse = new Response("", responseConfig); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const response = await client.request(operation, { variables }); + expect(response).toHaveProperty("errors", { + networkStatusCode: responseConfig.status, + message: `GraphQL Client: ${responseConfig.statusText}`, + response: mockedSuccessResponse, + }); + }); + + it("includes an error object if the fetch promise fails", async () => { + const errorMessage = "Async error message"; + + fetchMock.mockRejectedValue(new Error(errorMessage)); + + const response = await client.request(operation, { variables }); + expect(response).toHaveProperty("errors", { + message: `GraphQL Client: ${errorMessage}`, + }); + }); + + it("includes an error object if the response content type is not application/json", async () => { + const contentType = "multipart/mixed"; + const responseConfig = { + status: 200, + headers: new Headers({ + "Content-Type": contentType, + }), + }; + + const mockedSuccessResponse = new Response( + JSON.stringify({ data: {} }), + responseConfig, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const response = await client.request(operation, { variables }); + expect(response).toHaveProperty("errors", { + networkStatusCode: responseConfig.status, + message: `GraphQL Client: Response returned unexpected Content-Type: ${contentType}`, + response: mockedSuccessResponse, + }); + }); + + it("includes an error object if the API response contains errors", async () => { + const gqlError = ["GQL error"]; + const responseConfig = { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }; + + const mockedSuccessResponse = new Response( + JSON.stringify({ errors: gqlError }), + responseConfig, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const response = await client.request(operation, { variables }); + expect(response).toHaveProperty("errors", { + networkStatusCode: responseConfig.status, + message: + "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", + graphQLErrors: gqlError, + response: mockedSuccessResponse, + }); + }); + + it("includes an error object if the API does not throw or return an error and does not include a data object in its response", async () => { + const responseConfig = { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }; + + const mockedSuccessResponse = new Response( + JSON.stringify({}), + responseConfig, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + const response = await client.request(operation, { variables }); + expect(response).toHaveProperty("errors", { + networkStatusCode: mockedSuccessResponse.status, + message: + "GraphQL Client: An unknown error has occurred. The API did not return a data object or any errors in its response.", + response: mockedSuccessResponse, + }); + }); + + it("includes an error object and a data object if the API returns both errors and data in the response", async () => { + const gqlError = ["GQL error"]; + const data = { product: { title: "product title" } }; + + const responseConfig = { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }; + + const mockedSuccessResponse = new Response( + JSON.stringify({ errors: gqlError, data }), + responseConfig, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + const response = await client.request(operation, { variables }); + + expect(response).toHaveProperty("data", data); + expect(response).toHaveProperty("errors", { + networkStatusCode: responseConfig.status, + message: + "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", + graphQLErrors: gqlError, + response: mockedSuccessResponse, + }); + }); + }); + + describe("retries", () => { + retryTests(functionName); + + describe("Aborted fetch responses", () => { + it("calls the global fetch 1 time and returns a response object with a plain error when the client default retries value is 0 ", async () => { + fetchMock.mockAbort(); + + const { errors } = await client.request(operation); + + expect(errors?.message?.startsWith("GraphQL Client: ")).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("calls the global fetch 2 times and returns a response object with an error when the client was initialized with 1 retries and all fetches were aborted", async () => { + fetchMock.mockAbort(); + + const client = getValidClient({ retries: 1 }); + + const { errors } = await client.request(operation); + + expect( + errors?.message?.startsWith( + "GraphQL Client: Attempted maximum number of 1 network retries. Last message - ", + ), + ).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("calls the global fetch 3 times and returns a response object with an error when the function is provided with 2 retries and all fetches were aborted", async () => { + fetchMock.mockAbort(); + + const { errors } = await client.request(operation, { + retries: 2, + }); + + expect( + errors?.message?.startsWith( + "GraphQL Client: Attempted maximum number of 2 network retries. Last message - ", + ), + ).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("returns a valid response object without an error property after an aborted fetch and the next response is valid", async () => { + const mockResponseData = { + data: { shop: { name: "Test shop" } }, + }; + const mockedSuccessResponse = new Response( + JSON.stringify(mockResponseData), + { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }, + ); + + fetchMock.mockAbortOnce(); + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const response = await client.request(operation, { + retries: 2, + }); + + expect(response.errors).toBeUndefined(); + expect(response.data).toEqual(mockResponseData.data); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("delays a retry by 1000ms", async () => { + const client = getValidClient({ retries: 1 }); + fetchMock.mockAbort(); + + await client.request(operation); + + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it("logs each retry attempt if a logger is provided", async () => { + const client = getValidClient({ + retries: 2, + logger: mockLogger, + }); + fetchMock.mockAbort(); + + await client.request(operation); + + const requestParams = [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ]; + + expect(mockLogger).toHaveBeenCalledTimes(2); + expect(mockLogger).toHaveBeenNthCalledWith(1, { + type: "HTTP-Retry", + content: { + requestParams, + lastResponse: undefined, + retryAttempt: 1, + maxRetries: 2, + }, + }); + + expect(mockLogger).toHaveBeenNthCalledWith(2, { + type: "HTTP-Retry", + content: { + requestParams, + lastResponse: undefined, + retryAttempt: 2, + maxRetries: 2, + }, + }); + }); + }); + + describe.each([ + [429, "Too Many Requests"], + [503, "Service Unavailable"], + ])("%i responses", (status, statusText) => { + const mockedFailedResponse = new Response(JSON.stringify({}), { + status, + headers: new Headers({ + "Content-Type": "application/json", + }), + }); + + it("calls the global fetch 1 time and returns a response object with an error when the client default retries value is 0", async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const response = await client.request(operation); + + expect(response.errors?.message).toBe( + `GraphQL Client: ${statusText}`, + ); + expect(response.errors?.networkStatusCode).toBe(status); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it(`calls the global fetch 2 times and returns a response object with an error when the client was initialized with 1 retries and all fetches returned ${status} responses`, async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const client = getValidClient({ retries: 1 }); + + const response = await client.request(operation); + + expect(response.errors?.message).toBe( + `GraphQL Client: ${statusText}`, + ); + expect(response.errors?.networkStatusCode).toBe(status); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it(`calls the global fetch 3 times and returns a response object with an error when the function is provided with 2 retries and all fetches returned ${status} responses`, async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const response = await client.request(operation, { + retries: 2, + }); + + expect(response.errors?.message).toBe( + `GraphQL Client: ${statusText}`, + ); + expect(response.errors?.networkStatusCode).toBe(status); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it(`returns a valid response after an a failed ${status} fetch response and the next response is valid`, async () => { + const mockedSuccessData = { data: { shop: { name: "shop1" } } }; + fetchMock.mockResponses( + ["", { status }], + [ + JSON.stringify(mockedSuccessData), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ], + ); + + const response = await client.request(operation, { + retries: 2, + }); + + expect(response.data).toEqual(mockedSuccessData.data); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it(`returns a failed non 429/503 response after an a failed ${status} fetch response and the next response has failed`, async () => { + fetchMock.mockResponses(["", { status }], ["", { status: 500 }]); + + const response = await client.request(operation, { + retries: 2, + }); + + expect(response.errors?.networkStatusCode).toBe(500); + expect(response.errors?.message).toEqual( + "GraphQL Client: Internal Server Error", + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("delays a retry by 1000ms", async () => { + const client = getValidClient({ retries: 1 }); + fetchMock.mockResolvedValue(mockedFailedResponse); + + const response = await client.request(operation); + + expect(response.errors?.networkStatusCode).toBe(status); + + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it("logs each retry attempt if a logger is provided", async () => { + const client = getValidClient({ + retries: 2, + logger: mockLogger, + }); + fetchMock.mockResolvedValue(mockedFailedResponse); + await client.request(operation); + + const retryLogs = mockLogger.mock.calls.filter( + (args) => args[0].type === "HTTP-Retry", + ); + + expect(retryLogs.length).toBe(2); + + const requestParams = [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ]; + + const firstLogContent = retryLogs[0][0].content; + expect(firstLogContent.requestParams).toEqual(requestParams); + expect(firstLogContent.lastResponse.status).toBe(status); + expect(firstLogContent.retryAttempt).toBe(1); + expect(firstLogContent.maxRetries).toBe(2); + + const secondLogContent = retryLogs[1][0].content; + expect(secondLogContent.requestParams).toEqual(requestParams); + expect(secondLogContent.lastResponse.status).toBe(status); + expect(secondLogContent.retryAttempt).toBe(2); + expect(secondLogContent.maxRetries).toBe(2); + }); + }); + + it("returns a response object with an error when the retries config value is less than 0", async () => { + const retries = -1; + + const response = await client.request(operation, { retries }); + + expect(response.errors?.message).toEqual( + `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, + ); + }); + + it("returns a response object with an error when the retries config value is greater than 3", async () => { + const retries = 4; + const response = await client.request(operation, { retries }); + + expect(response.errors?.message).toEqual( + `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, + ); + }); + }); + + describe("request/response logging", () => { + it("logs the request and response info if a logger is provided", async () => { + const mockResponseData = { data: { shop: { name: "Test shop" } } }; + const mockedSuccessResponse = new Response( + JSON.stringify(mockResponseData), + { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const client = getValidClient({ logger: mockLogger }); + + await client.request(operation); + + expect(mockLogger).toBeCalledWith({ + type: "HTTP-Response", + content: { + response: mockedSuccessResponse, + requestParams: [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ], + }, + }); + }); + }); + }); + }); +}); diff --git a/packages/graphql-client/src/graphql-client/tests/graphql-client/client-init-config.test.ts b/packages/graphql-client/src/graphql-client/tests/graphql-client/client-init-config.test.ts new file mode 100644 index 000000000..1d4d0dd94 --- /dev/null +++ b/packages/graphql-client/src/graphql-client/tests/graphql-client/client-init-config.test.ts @@ -0,0 +1,70 @@ +import fetchMock from "jest-fetch-mock"; + +import { clientConfig, getValidClient } from "./fixtures"; + +describe("GraphQL Client", () => { + fetchMock.enableMocks(); + + beforeEach(() => { + jest + .spyOn(global, "setTimeout") + .mockImplementation(jest.fn((resolve) => resolve() as any)); + fetchMock.mockResponse(() => Promise.resolve(JSON.stringify({ data: {} }))); + }); + + afterEach(() => { + fetchMock.resetMocks(); + jest.restoreAllMocks(); + }); + + describe("createGraphQLClient()", () => { + describe("client initialization", () => { + it("returns a client object that contains a config object and request, requestStream and fetch functions", () => { + const client = getValidClient(); + expect(client).toHaveProperty("config"); + expect(client).toMatchObject({ + fetch: expect.any(Function), + request: expect.any(Function), + requestStream: expect.any(Function), + }); + }); + + it("throws an error when the retries config value is less than 0", () => { + const retries = -1; + expect(() => getValidClient({ retries })).toThrowError( + `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, + ); + }); + + it("throws an error when the retries config value is greater than 3", () => { + const retries = 4; + expect(() => getValidClient({ retries })).toThrowError( + `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, + ); + }); + }); + + describe("config object", () => { + it("returns a config object that includes the url", () => { + const client = getValidClient(); + expect(client.config.url).toBe(clientConfig.url); + }); + + it("returns a config object that includes the headers", () => { + const client = getValidClient(); + expect(client.config.headers).toBe(clientConfig.headers); + }); + + it("returns a config object that includes the default retries value when it is not provided at initialization", () => { + const client = getValidClient(); + expect(client.config.retries).toBe(0); + }); + + it("returns a config object that includes the provided retries value", () => { + const retries = 3; + const client = getValidClient({ retries }); + expect(client.config.retries).toBe(retries); + }); + }); + }); +}); diff --git a/packages/graphql-client/src/graphql-client/tests/graphql-client/client-requestStream.test.ts b/packages/graphql-client/src/graphql-client/tests/graphql-client/client-requestStream.test.ts new file mode 100644 index 000000000..f3cffcea6 --- /dev/null +++ b/packages/graphql-client/src/graphql-client/tests/graphql-client/client-requestStream.test.ts @@ -0,0 +1,1417 @@ +import fetchMock from "jest-fetch-mock"; + +import { GraphQLClient, ClientStreamResponse } from "../../types"; + +import { + clientConfig, + getValidClient, + createIterableResponse, + createReaderStreamResponse, +} from "./fixtures"; +import { fetchApiTests, parametersTests, retryTests } from "./common-tests"; + +const operation = ` +query shop($country: CountryCode, $language: LanguageCode) @inContext(country: $country, language: $language) { + shop { + id + ... @defer { + name + description + } + } +} +`; + +const variables = { + language: "EN", + country: "JP", +}; + +describe("GraphQL Client", () => { + let mockLogger: jest.Mock; + let client: GraphQLClient; + + fetchMock.enableMocks(); + + beforeEach(() => { + jest + .spyOn(global, "setTimeout") + .mockImplementation(jest.fn((resolve) => resolve() as any)); + fetchMock.mockResponse(() => Promise.resolve(JSON.stringify({ data: {} }))); + mockLogger = jest.fn(); + client = getValidClient(); + }); + + afterEach(() => { + fetchMock.resetMocks(); + jest.restoreAllMocks(); + }); + + describe("requestStream()", () => { + const functionName = "requestStream"; + + const id = "gid://shopify/Shop/1"; + const name = "Shop 1"; + const description = "Test shop description"; + + fetchApiTests(functionName, operation); + + describe("calling the function", () => { + describe("fetch parameters", () => { + parametersTests("requestStream", operation); + + it("throws an error if the operation does not include the defer directive", async () => { + const customOperation = ` + query { + shop { + name + } + } + `; + + await expect(() => + client.requestStream(customOperation), + ).rejects.toThrow( + new Error( + "GraphQL Client: This operation does not result in a streamable response - use request() instead.", + ), + ); + }); + }); + + describe("returned async iterator", () => { + it("returns an async iterator that returns an object that includes an error object if the response is not ok", async () => { + const responseConfig = { + status: 400, + statusText: "Bad request", + ok: false, + headers: new Headers({ + "Content-Type": "application/json", + }), + json: jest.fn(), + }; + + const mockedFailedResponse = new Response("", responseConfig); + + fetchMock.mockResolvedValue(mockedFailedResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + for await (const response of responseStream) { + expect(response).toHaveProperty("errors", { + networkStatusCode: responseConfig.status, + message: `GraphQL Client: ${responseConfig.statusText}`, + response: mockedFailedResponse, + }); + } + }); + + it("returns an async iterator that returns an object that includes an error object if the fetch promise fails", async () => { + const errorMessage = "Async error message"; + + fetchMock.mockRejectedValue(new Error(errorMessage)); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + for await (const response of responseStream) { + expect(response).toHaveProperty("errors", { + message: `GraphQL Client: ${errorMessage}`, + }); + } + }); + + describe("response is unexpected Content-Type", () => { + it("returns an async iterator that returns an object with an error object if the content type is not JSON or Multipart", async () => { + const contentType = "text/html"; + + const responseConfig = { + status: 200, + ok: true, + headers: new Headers({ + "Content-Type": contentType, + }), + json: jest.fn(), + }; + + const mockedSuccessResponse = new Response("", responseConfig); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + for await (const response of responseStream) { + expect(response).toHaveProperty("errors", { + networkStatusCode: mockedSuccessResponse.status, + message: `GraphQL Client: Response returned unexpected Content-Type: ${contentType}`, + response: mockedSuccessResponse, + }); + } + }); + }); + + describe("response is Content-Type: application/json", () => { + const headers = new Headers({ + "Content-Type": "application/json", + }); + + it("returns an async iterator that returns an object that includes the response data object and extensions and has a false hasNext value", async () => { + const mockResponseData = { + data: { shop: { name: "Test shop" } }, + extensions: { + context: { + country: "JP", + language: "ja", + }, + }, + }; + + const responseConfig = { + status: 200, + ok: true, + headers, + }; + + const mockedSuccessResponse = new Response( + JSON.stringify(mockResponseData), + responseConfig, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + for await (const response of responseStream) { + expect(response).toHaveProperty("data", mockResponseData.data); + expect(response).toHaveProperty( + "extensions", + mockResponseData.extensions, + ); + expect(response).toHaveProperty("hasNext", false); + } + }); + + it("returns an async iterator that returns an object that includes an error object if the API response contains errors", async () => { + const gqlError = ["GQL error"]; + + const responseConfig = { + status: 200, + ok: true, + headers, + }; + + const mockedSuccessResponse = new Response( + JSON.stringify({ errors: gqlError }), + responseConfig, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + for await (const response of responseStream) { + expect(response).toHaveProperty("errors", { + networkStatusCode: responseConfig.status, + message: + "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", + graphQLErrors: gqlError, + response: mockedSuccessResponse, + }); + } + }); + + it("returns an async iterator that returns an object that includes an error object if the API does not throw or return an error and does not include a data object in its response", async () => { + const responseConfig = { + status: 200, + ok: true, + headers, + }; + + const mockedSuccessResponse = new Response( + JSON.stringify({}), + responseConfig, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + for await (const response of responseStream) { + expect(response).toHaveProperty("errors", { + networkStatusCode: mockedSuccessResponse.status, + message: + "GraphQL Client: An unknown error has occurred. The API did not return a data object or any errors in its response.", + response: mockedSuccessResponse, + }); + } + }); + }); + + describe("response is Content-Type: multipart/mixed", () => { + describe.each([ + ["Readable Stream", createReaderStreamResponse], + ["Async Iterator", createIterableResponse], + ])("Server responded with a %s", (_name, responseGenerator) => { + const streamCompleteDataChunks: [string, string[]] = [ + "stream multiple, complete data chunk", + [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}},"extensions":{"context": {"country": "${variables.country}", "language": "${variables.language}"}},"hasNext":true} + --graphql + + `, + ` + Content-Type: application/json + Content-Length: 77\r\n\r\n{"incremental":[{"path":["shop"],"data":{"name":"${name}","description":"${description}"},"errors":[]}],"hasNext":false} + + --graphql--`, + ], + ]; + + const streamIncompleteDataChunks: [string, string[]] = [ + "stream multiple, incomplete data chunk", + [ + ` + --graphql + Content-Type: app`, + `lication/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"`, + `${id}"}},"exte`, + `nsions":{"context":{"country":"`, + `${variables.country}","language":"${variables.language}"}},"hasNext":true} + --graphql + + `, + ` + Content-Type: appli`, + `cation/json + Content-Length: 77\r\n`, + `\r\n{"incremental":[{"path":["shop"],"data":{"name":"${name}","descripti`, + `on":"${description}"},"errors":[]}],"hasNext":false} + + --graphql--`, + ], + ]; + + describe.each([ + streamCompleteDataChunks, + streamIncompleteDataChunks, + ])("%s", (_name, multipleResponsesArray) => { + let results: any; + let responseStream: any; + + beforeAll(async () => { + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + responseStream = await client.requestStream(operation, { + variables, + }); + + results = []; + + for await (const response of responseStream) { + results.push(response); + } + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it("returns an async iterator and the iterator returned 2 response objects", () => { + expect(responseStream[Symbol.asyncIterator]).toBeDefined(); + expect(results.length).toBe(2); + }); + + describe("response objects returned by iterator", () => { + let response: ClientStreamResponse; + + describe("initial response object", () => { + beforeAll(() => { + response = results[0]; + }); + + it("contains a data object that is the first chunk of data", () => { + expect(response.data).toEqual({ + shop: { + id, + }, + }); + }); + + it("contains the extensions object", () => { + expect(response.extensions).toEqual({ + context: { + language: variables.language, + country: variables.country, + }, + }); + }); + + it("contains a true hasNext flag", () => { + expect(response.hasNext).toBe(true); + }); + }); + + describe("last response object", () => { + beforeAll(() => { + response = results[1]; + }); + + it("contains a data object that is a combination of all the data chunks", () => { + expect(response.data).toEqual({ + shop: { + id, + name, + description, + }, + }); + }); + + it("contains the extensions object", () => { + expect(response.extensions).toEqual({ + context: { + language: variables.language, + country: variables.country, + }, + }); + }); + + it("contains a false hasNext flag", () => { + expect(response.hasNext).toBe(false); + }); + + it("does not contain the errors object", () => { + expect(response.errors).toBeUndefined(); + }); + }); + }); + }); + + describe("stream a single completed data chunk", () => { + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}},"extensions":{"context":{"country":"${variables.country}","language":"${variables.language}"}},"hasNext":true} + --graphql + + Content-Type: application/json + Content-Length: 77\r\n\r\n{"incremental":[{"path":["shop"],"data":{"name":"${name}","description":"${description}"}, "errors":[]}],"hasNext":false} + + --graphql--`, + ]; + + let results: any; + let responseStream: any; + + beforeAll(async () => { + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + responseStream = await client.requestStream(operation, { + variables, + }); + + results = []; + + for await (const response of responseStream) { + results.push(response); + } + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it("returns an async iterator and the iterator returned 1 response object", () => { + expect(responseStream[Symbol.asyncIterator]).toBeDefined(); + expect(results.length).toBe(1); + }); + + describe("single response object returned by iterator", () => { + let response: ClientStreamResponse; + + beforeAll(() => { + response = results[0]; + }); + + it("contains a data object that is the combination of all chunk data", () => { + expect(response.data).toEqual({ + shop: { + id, + name, + description, + }, + }); + }); + + it("contains the extensions object", () => { + expect(response.extensions).toEqual({ + context: { + language: variables.language, + country: variables.country, + }, + }); + }); + + it("contains a false hasNext flag", () => { + expect(response.hasNext).toBe(false); + }); + + it("does not contain the errors object", () => { + expect(response.errors).toBeUndefined(); + }); + }); + }); + + describe("incremental array contains multiple chunks", () => { + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}},"extensions":{"context":{"country":"${variables.country}","language":"${variables.language}"}},"hasNext":true} + --graphql + + Content-Type: application/json + Content-Length: 77\r\n\r\n{"incremental":[{"path":["shop"],"data":{"name":"${name}"}, "errors":[]},{"path":["shop"],"data":{"description":"${description}"}, "errors":[]}],"hasNext":false} + + --graphql--`, + ]; + + let results: any; + let responseStream: any; + + beforeAll(async () => { + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + responseStream = await client.requestStream(operation, { + variables, + }); + + results = []; + + for await (const response of responseStream) { + results.push(response); + } + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it("returns an async iterator and the iterator returned 1 response object", () => { + expect(responseStream[Symbol.asyncIterator]).toBeDefined(); + expect(results.length).toBe(1); + }); + + describe("single response object returned by iterator", () => { + let response: ClientStreamResponse; + + beforeAll(() => { + response = results[0]; + }); + + it("contains a data object that is the combination of all chunk data", () => { + expect(response.data).toEqual({ + shop: { + id, + name, + description, + }, + }); + }); + + it("contains the extensions object", () => { + expect(response.extensions).toEqual({ + context: { + language: variables.language, + country: variables.country, + }, + }); + }); + + it("contains a false hasNext flag", () => { + expect(response.hasNext).toBe(false); + }); + + it("does not contain the errors object", () => { + expect(response.errors).toBeUndefined(); + }); + }); + }); + + describe("no extensions", () => { + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}}, "hasNext":true} + --graphql + + `, + ` + Content-Type: application/json + Content-Length: 77\r\n\r\n{"incremental":[{"path":["shop"],"data":{"name":"${name}","description":"${description}"}}],"hasNext":false} + + --graphql--`, + ]; + + let results: any; + let responseStream: any; + + beforeAll(async () => { + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + responseStream = await client.requestStream(operation); + + results = []; + + for await (const response of responseStream) { + results.push(response); + } + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + describe("response objects returned by iterator", () => { + describe("initial response object", () => { + it("does not contain the extensions object", () => { + expect(results[0].extensions).toBeUndefined(); + }); + }); + + describe("last response object", () => { + it("does not contain the extensions object", () => { + expect(results[1].extensions).toBeUndefined(); + }); + }); + }); + }); + + describe("error scenarios", () => { + describe("errors while processing data stream", () => { + describe("unexpected or premature termination of stream data", () => { + it("returns an async iterator that returns a response object with no data field and an incomplete data error when the stream ends prematurely", async () => { + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}}, + `, + ]; + + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = + await client.requestStream(operation); + + const results: any = []; + + for await (const response of responseStream) { + results.push(response); + } + + expect(results[0].errors).toEqual({ + networkStatusCode: 200, + message: + "GraphQL Client: Response stream terminated unexpectedly", + response: mockedSuccessResponse, + }); + + expect(results[0].data).toBeUndefined(); + }); + + it("returns an async iterator that returns a response object with partial data and an incomplete data error when the stream ends before all deferred chunks are returned", async () => { + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}},"extensions":{"context": {"country": "${variables.country}", "language": "${variables.language}"}},"hasNext":true} + --graphql + + `, + ]; + + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream( + operation, + { + variables, + }, + ); + + const results: any = []; + + for await (const response of responseStream) { + results.push(response); + } + + const lastResponse = results.slice(-1)[0]; + expect(lastResponse.data).toEqual({ + shop: { id }, + }); + + expect(lastResponse.errors).toEqual({ + networkStatusCode: 200, + message: + "GraphQL Client: Response stream terminated unexpectedly", + response: mockedSuccessResponse, + }); + }); + }); + + it("returns an async iterator that returns a response object with no data value and a JSON parsing error if the returned data is a malformed JSON", async () => { + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}}},"extensions":{"context": {"country": "${variables.country}", "language": "${variables.language}"}},"hasNext":false} + --graphql-- + `, + ]; + + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + const results: any = []; + + for await (const response of responseStream) { + results.push(response); + } + + const response = results[0]; + const errors = response.errors; + expect(errors.networkStatusCode).toBe(200); + expect(errors.message).toMatch( + new RegExp( + /^GraphQL Client: Error in parsing multipart response - /, + ), + ); + expect(errors.response).toBe(mockedSuccessResponse); + + expect(response.data).toBeUndefined(); + }); + }); + + describe("GQL errors", () => { + it("returns an async iterator that returns a response object with no data value and a GQL error if the initial returned response payload contains only an errors field", async () => { + const errors = [ + { + message: "Field 'test' doesn't exist on type 'Shop'", + locations: [{ line: 5, column: 11 }], + path: ["query shop", "shop", "test"], + extensions: { + code: "undefinedField", + typeName: "Shop", + fieldName: "test", + }, + }, + ]; + + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"errors":${JSON.stringify( + errors, + )},"hasNext":false} + --graphql-- + `, + ]; + + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + const results: any = []; + + for await (const response of responseStream) { + results.push(response); + } + + expect(results[0].errors).toEqual({ + networkStatusCode: 200, + message: + "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", + graphQLErrors: errors, + response: mockedSuccessResponse, + }); + + expect(results[0].data).toBeUndefined(); + }); + + it("returns an async iterator that returns a response object with partial data and a GQL error if the initial returned response payload contains both data and error values", async () => { + const errors = [ + { + message: "Field 'test' doesn't exist on type 'Shop'", + locations: [{ line: 5, column: 11 }], + path: ["query shop", "shop", "test"], + extensions: { + code: "undefinedField", + typeName: "Shop", + fieldName: "test", + }, + }, + ]; + + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}},"errors":${JSON.stringify( + errors, + )},"hasNext":false} + --graphql-- + `, + ]; + + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation); + + const results: any = []; + + for await (const response of responseStream) { + results.push(response); + } + + const response = results[0]; + expect(response.data).toEqual({ + shop: { + id, + }, + }); + + expect(response.errors).toEqual({ + networkStatusCode: 200, + message: + "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", + graphQLErrors: errors, + response: mockedSuccessResponse, + }); + }); + + it("returns an async iterator that returns a response object with the initial data value and a GQL error if the incremental response payload contains only an errors field", async () => { + const errors = [ + { + message: + "Access denied for description field. Required access: `fake_unauthenticated_read_shop_description` access scope.", + locations: [ + { + line: 7, + column: 13, + }, + ], + path: ["shop", "description"], + extensions: { + code: "ACCESS_DENIED", + documentation: + "https://shopify.dev/api/usage/access-scopes", + requiredAccess: + "`fake_unauthenticated_read_shop_description` access scope.", + }, + }, + ]; + + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}},"extensions":{"context":{"country":"${ + variables.country + }","language":"${ + variables.language + }"}},"hasNext":true} + --graphql + + Content-Type: application/json + Content-Length: 77\r\n\r\n{"incremental":[{"path":["shop"], "errors":${JSON.stringify( + errors, + )}}],"hasNext":false} + + --graphql--`, + ]; + + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + const results: any = []; + + for await (const response of responseStream) { + results.push(response); + } + + expect(results.length).toBe(1); + + expect(results[0].errors).toEqual({ + networkStatusCode: 200, + message: + "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", + graphQLErrors: errors, + response: mockedSuccessResponse, + }); + + expect(results[0].data).toEqual({ + shop: { + id, + }, + }); + }); + + it("returns an async iterator that returns a response object with a combined data value and a GQL error if the incremental response payloads contains both data and errors fields", async () => { + const primaryDomain = "https://test.shopify.com"; + + const shopOperation = ` + query shop($country: CountryCode, $language: LanguageCode) @inContext(country: $country, language: $language) { + shop { + id + ... @defer { + name + } + ... @defer { + description + primaryDomain + } + } + } + `; + + const errors = [ + { + message: + "Access denied for description field. Required access: `fake_unauthenticated_read_shop_description` access scope.", + locations: [ + { + line: 7, + column: 13, + }, + ], + path: ["shop", "description"], + extensions: { + code: "ACCESS_DENIED", + documentation: + "https://shopify.dev/api/usage/access-scopes", + requiredAccess: + "`fake_unauthenticated_read_shop_description` access scope.", + }, + }, + ]; + + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}},"extensions":{"context":{"country":"${ + variables.country + }","language":"${variables.language}"}},"hasNext":true} + --graphql + + Content-Type: application/json + Content-Length: 77\r\n\r\n{"incremental":[{"path":["shop"],"data":{"name":"${name}"}},{"path":["shop"],"data":{"primaryDomain":"${primaryDomain}"}, "errors":${JSON.stringify( + errors, + )}}],"hasNext":false} + + --graphql--`, + ]; + + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream( + shopOperation, + { + variables, + }, + ); + + const results: any = []; + + for await (const response of responseStream) { + results.push(response); + } + + expect(results.length).toBe(1); + + expect(results[0].errors).toEqual({ + networkStatusCode: 200, + message: + "GraphQL Client: An error occurred while fetching from the API. Review 'graphQLErrors' for details.", + graphQLErrors: errors, + response: mockedSuccessResponse, + }); + + expect(results[0].data).toEqual({ + shop: { + id, + name, + primaryDomain, + }, + }); + }); + + it("returns an async iterator that returns a response object with a no data returned error if the returned payload does not have an errors and data fields", async () => { + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"extensions":{"context": {"country": "${variables.country}", "language": "${variables.language}"}},"hasNext":false} + --graphql-- + `, + ]; + + const mockedSuccessResponse = responseGenerator( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + variables, + }); + + const results: any = []; + + for await (const response of responseStream) { + results.push(response); + } + + expect(results[0].data).toBeUndefined(); + expect(results[0].errors).toEqual({ + networkStatusCode: 200, + message: + "GraphQL Client: An unknown error has occurred. The API did not return a data object or any errors in its response.", + response: mockedSuccessResponse, + }); + }); + }); + }); + }); + }); + }); + + describe("retries", () => { + const multipleResponsesArray = [ + ` + --graphql + Content-Type: application/json + Content-Length: 120\r\n\r\n{"data":{"shop":{"id":"${id}"}},"hasNext":true} + --graphql + + `, + ` + Content-Type: application/json + Content-Length: 77\r\n\r\n{"path":["shop"],"data":{"name":"${name}","description":"${description}"},"hasNext":false,"errors":[]} + + --graphql--`, + ]; + + retryTests(functionName, operation); + + describe("Aborted fetch responses", () => { + it("calls the global fetch 1 time and the async iterator returns a response object with a plain error when the client default retries value is 0 ", async () => { + fetchMock.mockAbort(); + + const responseStream = await client.requestStream(operation); + + for await (const response of responseStream) { + expect( + response.errors?.message?.startsWith("GraphQL Client: "), + ).toBe(true); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("calls the global fetch 2 times and the async iterator returns a response object with an error when the client was initialized with 1 retries and all fetches were aborted", async () => { + fetchMock.mockAbort(); + + const client = getValidClient({ retries: 1 }); + + const responseStream = await client.requestStream(operation); + + for await (const response of responseStream) { + expect( + response.errors?.message?.startsWith( + "GraphQL Client: Attempted maximum number of 1 network retries. Last message - ", + ), + ).toBe(true); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("calls the global fetch 3 times and the async iterator returns a response object with an error when the function is provided with 2 retries and all fetches were aborted", async () => { + fetchMock.mockAbort(); + + const responseStream = await client.requestStream(operation, { + retries: 2, + }); + + for await (const response of responseStream) { + expect( + response.errors?.message?.startsWith( + "GraphQL Client: Attempted maximum number of 2 network retries. Last message - ", + ), + ).toBe(true); + } + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("returns a async iterator that returns valid response objects without an error property after an aborted fetch and the next response is valid", async () => { + const mockedSuccessResponse = createReaderStreamResponse( + multipleResponsesArray, + ); + + fetchMock.mockAbortOnce(); + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + retries: 2, + }); + + for await (const response of responseStream) { + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("delays a retry by 1000ms", async () => { + const client = getValidClient({ retries: 1 }); + fetchMock.mockAbort(); + + await client.requestStream(operation); + + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it("logs each retry attempt if a logger is provided", async () => { + const client = getValidClient({ + retries: 2, + logger: mockLogger, + }); + fetchMock.mockAbort(); + + await client.requestStream(operation); + + const requestParams = [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ]; + + expect(mockLogger).toHaveBeenCalledTimes(2); + expect(mockLogger).toHaveBeenNthCalledWith(1, { + type: "HTTP-Retry", + content: { + requestParams, + lastResponse: undefined, + retryAttempt: 1, + maxRetries: 2, + }, + }); + + expect(mockLogger).toHaveBeenNthCalledWith(2, { + type: "HTTP-Retry", + content: { + requestParams, + lastResponse: undefined, + retryAttempt: 2, + maxRetries: 2, + }, + }); + }); + }); + + describe.each([ + [429, "Too Many Requests"], + [503, "Service Unavailable"], + ])("%i responses", (status, statusText) => { + const mockedFailedResponse = new Response(JSON.stringify({}), { + status, + headers: new Headers({ + "Content-Type": "application/json", + }), + }); + + it("calls the global fetch 1 time and the async iterator returns a response object with an error when the client default retries value is 0", async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const responseStream = await client.requestStream(operation); + + for await (const response of responseStream) { + expect(response.errors?.message).toBe( + `GraphQL Client: ${statusText}`, + ); + expect(response.errors?.networkStatusCode).toBe(status); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it(`calls the global fetch 2 times and the async iterator returns a response object with an error when the client was initialized with 1 retries and all fetches returned ${status} responses`, async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const client = getValidClient({ retries: 1 }); + + const responseStream = await client.requestStream(operation); + + for await (const response of responseStream) { + expect(response.errors?.message).toBe( + `GraphQL Client: ${statusText}`, + ); + expect(response.errors?.networkStatusCode).toBe(status); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it(`calls the global fetch 3 times and the async iterator returns a response object with an error when the function is provided with 2 retries and all fetches returned ${status} responses`, async () => { + fetchMock.mockResolvedValue(mockedFailedResponse); + const responseStream = await client.requestStream(operation, { + retries: 2, + }); + + for await (const response of responseStream) { + expect(response.errors?.message).toBe( + `GraphQL Client: ${statusText}`, + ); + expect(response.errors?.networkStatusCode).toBe(status); + } + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it(`returns a async iterator that returns valid response objects without an error property after a failed ${status} response and the next response is valid`, async () => { + const mockedSuccessResponse = createReaderStreamResponse( + multipleResponsesArray, + ); + + fetchMock.mockResolvedValueOnce(mockedFailedResponse); + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const responseStream = await client.requestStream(operation, { + retries: 2, + }); + + for await (const response of responseStream) { + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("returns a failed non 429/503 response after an a failed 429 fetch response and the next response has failed", async () => { + const mockedFailed500Response = new Response(JSON.stringify({}), { + status: 500, + headers: new Headers({ + "Content-Type": "application/json", + }), + }); + + fetchMock.mockResolvedValueOnce(mockedFailedResponse); + fetchMock.mockResolvedValue(mockedFailed500Response); + + const responseStream = await client.requestStream(operation, { + retries: 2, + }); + + for await (const response of responseStream) { + expect(response.errors?.networkStatusCode).toBe(500); + expect(response.errors?.message).toEqual( + "GraphQL Client: Internal Server Error", + ); + } + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("delays a retry by 1000ms", async () => { + const client = getValidClient({ retries: 1 }); + fetchMock.mockResolvedValue(mockedFailedResponse); + + const responseStream = await client.requestStream(operation); + + for await (const response of responseStream) { + expect(response.errors?.networkStatusCode).toBe(status); + } + + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it("logs each retry attempt if a logger is provided", async () => { + const client = getValidClient({ + retries: 2, + logger: mockLogger, + }); + fetchMock.mockResolvedValue(mockedFailedResponse); + await client.requestStream(operation); + + const retryLogs = mockLogger.mock.calls.filter( + (args) => args[0].type === "HTTP-Retry", + ); + + expect(retryLogs.length).toBe(2); + + const requestParams = [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ]; + + const firstLogContent = retryLogs[0][0].content; + expect(firstLogContent.requestParams).toEqual(requestParams); + expect(firstLogContent.lastResponse.status).toBe(status); + expect(firstLogContent.retryAttempt).toBe(1); + expect(firstLogContent.maxRetries).toBe(2); + + const secondLogContent = retryLogs[1][0].content; + expect(secondLogContent.requestParams).toEqual(requestParams); + expect(secondLogContent.lastResponse.status).toBe(status); + expect(secondLogContent.retryAttempt).toBe(2); + expect(secondLogContent.maxRetries).toBe(2); + }); + }); + + it("returns a response object with an error when the retries config value is less than 0", async () => { + const retries = -1; + + const responseStream = await client.requestStream(operation, { + retries, + }); + + for await (const response of responseStream) { + expect(response.errors?.message).toEqual( + `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, + ); + } + }); + + it("returns a response object with an error when the retries config value is greater than 3", async () => { + const retries = 4; + const responseStream = await client.requestStream(operation, { + retries, + }); + + for await (const response of responseStream) { + expect(response.errors?.message).toEqual( + `GraphQL Client: The provided "retries" value (${retries}) is invalid - it cannot be less than 0 or greater than 3`, + ); + } + }); + }); + + describe("request/response logging", () => { + it("logs the request and response info if a logger is provided", async () => { + const mockResponseData = { data: { shop: { name: "Test shop" } } }; + const mockedSuccessResponse = new Response( + JSON.stringify(mockResponseData), + { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }, + ); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + + const client = getValidClient({ logger: mockLogger }); + + await client.requestStream(operation); + + expect(mockLogger).toBeCalledWith({ + type: "HTTP-Response", + content: { + response: mockedSuccessResponse, + requestParams: [ + clientConfig.url, + { + method: "POST", + body: JSON.stringify({ query: operation }), + headers: clientConfig.headers, + }, + ], + }, + }); + }); + }); + }); + }); +}); diff --git a/packages/graphql-client/src/graphql-client/tests/graphql-client/common-tests.ts b/packages/graphql-client/src/graphql-client/tests/graphql-client/common-tests.ts new file mode 100644 index 000000000..abf7d6e38 --- /dev/null +++ b/packages/graphql-client/src/graphql-client/tests/graphql-client/common-tests.ts @@ -0,0 +1,166 @@ +import fetchMock from "jest-fetch-mock"; + +import { createGraphQLClient } from "../../graphql-client"; +import { GraphQLClient } from "../../types"; + +import { operation, variables, clientConfig, getValidClient } from "./fixtures"; + +type ClientFunctionNames = keyof Omit; + +export const fetchApiTests = ( + functionName: ClientFunctionNames, + gqlOperation: string = operation, +) => { + let client: GraphQLClient; + + beforeEach(() => { + client = getValidClient(); + }); + + it("uses the global fetch when a custom fetch API is not provided at initialization ", () => { + client[functionName](gqlOperation); + + expect(fetchMock).toHaveBeenCalledWith(clientConfig.url, { + method: "POST", + headers: clientConfig.headers, + body: JSON.stringify({ + query: gqlOperation, + }), + }); + }); + + it("uses the provided custom fetch when a custom fetch API is provided at initialization ", () => { + const customFetchApi = jest + .fn() + .mockResolvedValue(new Response(JSON.stringify({ data: {} }))) as any; + + const client = createGraphQLClient({ + ...clientConfig, + customFetchApi, + }); + + const props: [string] = [gqlOperation]; + + client[functionName](...props); + + expect(customFetchApi).toHaveBeenCalledWith(clientConfig.url, { + method: "POST", + headers: clientConfig.headers, + body: JSON.stringify({ + query: gqlOperation, + }), + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}; + +export const parametersTests = ( + functionName: ClientFunctionNames, + gqlOperation: string = operation, +) => { + let client: GraphQLClient; + + beforeEach(() => { + client = getValidClient(); + }); + + it("calls fetch API with provided operation", async () => { + await client[functionName](gqlOperation); + expect(fetchMock).toHaveBeenCalledWith(clientConfig.url, { + method: "POST", + headers: clientConfig.headers, + body: JSON.stringify({ + query: gqlOperation, + }), + }); + }); + + it("calls fetch API with provided variables", async () => { + await client[functionName](gqlOperation, { variables }); + expect(fetchMock).toHaveBeenCalledWith(clientConfig.url, { + method: "POST", + headers: clientConfig.headers, + body: JSON.stringify({ + query: gqlOperation, + variables, + }), + }); + }); + + it("calls fetch API with provided url override", async () => { + const url = "http://test-store.myshopify.com/api/2023-07/graphql.json"; + await client[functionName](gqlOperation, { url }); + expect(fetchMock).toHaveBeenCalledWith(url, { + method: "POST", + headers: clientConfig.headers, + body: JSON.stringify({ + query: gqlOperation, + }), + }); + }); + + it("calls fetch API with provided headers override", async () => { + const arrayHeadersProp = "array-headers"; + const arrayHeadersValue = ["1", "2", "3"]; + + const stringHeaders = { + "Content-Type": "application/graphql", + "custom-header": "custom-headers", + }; + + await client[functionName](gqlOperation, { + headers: { ...stringHeaders, [arrayHeadersProp]: arrayHeadersValue }, + }); + + expect(fetchMock).toHaveBeenCalledWith(clientConfig.url, { + method: "POST", + headers: { + ...clientConfig.headers, + ...stringHeaders, + [arrayHeadersProp]: arrayHeadersValue.join(", "), + }, + body: JSON.stringify({ + query: gqlOperation, + }), + }); + }); +}; + +export const retryTests = ( + functionName: ClientFunctionNames, + gqlOperation: string = operation, +) => { + let client: GraphQLClient; + + beforeEach(() => { + client = getValidClient(); + }); + + it("does not retry additional network requests if the initial response is successful", async () => { + const mockedSuccessResponse = new Response(JSON.stringify({ data: {} }), { + status: 200, + headers: new Headers({ + "Content-Type": "application/json", + }), + }); + + fetchMock.mockResolvedValue(mockedSuccessResponse); + await client[functionName](gqlOperation, { retries: 2 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry additional network requests on a failed response that is not a 429 or 503", async () => { + const mockedFailedResponse = new Response(JSON.stringify({ data: {} }), { + status: 500, + headers: new Headers({ + "Content-Type": "application/json", + }), + }); + + fetchMock.mockResolvedValue(mockedFailedResponse); + await client[functionName](gqlOperation, { retries: 2 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}; diff --git a/packages/graphql-client/src/graphql-client/tests/graphql-client/fixtures.ts b/packages/graphql-client/src/graphql-client/tests/graphql-client/fixtures.ts new file mode 100644 index 000000000..11c841a48 --- /dev/null +++ b/packages/graphql-client/src/graphql-client/tests/graphql-client/fixtures.ts @@ -0,0 +1,106 @@ +import { TextEncoder, TextDecoder } from "util"; +import { Readable } from "stream"; + +import { ReadableStream } from "web-streams-polyfill/es2018"; + +import { createGraphQLClient } from "../../graphql-client"; +import { LogContentTypes, ClientOptions } from "../../types"; + +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder as any; + +export const clientConfig = { + url: "http://test-store.myshopify.com/api/2023-10/graphql.json", + headers: { + "Content-Type": "application/json", + "X-Shopify-Storefront-Access-Token": "public-token", + }, +}; + +export const operation = ` +query { + shop { + name + } +} +`; + +export const variables = { + country: "US", +}; + +export function getValidClient({ + retries, + logger, +}: { + retries?: number; + logger?: (logContent: LogContentTypes) => void; +} = {}) { + const updatedConfig: ClientOptions = { ...clientConfig }; + + if (typeof retries === "number") { + updatedConfig.retries = retries; + } + + if (logger !== undefined) { + updatedConfig.logger = logger; + } + + return createGraphQLClient(updatedConfig); +} + +const streamResponseConfig = { + status: 200, + ok: true, + headers: new Headers({ + "Content-Type": "multipart/mixed; boundary=graphql", + }), +}; + +function createReadableStream( + responseArray: string[], + stringEncoder?: (str: any) => Uint8Array, +) { + return new ReadableStream({ + start(controller) { + let index = 0; + queueData(); + function queueData() { + const chunk = responseArray[index]; + const string = stringEncoder ? stringEncoder(chunk) : chunk; + + // Add the string to the stream + controller.enqueue(string); + + index++; + + if (index > responseArray.length - 1) { + controller.close(); + } else { + return queueData(); + } + return {}; + } + }, + }); +} + +export function createReaderStreamResponse(responseArray: string[]) { + const encoder = new TextEncoder(); + const stream = createReadableStream(responseArray, (str) => { + return encoder.encode(str); + }); + + return { + ...streamResponseConfig, + body: { + getReader: () => stream.getReader(), + }, + } as any; +} + +export function createIterableResponse(responseArray: string[]) { + const stream = createReadableStream(responseArray); + + return new Response(Readable.from(stream) as any, streamResponseConfig); +} diff --git a/packages/graphql-client/src/graphql-client/tests/utilities.test.ts b/packages/graphql-client/src/graphql-client/tests/utilities.test.ts index 8fe829ce6..f12d51d78 100644 --- a/packages/graphql-client/src/graphql-client/tests/utilities.test.ts +++ b/packages/graphql-client/src/graphql-client/tests/utilities.test.ts @@ -4,6 +4,10 @@ import { getErrorMessage, validateRetries, getKeyValueIfValid, + getErrorCause, + combineErrors, + buildCombinedDataObject, + buildDataObjectByPath, } from "../utilities"; describe("formatErrorMessage()", () => { @@ -34,6 +38,55 @@ describe("getErrorMessage()", () => { }); }); +describe("getErrorCause()", () => { + it("returns the cause object if its available in the provided error object", () => { + const message = "Test error"; + const cause = { status: 500 }; + const params = [message, { cause }] as any; + + expect(getErrorCause(new Error(...params))).toBe(cause); + }); + + it("returns an undefined if there is no cause object in the provided error object", () => { + const message = "Test error"; + + expect(getErrorCause(new Error(message))).toBeUndefined(); + }); + + it("returns an undefined if the provided object is not an error object", () => { + expect(getErrorCause({ message: "test" })).toBeUndefined(); + }); +}); + +describe("combineErrors()", () => { + it("returns a flat array of errors from multiple error arrays", () => { + const error1 = { message: "Error 1" }; + const error2 = { message: "Error 2" }; + const error3 = { message: "Error 3" }; + + const dataArray = [ + { data: {} }, + { errors: [] }, + { errors: [error1] }, + { errors: [error2, error3] }, + ]; + + expect(combineErrors(dataArray)).toEqual([error1, error2, error3]); + }); + + it("returns an empty array when data array objects do not include errors", () => { + const dataArray = [{ data: {} }, { data: {} }]; + + expect(combineErrors(dataArray).length).toBe(0); + }); + + it("returns an empty array when data array objects include empty errors arrays", () => { + const dataArray = [{ data: {}, errors: [] }, { data: {} }, { errors: [] }]; + + expect(combineErrors(dataArray).length).toBe(0); + }); +}); + describe("validateRetries()", () => { const client = CLIENT; @@ -124,3 +177,146 @@ describe("getKeyValueIfValid()", () => { expect(getKeyValueIfValid(key, value)).toEqual({}); }); }); + +describe("buildDataObjectByPath()", () => { + it("returns an object using the provided path array that leads to the data object", () => { + const path = ["a1", "b1", "c1", "d1"]; + const data = { e1: "text" }; + + const obj = buildDataObjectByPath(path, data); + + expect(obj).toEqual({ + a1: { + b1: { + c1: { + d1: data, + }, + }, + }, + }); + }); + + it("returns the data object if the path array is empty", () => { + const path: string[] = []; + const data = { first: "text" }; + + const obj = buildDataObjectByPath(path, data); + + expect(obj).toEqual(data); + }); +}); + +describe("buildCombinedDataObject()", () => { + it("returns the only object within the provided array", () => { + const obj1 = { t1: "test1" }; + + expect(buildCombinedDataObject([obj1])).toEqual({ ...obj1 }); + }); + + it("returns an object that is the combination of multiple simple objects", () => { + const obj1 = { t1: "test1" }; + const obj2 = { t2: "test2" }; + + expect(buildCombinedDataObject([obj1, obj2])).toEqual({ ...obj1, ...obj2 }); + }); + + it("returns an object that is the combination of the multiple complex objects", () => { + const obj1 = { t1: { t2: { t3: { t4: "test4", t5: "test5" } } } }; + const obj2 = { t1: { a1: "a", t2: { t3: { b1: "b" } } } }; + const obj3 = { x1: "x1", x2: [{ z0: null }, { z1: "z1" }] }; + const obj4 = { x2: { 1: { y1: "y1" } } }; + const obj5 = { x2: { 0: { y0: "y0" } } }; + + expect(buildCombinedDataObject([obj1, obj2, obj3, obj4, obj5])).toEqual({ + t1: { + a1: "a", + t2: { + t3: { + t4: "test4", + t5: "test5", + b1: "b", + }, + }, + }, + x1: "x1", + x2: [ + { z0: null, y0: "y0" }, + { z1: "z1", y1: "y1" }, + ], + }); + }); + + it("returns an object that is the combination of an object with an array and an object that has extra data to a specific array item", () => { + const obj1 = { t1: { t2: [{ a1: "a1" }, { a2: "a2" }] } }; + const obj2 = { t1: { t2: { 0: { b1: "b1" } } } }; + + expect(buildCombinedDataObject([obj1, obj2])).toEqual({ + t1: { + t2: [{ a1: "a1", b1: "b1" }, { a2: "a2" }], + }, + }); + }); + + it("returns an object with an array that is the combination of multiple arrays", () => { + const obj1 = { t1: [{ a1: "a1" }, { a2: "a2" }] }; + const obj2 = { t1: [{ b1: "b1" }, { a3: "a3" }] }; + + expect(buildCombinedDataObject([obj1, obj2])).toEqual({ + t1: [ + { a1: "a1", b1: "b1" }, + { a2: "a2", a3: "a3" }, + ], + }); + }); +}); + +describe("getKeyValueIfValid()", () => { + it("returns an object with the provided key and value if the provided value is a string", () => { + const key = "data"; + const value = "test"; + + expect(getKeyValueIfValid(key, value)).toEqual({ [key]: value }); + }); + + it("returns an object with the provided key and value if the provided value is a number", () => { + const key = "data"; + const value = 3; + + expect(getKeyValueIfValid(key, value)).toEqual({ [key]: value }); + }); + + it("returns an object with the provided key and value if the provided value exists and is a non empty object", () => { + const key = "data"; + const value = { name: "test" }; + + expect(getKeyValueIfValid(key, value)).toEqual({ [key]: value }); + }); + + it("returns an object with the provided key and value if the provided value exists and is an array", () => { + const key = "data"; + const value = ["test"]; + + expect(getKeyValueIfValid(key, value)).toEqual({ [key]: value }); + }); + + it("returns an object with the provided key and value if the provided value exists and is an empty array", () => { + const key = "data"; + const value = []; + + expect(getKeyValueIfValid(key, value)).toEqual({ [key]: value }); + }); + + it("returns an empty object if the provided object exists but is an empty object", () => { + const key = "data"; + const value = {}; + + expect(getKeyValueIfValid(key, value)).toEqual({}); + }); + + it("returns an empty object if the provided object is undefined", () => { + const key = "data"; + const value = undefined; + + expect(getKeyValueIfValid(key, value)).toEqual({}); + }); +}); diff --git a/packages/graphql-client/src/graphql-client/types.ts b/packages/graphql-client/src/graphql-client/types.ts index a11b5582e..f49024b4b 100644 --- a/packages/graphql-client/src/graphql-client/types.ts +++ b/packages/graphql-client/src/graphql-client/types.ts @@ -9,6 +9,8 @@ export type CustomFetchApi = ( type OperationVariables = Record; +export type DataChunk = Buffer | Uint8Array; + export type Headers = Record; export interface ResponseErrors { @@ -29,6 +31,16 @@ export interface ClientResponse extends FetchResponseBody { errors?: ResponseErrors; } +export interface ClientStreamResponse + extends Omit, "data"> { + data?: Partial; + hasNext: boolean; +} + +export interface ClientStreamIterator { + [Symbol.asyncIterator](): AsyncIterator>; +} + export interface LogContent { type: string; content: any; @@ -87,4 +99,7 @@ export interface GraphQLClient { request: ( ...props: RequestParams ) => Promise>; + requestStream: ( + ...props: RequestParams + ) => Promise>; } diff --git a/packages/graphql-client/src/graphql-client/utilities.ts b/packages/graphql-client/src/graphql-client/utilities.ts index 423a91b54..49bd7a1ac 100644 --- a/packages/graphql-client/src/graphql-client/utilities.ts +++ b/packages/graphql-client/src/graphql-client/utilities.ts @@ -8,6 +8,16 @@ export function getErrorMessage(error: any) { return error instanceof Error ? error.message : JSON.stringify(error); } +export function getErrorCause(error: any): Record | undefined { + return error instanceof Error && error.cause ? error.cause : undefined; +} + +export function combineErrors(dataArray: Record[]) { + return dataArray.flatMap(({ errors }) => { + return errors ?? []; + }); +} + export function validateRetries({ client, retries, @@ -35,3 +45,48 @@ export function getKeyValueIfValid(key: string, value?: any) { ? { [key]: value } : {}; } + +export function buildDataObjectByPath( + path: string[], + data: any, +): Record { + if (path.length === 0) { + return data; + } + + const key = path.pop() as string | number; + const newData = { + [key]: data, + }; + + if (path.length === 0) { + return newData; + } + + return buildDataObjectByPath(path, newData); +} + +function combineObjects(baseObject: any, newObject: any) { + return Object.keys(newObject || {}).reduce( + (acc: any, key: string | number) => { + 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 }, + ); +} + +export function buildCombinedDataObject([ + initialDatum, + ...remainingData +]: any[]) { + return remainingData.reduce(combineObjects, { ...initialDatum }); +} diff --git a/packages/storefront-api-client/README.md b/packages/storefront-api-client/README.md index d71d8da4b..38c398f31 100644 --- a/packages/storefront-api-client/README.md +++ b/packages/storefront-api-client/README.md @@ -82,10 +82,11 @@ const client = createStorefrontApiClient({ | Property | Type | Description | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | config | [`StorefrontApiClientConfig`](#storefrontapiclientconfig-properties) | Configuration for the client | -| getHeaders | `(headers?: {[key: string]: string}) => {[key: string]: string}` | Returns Storefront API specific headers needed to interact with the API. If additional `headers` are provided, the custom headers will be included in the returned headers object. | +| getHeaders | `(headers?: Record) => Record` | Returns Storefront API specific headers needed to interact with the API. If additional `headers` are provided, the custom headers will be included in the returned headers object. | | getApiUrl | `(apiVersion?: string) => string` | Returns the shop specific API url. If an API version is provided, the returned URL will include the provided version, else the URL will include the API version set at client initialization. | | fetch | `(operation: string, options?: `[`ApiClientRequestOptions`](#apiclientrequestoptions-properties)`) => Promise` | Fetches data from Storefront API using the provided GQL `operation` string and [`ApiClientRequestOptions`](#apiclientrequestoptions-properties) object and returns the network response. | | request | `(operation: string, options?: `[`ApiClientRequestOptions`](#apiclientrequestoptions-properties)`) => Promise<`[`ClientResponse`](#ClientResponsetdata)`>` | Requests data from Storefront API using the provided GQL `operation` string and [`ApiClientRequestOptions`](#apiclientrequestoptions-properties) object and returns a normalized response object. | +| requestStream | `(operation: string, options?: `[`RequestOptions`](#requestoptions-properties)`) => Promise `](#clientstreamresponsetdata)`>>` | Fetches GQL operations that can result in a streamed response from the API. The function returns an async iterator and the iterator will return [normalized stream response objects](#clientstreamresponsetdata) as data becomes available through the stream. | ## `StorefrontApiClientConfig` properties @@ -96,7 +97,7 @@ const client = createStorefrontApiClient({ | apiVersion | `string` | The Storefront API version to use in the API request | | publicAccessToken | `string \| never` | The provided public access token. If `privateAccessToken` was provided, `publicAccessToken` will not be available. | | privateAccessToken | `string \| never` | The provided private access token. If `publicAccessToken` was provided, `privateAccessToken` will not be available. | -| headers | `{[key: string]: string}` | The headers generated by the client during initialization | +| headers | `Record` | The headers generated by the client during initialization | | apiUrl | `string` | The API URL generated from the provided store domain and api version | | clientName? | `string` | The provided client name | | retries? | `number` | The number of retries the client will attempt when the API responds with a `Too Many Requests (429)` or `Service Unavailable (503)` response | @@ -106,9 +107,9 @@ const client = createStorefrontApiClient({ | Name | Type | Description | | -------------- | ------------------------ | ---------------------------------------------------- | -| variables? | `{[key: string]: any}` | Variable values needed in the graphQL operation | +| variables? | `Record` | Variable values needed in the graphQL operation | | apiVersion? | `string` | The Storefront API version to use in the API request | -| headers? | `{[key: string]: string}` | Customized headers to be included in the API request | +| headers? | `Record` | Customized headers to be included in the API request | | retries? | `number` | Alternative number of retries for the request. Retries only occur for requests that were abandoned or if the server responds with a `Too Many Request (429)` or `Service Unavailable (503)` response. Minimum value is `0` and maximum value is `3`.| ## `ClientResponse` @@ -117,7 +118,16 @@ const client = createStorefrontApiClient({ | ----------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | data? | `TData \| any` | Data returned from the Storefront API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. | | errors? | [`ResponseErrors`](#responseerrors) | Errors object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. | -| extensions? | `{[key: string]: any}` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. | +| extensions? | `Record` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. | + +## `ClientStreamResponse` + +| Name | Type | Description | +| ----------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| data? | `Partial \| any` | Currently available data returned from the Storefront API. If `TData` was provided to the function, the return type is `TData`, else it returns type `any`. | +| errors? | [`ResponseErrors`](#responseerrors) | Errors object that contains any API or network errors that occured while fetching the data from the API. It does not include any `UserErrors`. | +| extensions? | `Record` | Additional information on the GraphQL response data and context. It can include the `context` object that contains the context settings used to generate the returned API response. | +| hasNext | `boolean` | Flag to indicate whether the response stream has more incoming data | ## `ResponseErrors` @@ -220,6 +230,32 @@ const {data, errors, extensions} = await client.request(productQuery, { }); ``` +### Query for product info using the `@defer` directive + +```typescript +const productQuery = ` + query ProductQuery($handle: String) { + product(handle: $handle) { + id + handle + ... @defer(label: "deferredFields") { + title + description + } + } + } +`; + +const responseStream = await client.requestStream(productQuery, { + variables: {handle: 'sample-product'}, +}); + +// await available data from the async iterator +for await (const response of responseStream) { + const {data, errors, extensions, hasNext} = response; +} +``` + ### Create a localized cart ```typescript @@ -329,16 +365,17 @@ const {data, errors, extensions} = await client.request(productQuery, { }); ``` -### Provide GQL query type to `client.request()` +### Provide GQL query type to `client.request()` and `client.requestStream()` ```typescript import {print} from 'graphql/language'; // GQL operation types are usually auto generated during the application build -import {CollectionQuery} from 'types/appTypes'; +import {CollectionQuery, CollectionDeferredQuery} from 'types/appTypes'; import collectionQuery from './collectionQuery.graphql'; +import collectionDeferredQuery from './collectionDeferredQuery.graphql'; -const {data, error, extensions} = await client.request( +const {data, errors, extensions} = await client.request( print(collectionQuery), { variables: { @@ -346,6 +383,13 @@ const {data, error, extensions} = await client.request( }, } ); + +const responseStream = await client.requestStream( + print(collectionDeferredQuery), + { + variables: {handle: 'sample-collection'}, + } +); ``` ### Using `client.fetch()` to get API data diff --git a/packages/storefront-api-client/src/storefront-api-client.ts b/packages/storefront-api-client/src/storefront-api-client.ts index a38935a33..a660964dc 100644 --- a/packages/storefront-api-client/src/storefront-api-client.ts +++ b/packages/storefront-api-client/src/storefront-api-client.ts @@ -110,6 +110,9 @@ export function createStorefrontApiClient({ request: (...props) => { return graphqlClient.request(...getGQLClientParams(...props)); }, + requestStream: (...props) => { + return graphqlClient.requestStream(...getGQLClientParams(...props)); + }, }; return Object.freeze(client); diff --git a/packages/storefront-api-client/src/tests/storefront-api-client/fixtures.ts b/packages/storefront-api-client/src/tests/storefront-api-client/fixtures.ts index fe2254019..8dee20bdd 100644 --- a/packages/storefront-api-client/src/tests/storefront-api-client/fixtures.ts +++ b/packages/storefront-api-client/src/tests/storefront-api-client/fixtures.ts @@ -26,4 +26,5 @@ export const graphqlClientMock: GraphQLClient = { }, fetch: jest.fn(), request: jest.fn(), + requestStream: jest.fn(), }; diff --git a/packages/storefront-api-client/src/tests/storefront-api-client/storefront-api-client.test.ts b/packages/storefront-api-client/src/tests/storefront-api-client/storefront-api-client.test.ts index 1a792e9bb..9dc3fc99d 100644 --- a/packages/storefront-api-client/src/tests/storefront-api-client/storefront-api-client.test.ts +++ b/packages/storefront-api-client/src/tests/storefront-api-client/storefront-api-client.test.ts @@ -34,6 +34,7 @@ describe("Storefront API Client", () => { const mockRequestResponse = { data: {}, }; + const mockRequestStreamResponse = {}; beforeEach(() => { (createGraphQLClient as jest.Mock).mockReturnValue(graphqlClientMock); @@ -106,15 +107,16 @@ describe("Storefront API Client", () => { ).toHaveProperty("logger", logger); }); - it("returns a client object that contains a config object, getters for header and API URL and request and fetch functions", () => { + it("returns a client object that contains a config object, getters for header and API URL and request, requestStream and fetch functions", () => { const client = createStorefrontApiClient(config); expect(client).toHaveProperty("config"); expect(client).toMatchObject({ getHeaders: expect.any(Function), getApiUrl: expect.any(Function), - request: expect.any(Function), fetch: expect.any(Function), + request: expect.any(Function), + requestStream: expect.any(Function), }); }); @@ -329,242 +331,165 @@ describe("Storefront API Client", () => { }); }); - describe("getHeaders()", () => { - let client: StorefrontApiClient; - - beforeEach(() => { - client = createStorefrontApiClient(config); - }); - - it("returns the client's default headers if no custom headers are provided", () => { - const headers = client.getHeaders(); - expect(headers).toEqual(client.config.headers); - }); - - it("returns a headers object that contains both the client default headers and the provided custom headers", () => { - const headers = { - "Shopify-Storefront-Id": "test-id", - }; - const updatedHeaders = client.getHeaders(headers); - expect(updatedHeaders).toEqual({ - ...headers, - ...client.config.headers, - }); - }); - }); + describe("client functions", () => { + const operation = ` + query products{ + products(first: 1) { + nodes { + id + title + } + } + } + `; - describe("getApiUrl()", () => { let client: StorefrontApiClient; beforeEach(() => { - client = createStorefrontApiClient(config); - }); - - it("returns the client's default API url if no API version was provided", () => { - const url = client.getApiUrl(); - expect(url).toBe(client.config.apiUrl); - }); - - it("returns an API url that is directed at the provided api version", () => { - const version = "unstable"; - const url = client.getApiUrl(version); - expect(url).toEqual( - `${config.storeDomain}/api/${version}/graphql.json`, - ); - }); - - it("throws an error when the api version is not a string", () => { - const version = 123; - expect(() => client.getApiUrl(version as any)).toThrow( - new Error( - `Storefront API Client: the provided apiVersion ("123") is invalid. Currently supported API versions: ${mockApiVersions.join( - ", ", - )}`, - ), + (graphqlClientMock.fetch as jest.Mock).mockResolvedValue( + mockFetchResponse, ); - }); - - it("console warns when a unsupported api version is provided", () => { - const consoleWarnSpy = jest - .spyOn(global.console, "warn") - .mockImplementation(jest.fn()); - - const version = "2021-01"; - client.getApiUrl(version); - expect(consoleWarnSpy).toHaveBeenCalledWith( - `Storefront API Client: the provided apiVersion ("2021-01") is likely deprecated or not supported. Currently supported API versions: ${mockApiVersions.join( - ", ", - )}`, + (graphqlClientMock.request as jest.Mock).mockResolvedValue( + mockRequestResponse, ); - }); - }); - describe("fetch()", () => { - let client: StorefrontApiClient; - const operation = ` - query products{ - products(first: 1) { - nodes { - id - title - } - } - } - `; - - beforeEach(() => { - (graphqlClientMock.fetch as jest.Mock).mockResolvedValue( - mockFetchResponse, + (graphqlClientMock.requestStream as jest.Mock).mockResolvedValue( + mockRequestStreamResponse, ); client = createStorefrontApiClient(config); }); - afterEach(() => { - jest.resetAllMocks(); - }); - - describe("parameters", () => { - it("calls the graphql client fetch() with just the operation string when there are no options provided", async () => { - await client.fetch(operation); - - expect(graphqlClientMock.fetch).toHaveBeenCalledWith(operation); + describe("getHeaders()", () => { + it("returns the client's default headers if no custom headers are provided", () => { + const headers = client.getHeaders(); + expect(headers).toEqual(client.config.headers); }); - it("calls the graphql client fetch() with provided variables", async () => { - const variables = { first: 1 }; - - await client.fetch(operation, { variables }); - expect((graphqlClientMock.fetch as jest.Mock).mock.calls[0][0]).toBe( - operation, - ); - expect( - (graphqlClientMock.fetch as jest.Mock).mock.calls[0][1], - ).toEqual({ variables }); - }); - - it("calls the graphql client fetch() with customized headers", async () => { - const headers = { "custom-header": "custom" }; - - await client.fetch(operation, { headers }); - expect( - (graphqlClientMock.fetch as jest.Mock).mock.calls.pop()[1], - ).toEqual({ - headers: client.getHeaders(headers), + it("returns a headers object that contains both the client default headers and the provided custom headers", () => { + const headers = { + "Shopify-Storefront-Id": "test-id", + }; + const updatedHeaders = client.getHeaders(headers); + expect(updatedHeaders).toEqual({ + ...headers, + ...client.config.headers, }); }); + }); - it("calls the graphql client fetch() with provided api version URL", async () => { - const apiVersion = "unstable"; - - await client.fetch(operation, { apiVersion }); - expect( - (graphqlClientMock.fetch as jest.Mock).mock.calls.pop()[1], - ).toEqual({ - url: client.getApiUrl(apiVersion), - }); + describe("getApiUrl()", () => { + it("returns the client's default API url if no API version was provided", () => { + const url = client.getApiUrl(); + expect(url).toBe(client.config.apiUrl); }); - it("calls the graphql client fetch() with provided retries", async () => { - const retries = 2; - - await client.fetch(operation, { retries }); - expect( - (graphqlClientMock.fetch as jest.Mock).mock.calls.pop()[1], - ).toEqual({ - retries, - }); + it("returns an API url that is directed at the provided api version", () => { + const version = "unstable"; + const url = client.getApiUrl(version); + expect(url).toEqual( + `${config.storeDomain}/api/${version}/graphql.json`, + ); }); - }); - - it("returns the graphql client fetch response", async () => { - const response = await client.fetch(operation); - expect(response).toBe(mockFetchResponse); - }); - }); - describe("request()", () => { - let client: StorefrontApiClient; - const operation = ` - query products{ - products(first: 1) { - nodes { - id - title - } - } - } - `; - - beforeEach(() => { - (graphqlClientMock.request as jest.Mock).mockResolvedValue( - mockRequestResponse, - ); - - client = createStorefrontApiClient(config); - }); + it("throws an error when the api version is not a string", () => { + const version = 123; + expect(() => client.getApiUrl(version as any)).toThrow( + new Error( + `Storefront API Client: the provided apiVersion ("123") is invalid. Currently supported API versions: ${mockApiVersions.join( + ", ", + )}`, + ), + ); + }); - afterEach(() => { - jest.resetAllMocks(); - }); + it("console warns when a unsupported api version is provided", () => { + const consoleWarnSpy = jest + .spyOn(global.console, "warn") + .mockImplementation(jest.fn()); - describe("parameters", () => { - it("calls the graphql client request() with just the operation string when there are no options provided", async () => { - await client.request(operation); + const version = "2021-01"; + client.getApiUrl(version); - expect(graphqlClientMock.request).toHaveBeenCalledWith(operation); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Storefront API Client: the provided apiVersion ("2021-01") is likely deprecated or not supported. Currently supported API versions: ${mockApiVersions.join( + ", ", + )}`, + ); }); + }); - it("calls the graphql client request() with provided variables", async () => { - const variables = { first: 1 }; - - await client.request(operation, { variables }); - expect( - (graphqlClientMock.request as jest.Mock).mock.calls[0][0], - ).toBe(operation); - expect( - (graphqlClientMock.request as jest.Mock).mock.calls[0][1], - ).toEqual({ variables }); - }); + describe.each([ + ["fetch", mockFetchResponse], + ["request", mockRequestResponse], + ["requestStream", mockRequestStreamResponse], + ])("%s()", (functionName, mockResponse) => { + describe("parameters", () => { + it(`calls the graphql client ${functionName}() with just the operation string when there are no options provided`, async () => { + await client[functionName](operation); + + expect(graphqlClientMock[functionName]).toHaveBeenCalledWith( + operation, + ); + }); - it("calls the graphql client request() with customized headers", async () => { - const headers = { "custom-header": "custom" }; + it(`calls the graphql client ${functionName}() with provided variables`, async () => { + const variables = { first: 1 }; - await client.request(operation, { headers }); - expect( - (graphqlClientMock.request as jest.Mock).mock.calls.pop()[1], - ).toEqual({ - headers: client.getHeaders(headers), + await client[functionName](operation, { variables }); + expect( + (graphqlClientMock[functionName] as jest.Mock).mock.calls[0][0], + ).toBe(operation); + expect( + (graphqlClientMock[functionName] as jest.Mock).mock.calls[0][1], + ).toEqual({ variables }); }); - }); - - it("calls the graphql client request() with provided api version URL", async () => { - const apiVersion = "unstable"; - await client.request(operation, { apiVersion }); - expect( - (graphqlClientMock.request as jest.Mock).mock.calls.pop()[1], - ).toEqual({ - url: client.getApiUrl(apiVersion), + it(`calls the graphql client ${functionName}() with customized headers`, async () => { + const headers = { "custom-header": "custom" }; + + await client[functionName](operation, { headers }); + expect( + ( + graphqlClientMock[functionName] as jest.Mock + ).mock.calls.pop()[1], + ).toEqual({ + headers: client.getHeaders(headers), + }); }); - }); - it("calls the graphql client request() with provided retries", async () => { - const retries = 2; + it(`calls the graphql client ${functionName}() with provided api version URL`, async () => { + const apiVersion = "unstable"; + + await client[functionName](operation, { apiVersion }); + expect( + ( + graphqlClientMock[functionName] as jest.Mock + ).mock.calls.pop()[1], + ).toEqual({ + url: client.getApiUrl(apiVersion), + }); + }); - await client.request(operation, { retries }); - expect( - (graphqlClientMock.request as jest.Mock).mock.calls.pop()[1], - ).toEqual({ - retries, + it(`calls the graphql client ${functionName}() with provided retries`, async () => { + const retries = 2; + + await client[functionName](operation, { retries }); + expect( + ( + graphqlClientMock[functionName] as jest.Mock + ).mock.calls.pop()[1], + ).toEqual({ + retries, + }); }); }); - }); - it("returns the graphql client request response", async () => { - const response = await client.request(operation); - expect(response).toBe(mockRequestResponse); + it(`returns the graphql client ${functionName} response`, async () => { + const response = await client[functionName](operation); + expect(response).toBe(mockResponse); + }); }); }); }); diff --git a/packages/storefront-api-client/src/types.ts b/packages/storefront-api-client/src/types.ts index b6084ce5d..a6a9c27f8 100644 --- a/packages/storefront-api-client/src/types.ts +++ b/packages/storefront-api-client/src/types.ts @@ -4,6 +4,7 @@ import { ApiClientLogger, ApiClientLogContentTypes, ApiClientConfig, + ApiClientRequestStream, } from "@shopify/graphql-client"; export type StorefrontApiClientLogContentTypes = ApiClientLogContentTypes; @@ -42,4 +43,6 @@ export type StorefrontOperations = StorefrontQueries & StorefrontMutations; export type StorefrontApiClient = ApiClient< StorefrontApiClientConfig, StorefrontOperations ->; +> & { + requestStream: ApiClientRequestStream; +};