diff --git a/.changeset/empty-houses-sparkle.md b/.changeset/empty-houses-sparkle.md new file mode 100644 index 000000000..49dbd650e --- /dev/null +++ b/.changeset/empty-houses-sparkle.md @@ -0,0 +1,6 @@ +--- +"@shopify/admin-api-client": minor +"@shopify/graphql-client": minor +--- + +Added the ability to automatically type GraphQL queries when the files created by @shopify/api-codegen-preset are loaded for the app. diff --git a/packages/admin-api-client/README.md b/packages/admin-api-client/README.md index 074c7cfce..5be4caf68 100644 --- a/packages/admin-api-client/README.md +++ b/packages/admin-api-client/README.md @@ -277,6 +277,54 @@ const {data, errors, extensions} = await client.request(productQuery, { }); ``` +## Typing variables and return objects + +This client is compatible with the `@shopify/api-codegen-preset` package. +You can use that package to create types from your operations with the [Codegen CLI](https://www.graphql-cli.com/codegen/). + +There are different ways to [configure codegen](https://github.com/Shopify/shopify-api-js/tree/main/packages/api-codegen-preset#configuration) with it, but the simplest way is to: + +1. Add the preset package as a dev dependency to your project, for example: + ```bash + npm install --save-dev @shopify/api-codegen-preset + ``` +1. Create a `.graphqlrc.ts` file in your root containing: + ```ts + import { ApiType, shopifyApiProject } from "@shopify/api-codegen-preset"; + + export default { + schema: "https://shopify.dev/admin-graphql-direct-proxy", + documents: ["*.ts", "!node_modules"], + projects: { + default: shopifyApiProject({ + apiType: ApiType.Admin, + apiVersion: "2023-10", + outputDir: "./types", + }), + }, + }; + ``` +1. Add `"graphql-codegen": "graphql-codegen"` to your `scripts` section in `package.json`. +1. Tag your operations with `#graphql`, for example: + ```ts + const {data, errors, extensions} = await client.request( + `#graphql + query Shop { + shop { + name + } + }` + ); + console.log(data?.shop.name); + ``` +1. Run `npm run graphql-codegen` to parse the types from your operations. + +> [!NOTE] +> Remember to ensure that your tsconfig includes the files under `./types`! + +Once the script runs, it'll create the file `./types/admin.generated.d.ts`. +When TS includes that file, it'll automatically cause the client to detect the types for each query. + ## Log Content Types ### `UnsupportedApiVersionLog` diff --git a/packages/admin-api-client/package.json b/packages/admin-api-client/package.json index 3ab567be0..d292c93e4 100644 --- a/packages/admin-api-client/package.json +++ b/packages/admin-api-client/package.json @@ -63,38 +63,8 @@ "@shopify/graphql-client": "^0.7.0" }, "devDependencies": { - "@babel/core": "^7.21.3", - "@babel/plugin-transform-async-to-generator": "^7.20.7", - "@babel/plugin-transform-runtime": "^7.21.0", - "@babel/preset-env": "^7.20.2", - "@babel/preset-typescript": "^7.21.0", - "@changesets/changelog-github": "^0.4.8", - "@changesets/cli": "^2.26.1", - "@rollup/plugin-babel": "^6.0.3", - "@rollup/plugin-commonjs": "^24.0.1", - "@rollup/plugin-eslint": "^9.0.3", - "@rollup/plugin-node-resolve": "^15.0.1", - "@rollup/plugin-replace": "^5.0.2", - "@rollup/plugin-terser": "^0.4.0", - "@rollup/plugin-typescript": "^11.0.0", - "@shopify/babel-preset": "^25.0.0", - "@shopify/eslint-plugin": "^42.0.3", - "@shopify/prettier-config": "^1.1.2", - "@shopify/typescript-configs": "^5.1.0", - "@types/jest": "^29.5.0", - "@types/regenerator-runtime": "^0.13.1", - "@typescript-eslint/parser": "^6.7.5", - "babel-jest": "^29.5.0", - "eslint": "^8.51.0", - "jest": "^29.7.0", "jest-environment-jsdom": "^29.5.0", - "jest-fetch-mock": "^3.0.3", - "prettier": "^2.5.1", - "regenerator-runtime": "^0.13.11", - "rollup": "^3.19.1", - "rollup-plugin-dts": "^5.2.0", - "tslib": "^2.5.0", - "typescript": "^5.2.0" + "regenerator-runtime": "^0.14.0" }, "bugs": { "url": "https://github.com/Shopify/shopify-api-js/issues" diff --git a/packages/admin-api-client/src/admin-api-client.ts b/packages/admin-api-client/src/admin-api-client.ts index d7fb14fe6..7bd7e92ae 100644 --- a/packages/admin-api-client/src/admin-api-client.ts +++ b/packages/admin-api-client/src/admin-api-client.ts @@ -5,13 +5,13 @@ import { validateDomainAndGetStoreUrl, generateGetGQLClientParams, generateGetHeaders, - ApiClientRequestParams, } from "@shopify/graphql-client"; import { AdminApiClientOptions, AdminApiClient, AdminApiClientConfig, + AdminOperations, } from "./types"; import { DEFAULT_CONTENT_TYPE, @@ -88,25 +88,21 @@ export function createAdminApiClient({ const getHeaders = generateGetHeaders(config); const getApiUrl = generateGetApiUrl(config, apiUrlFormatter); - const getGQLClientParams = generateGetGQLClientParams({ + const getGQLClientParams = generateGetGQLClientParams({ getHeaders, getApiUrl, }); - const fetch = (...props: ApiClientRequestParams) => { - return graphqlClient.fetch(...getGQLClientParams(...props)); - }; - - const request = (...props: ApiClientRequestParams) => { - return graphqlClient.request(...getGQLClientParams(...props)); - }; - const client: AdminApiClient = { config, getHeaders, getApiUrl, - fetch, - request, + fetch: (...props) => { + return graphqlClient.fetch(...getGQLClientParams(...props)); + }, + request: (...props) => { + return graphqlClient.request(...getGQLClientParams(...props)); + }, }; return Object.freeze(client); diff --git a/packages/admin-api-client/src/index.ts b/packages/admin-api-client/src/index.ts index 5994aed01..c76110d3a 100644 --- a/packages/admin-api-client/src/index.ts +++ b/packages/admin-api-client/src/index.ts @@ -1 +1,2 @@ export { createAdminApiClient } from "./admin-api-client"; +export { AdminQueries, AdminMutations } from "./types"; diff --git a/packages/admin-api-client/src/tests/admin-api-client.test.ts b/packages/admin-api-client/src/tests/admin-api-client.test.ts index ec329cc88..696b4a93f 100644 --- a/packages/admin-api-client/src/tests/admin-api-client.test.ts +++ b/packages/admin-api-client/src/tests/admin-api-client.test.ts @@ -233,7 +233,7 @@ describe("Admin API Client", () => { ) ); - delete global.window; + delete (global as any).window; }); }); }); @@ -330,7 +330,7 @@ describe("Admin API Client", () => { it("returns a headers object that contains both the client default headers and the provided custom headers", () => { const headers = { - "X-GraphQL-Cost-Include-Fields": true, + "X-GraphQL-Cost-Include-Fields": "1", }; const updatedHeaders = client.getHeaders(headers); expect(updatedHeaders).toEqual({ @@ -361,7 +361,7 @@ describe("Admin API Client", () => { }); it("throws an error when the api version is not a string", () => { - const version = 123; + const version: any = 123; expect(() => client.getApiUrl(version)).toThrow( new Error( `Admin API Client: the provided apiVersion ("123") is invalid. Current supported API versions: ${mockApiVersions.join( diff --git a/packages/admin-api-client/src/types.ts b/packages/admin-api-client/src/types.ts index b4aab8d63..336bea7a0 100644 --- a/packages/admin-api-client/src/types.ts +++ b/packages/admin-api-client/src/types.ts @@ -22,4 +22,12 @@ export type AdminApiClientOptions = Omit< logger?: ApiClientLogger; }; -export type AdminApiClient = ApiClient; +export interface AdminQueries { + [key: string]: { variables: any; return: any }; +} +export interface AdminMutations { + [key: string]: { variables: any; return: any }; +} +export type AdminOperations = AdminQueries & AdminMutations; + +export type AdminApiClient = ApiClient; diff --git a/packages/graphql-client/src/api-client-utilities/operation-types.ts b/packages/graphql-client/src/api-client-utilities/operation-types.ts new file mode 100644 index 000000000..12c6d4bb1 --- /dev/null +++ b/packages/graphql-client/src/api-client-utilities/operation-types.ts @@ -0,0 +1,37 @@ +import { GraphQLClient } from "../graphql-client/types"; + +export type InputMaybe<_R = never> = never; + +export interface AllOperations { + [key: string]: { variables: any; return: any }; +} + +type UnpackedInput = "input" extends keyof InputType + ? InputType["input"] + : InputType; + +type UnpackedInputMaybe = InputType extends InputMaybe + ? InputMaybe> + : UnpackedInput; + +export type OperationVariables< + Operation extends keyof Operations, + Operations extends AllOperations +> = Operations[Operation]["variables"] extends { [key: string]: never } + ? { [key: string]: never } + : { + variables?: { + [k in keyof Operations[Operation]["variables"]]: UnpackedInputMaybe< + Operations[Operation]["variables"][k] + >; + }; + }; + +export type ResponseWithType = Omit & { + json: () => Promise; +}; + +export type ReturnData< + Operation extends keyof Operations, + Operations extends AllOperations +> = Operation extends keyof Operations ? Operations[Operation]["return"] : any; diff --git a/packages/graphql-client/src/api-client-utilities/types.ts b/packages/graphql-client/src/api-client-utilities/types.ts index 01ca098c2..0c5eabc01 100644 --- a/packages/graphql-client/src/api-client-utilities/types.ts +++ b/packages/graphql-client/src/api-client-utilities/types.ts @@ -3,11 +3,22 @@ import { LogContent, Logger as BaseLogger, Headers, - OperationVariables, ClientResponse, - GraphQLClient, } from "../graphql-client/types"; +import { + AllOperations, + OperationVariables, + ResponseWithType, + ReturnData, +} from "./operation-types"; + +export { + AllOperations, + InputMaybe, + OperationVariables, +} from "./operation-types"; + export interface UnsupportedApiVersionLog extends LogContent { type: "UNSUPPORTED_API_VERSION"; content: { @@ -31,28 +42,47 @@ export interface ApiClientConfig { retries?: number; } -export interface ApiClientRequestOptions { - variables?: OperationVariables; +export type ApiClientRequestOptions< + Operation extends keyof Operations = string, + Operations extends AllOperations = AllOperations +> = { apiVersion?: string; headers?: Headers; retries?: number; -} +} & (Operation extends keyof Operations + ? OperationVariables + : { variables?: { [key: string]: any } }); -export type ApiClientRequestParams = [ - operation: string, - options?: ApiClientRequestOptions +export type ApiClientRequestParams< + Operation extends keyof Operations, + Operations extends AllOperations +> = [ + operation: Operation, + options?: ApiClientRequestOptions ]; +export type ApiClientFetch = < + Operation extends keyof Operations = string +>( + ...params: ApiClientRequestParams +) => Promise }>>; + +export type ApiClientRequest = + ( + ...params: ApiClientRequestParams + ) => Promise< + ClientResponse< + TData extends undefined ? ReturnData : TData + > + >; + export interface ApiClient< - TClientConfig extends ApiClientConfig = ApiClientConfig + TClientConfig extends ApiClientConfig = ApiClientConfig, + Operations extends AllOperations = AllOperations > { readonly config: Readonly; getHeaders: (headers?: Headers) => Headers; getApiUrl: (apiVersion?: string) => string; - fetch: ( - ...props: ApiClientRequestParams - ) => ReturnType; - request: ( - ...props: ApiClientRequestParams - ) => Promise>; + fetch: ApiClientFetch; + request: ApiClientRequest; } diff --git a/packages/graphql-client/src/api-client-utilities/utilities.ts b/packages/graphql-client/src/api-client-utilities/utilities.ts index 6260cbc24..e2fa67737 100644 --- a/packages/graphql-client/src/api-client-utilities/utilities.ts +++ b/packages/graphql-client/src/api-client-utilities/utilities.ts @@ -1,6 +1,11 @@ import { RequestParams } from "../graphql-client/types"; -import { ApiClient, ApiClientConfig, ApiClientRequestOptions } from "./types"; +import { + AllOperations, + ApiClient, + ApiClientConfig, + ApiClientRequestOptions, +} from "./types"; export function generateGetHeaders( config: ApiClientConfig @@ -10,18 +15,20 @@ export function generateGetHeaders( }; } -export function generateGetGQLClientParams({ +export function generateGetGQLClientParams< + Operations extends AllOperations = AllOperations +>({ getHeaders, getApiUrl, }: { getHeaders: ApiClient["getHeaders"]; getApiUrl: ApiClient["getApiUrl"]; }) { - return ( - operation: string, - options?: ApiClientRequestOptions + return ( + operation: Operation, + options?: ApiClientRequestOptions ): RequestParams => { - const props: RequestParams = [operation]; + const props: RequestParams = [operation as string]; if (options && Object.keys(options).length > 0) { const { diff --git a/packages/graphql-client/src/graphql-client/types.ts b/packages/graphql-client/src/graphql-client/types.ts index d37401211..6ace6a51e 100644 --- a/packages/graphql-client/src/graphql-client/types.ts +++ b/packages/graphql-client/src/graphql-client/types.ts @@ -7,7 +7,7 @@ export type CustomFetchApi = ( } ) => Promise; -export interface OperationVariables { +interface OperationVariables { [key: string]: any; } diff --git a/packages/shopify-api/rest/load-rest-resources.ts b/packages/shopify-api/rest/load-rest-resources.ts index 05bbf4326..8afbc51aa 100644 --- a/packages/shopify-api/rest/load-rest-resources.ts +++ b/packages/shopify-api/rest/load-rest-resources.ts @@ -1,5 +1,4 @@ -import {ShopifyClients} from 'lib'; - +import type {ShopifyClients} from '../lib'; import {ConfigInterface} from '../lib/base-types'; import {logger} from '../lib/logger'; diff --git a/packages/storefront-api-client/src/storefront-api-client.ts b/packages/storefront-api-client/src/storefront-api-client.ts index 67ee8dc3d..19010a54b 100644 --- a/packages/storefront-api-client/src/storefront-api-client.ts +++ b/packages/storefront-api-client/src/storefront-api-client.ts @@ -5,7 +5,8 @@ import { validateApiVersion, generateGetGQLClientParams, generateGetHeaders, - ApiClientRequestParams, + ApiClientFetch, + ApiClientRequest, } from "@shopify/graphql-client"; import { @@ -100,12 +101,12 @@ export function createStorefrontApiClient({ getApiUrl, }); - const fetch = (...props: ApiClientRequestParams) => { + const fetch: ApiClientFetch = (...props) => { return graphqlClient.fetch(...getGQLClientParams(...props)); }; - const request = (...props: ApiClientRequestParams) => { - return graphqlClient.request(...getGQLClientParams(...props)); + const request: ApiClientRequest = (...props) => { + return graphqlClient.request(...getGQLClientParams(...props)); }; const client: StorefrontApiClient = {