From b830e5756088238d3a520b7ffce870fe80a36fff Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Tue, 7 Nov 2023 13:16:27 -0500 Subject: [PATCH] Add util factories to generate common API client functions --- .changeset/thin-avocados-trade.md | 5 + .../src/api-client-utilities/index.ts | 1 + .../tests/utilities.test.ts | 147 ++++++++++++++++++ .../src/api-client-utilities/types.ts | 16 +- .../src/api-client-utilities/utilities.ts | 44 ++++++ 5 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 .changeset/thin-avocados-trade.md create mode 100644 packages/graphql-client/src/api-client-utilities/tests/utilities.test.ts create mode 100644 packages/graphql-client/src/api-client-utilities/utilities.ts diff --git a/.changeset/thin-avocados-trade.md b/.changeset/thin-avocados-trade.md new file mode 100644 index 000000000..4512e792a --- /dev/null +++ b/.changeset/thin-avocados-trade.md @@ -0,0 +1,5 @@ +--- +"@shopify/graphql-client": minor +--- + +Add API client utility factories for generating the `getHeaders()` and `getGQLClientParams()` functions diff --git a/packages/graphql-client/src/api-client-utilities/index.ts b/packages/graphql-client/src/api-client-utilities/index.ts index 2cf5f04ca..9d84a5bc0 100644 --- a/packages/graphql-client/src/api-client-utilities/index.ts +++ b/packages/graphql-client/src/api-client-utilities/index.ts @@ -2,4 +2,5 @@ export { validateRetries, getErrorMessage } from "../graphql-client/utilities"; export * from "./validations"; export * from "./api-versions"; +export * from "./utilities"; export type * from "./types"; diff --git a/packages/graphql-client/src/api-client-utilities/tests/utilities.test.ts b/packages/graphql-client/src/api-client-utilities/tests/utilities.test.ts new file mode 100644 index 000000000..8aa6752f9 --- /dev/null +++ b/packages/graphql-client/src/api-client-utilities/tests/utilities.test.ts @@ -0,0 +1,147 @@ +import { generateGetHeaders, generateGetGQLClientParams } from "../utilities"; + +describe("generateGetHeaders()", () => { + const config = { + storeDomain: "https://test.shopify.io", + apiUrl: "https://test.shopify.io/api/2023-10/graphql.json", + apiVersion: "2023-10", + headers: { + "X-Shopify-Storefront-Access-Token": "public-access-token", + }, + }; + + let getHeader: ReturnType; + + beforeEach(() => { + getHeader = generateGetHeaders(config); + }); + + it("returns a function ", () => { + expect(getHeader).toEqual(expect.any(Function)); + }); + + describe("returned function", () => { + it("returns the config headers if no custom headers were passed in", () => { + expect(getHeader()).toEqual(config.headers); + }); + + it("returns a set of headers that includes both the provided custom headers and the config headers", () => { + const customHeaders = { + "Shopify-Storefront-Id": "shop-id", + }; + + expect(getHeader(customHeaders)).toEqual({ + ...customHeaders, + ...config.headers, + }); + }); + + it("returns a set of headers where the client config headers cannot be overwritten with the custom headers", () => { + const customHeaders = { + "Shopify-Storefront-Id": "shop-id", + "X-Shopify-Storefront-Access-Token": "", + }; + + const headers = getHeader(customHeaders); + expect(headers["X-Shopify-Storefront-Access-Token"]).toEqual( + config.headers["X-Shopify-Storefront-Access-Token"] + ); + }); + }); +}); + +describe("generateGetGQLClientParams()", () => { + const mockHeaders = { + "X-Shopify-Storefront-Access-Token": "public-access-token", + }; + const mockApiUrl = "https://test.shopify.io/api/unstable/graphql.json"; + const operation = ` + query products{ + products(first: 1) { + nodes { + id + title + } + } + } + `; + + let getHeaderMock: jest.Mock; + let getApiUrlMock: jest.Mock; + let getGQLClientParams: ReturnType; + + beforeEach(() => { + getHeaderMock = jest.fn().mockReturnValue(mockHeaders); + getApiUrlMock = jest.fn().mockReturnValue(mockApiUrl); + getGQLClientParams = generateGetGQLClientParams({ + getHeaders: getHeaderMock, + getApiUrl: getApiUrlMock, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("returns a function", () => { + expect(getGQLClientParams).toEqual(expect.any(Function)); + }); + + describe("returned function", () => { + it("returns an array with only the operation string if no additional options were passed into the function", () => { + const params = getGQLClientParams(operation); + + expect(params).toHaveLength(1); + expect(params[0]).toBe(operation); + }); + + it("returns an array with only the operation string if an empty options object was passed into the function", () => { + const params = getGQLClientParams(operation, {}); + + expect(params).toHaveLength(1); + expect(params[0]).toBe(operation); + }); + + it("returns an array with the operation string and an option with variables when variables were provided", () => { + const variables = { first: 10 }; + const params = getGQLClientParams(operation, { variables }); + + expect(params).toHaveLength(2); + + expect(params[0]).toBe(operation); + expect(params[1]).toEqual({ variables }); + }); + + it("returns an array with the operation string and an option with headers when custom headers were provided", () => { + const customHeaders = { "Shopify-Storefront-Id": "shop-id" }; + const params = getGQLClientParams(operation, { customHeaders }); + + expect(params).toHaveLength(2); + + expect(params[0]).toBe(operation); + expect(getHeaderMock).toHaveBeenCalledWith(customHeaders); + expect(params[1]).toEqual({ headers: mockHeaders }); + }); + + it("returns an array with the operation string and an option with url when an api version was provided", () => { + const apiVersion = "unstable"; + const params = getGQLClientParams(operation, { apiVersion }); + + expect(params).toHaveLength(2); + + expect(params[0]).toBe(operation); + expect(getApiUrlMock).toHaveBeenCalledWith(apiVersion); + expect(params[1]).toEqual({ url: mockApiUrl }); + }); + + it("returns an array with the operation string and an option with retries when a retries value was provided", () => { + const retries = 2; + const params = getGQLClientParams(operation, { retries }); + + expect(params).toHaveLength(2); + + expect(params[0]).toBe(operation); + expect(params[1]).toEqual({ retries }); + }); + }); +}); diff --git a/packages/graphql-client/src/api-client-utilities/types.ts b/packages/graphql-client/src/api-client-utilities/types.ts index ce38520b4..38327940c 100644 --- a/packages/graphql-client/src/api-client-utilities/types.ts +++ b/packages/graphql-client/src/api-client-utilities/types.ts @@ -24,11 +24,11 @@ export type ApiClientLogger = BaseLogger; export interface ApiClientConfig { - readonly storeDomain: string; - readonly apiVersion: string; - readonly headers: Headers; - readonly apiUrl: string; - readonly retries?: number; + storeDomain: string; + apiVersion: string; + headers: Headers; + apiUrl: string; + retries?: number; } export interface ApiClientRequestOptions { @@ -43,8 +43,10 @@ export type ApiClientRequestParams = [ options?: ApiClientRequestOptions ]; -export interface ApiClient { - readonly config: TClientConfig; +export interface ApiClient< + TClientConfig extends ApiClientConfig = ApiClientConfig +> { + readonly config: Readonly; getHeaders: (customHeaders?: Headers) => Headers; getApiUrl: (apiVersion?: string) => string; fetch: ( diff --git a/packages/graphql-client/src/api-client-utilities/utilities.ts b/packages/graphql-client/src/api-client-utilities/utilities.ts new file mode 100644 index 000000000..9268d5390 --- /dev/null +++ b/packages/graphql-client/src/api-client-utilities/utilities.ts @@ -0,0 +1,44 @@ +import { RequestParams } from "../graphql-client/types"; + +import { ApiClient, ApiClientConfig, ApiClientRequestOptions } from "./types"; + +export function generateGetHeaders( + config: ApiClientConfig +): ApiClient["getHeaders"] { + return (customHeaders) => { + return { ...(customHeaders ?? {}), ...config.headers }; + }; +} + +export function generateGetGQLClientParams({ + getHeaders, + getApiUrl, +}: { + getHeaders: ApiClient["getHeaders"]; + getApiUrl: ApiClient["getApiUrl"]; +}) { + return ( + operation: string, + options?: ApiClientRequestOptions + ): RequestParams => { + const props: RequestParams = [operation]; + + if (options && Object.keys(options).length > 0) { + const { + variables, + apiVersion: propApiVersion, + customHeaders, + retries, + } = options; + + props.push({ + ...(variables ? { variables } : {}), + ...(customHeaders ? { headers: getHeaders(customHeaders) } : {}), + ...(propApiVersion ? { url: getApiUrl(propApiVersion) } : {}), + ...(retries ? { retries } : {}), + }); + } + + return props; + }; +}