diff --git a/packages/admin-api-client/.eslintrc.cjs b/packages/admin-api-client/.eslintrc.cjs new file mode 100644 index 000000000..e290079e1 --- /dev/null +++ b/packages/admin-api-client/.eslintrc.cjs @@ -0,0 +1,17 @@ +module.exports = { + extends: "../../.eslintrc.cjs", + overrides: [ + { + files: ["**/*.cjs"], + env: { + node: true, + }, + }, + { + files: ["**/*.test.ts"], + rules: { + "@typescript-eslint/ban-ts-comment": 0, + }, + }, + ], +}; diff --git a/packages/admin-api-client/.gitignore b/packages/admin-api-client/.gitignore new file mode 100644 index 000000000..d5450fae9 --- /dev/null +++ b/packages/admin-api-client/.gitignore @@ -0,0 +1,18 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +node_modules/ +dist/ + +package-lock.json +.vscode/ +.DS_Store +.rollup.cache/ + +# ignore any locally packed packages +*.tgz +!*.d.ts diff --git a/packages/admin-api-client/README.md b/packages/admin-api-client/README.md new file mode 100644 index 000000000..7c9170c4b --- /dev/null +++ b/packages/admin-api-client/README.md @@ -0,0 +1,243 @@ +# @shopify/admin-api-client + + + +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](../../LICENSE.md) + + + +The Admin API Client library is for developers who want to interact with Shopify's GraphQL `Admin API`. The features of this library are designed to be lightweight and minimally opinionated. + +## Getting Started + +To install this package, you can run this in your terminal: + +```typescript +npm install @shopify/admin-api-client -s +``` + +## Admin API Client Examples + +### Initialize the Admin API Client + +```typescript +import {createAdminAPIClient} from '@shopify/admin-api-client'; + +const client = createAdminAPIClient({ + storeDomain: 'your-shop-name.myshopify.com', + apiVersion: '2023-04', + accessToken: 'your-admin-api-access-token', +}); +``` + +### Query for a product using the client + +```typescript +const operation = ` + query ProductQuery($id: ID!) { + product(id: $id) { + id + title + handle + } + } +`; + +const {data, error, extensions} = await client.request(operation, { + variables: { + id: 'gid://shopify/Product/7608002183224', + }, +}); +``` + +### `createAdminAPIClient()` parameters + +| Property | Type | Description | +| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| storeDomain | `string` | The domain of the store. It can be the Shopify `myshopify.com` domain or a custom store domain. | +| apiVersion | `string` | The requested Admin API version. | +| accessToken? | `string` | Admin API access token. | +| clientName? | `string` | Name of the client. | +| customFetchAPI? | `CustomFetchAPI` | A custom fetch function that will be used by the client when it interacts with the API. Defaults to the [browser's Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). | + +## Client properties + +| Property | Type | Description | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| config | `ClientConfig` | Configuration for the client | +| getHeaders | `(customHeaders?: Record) => Record` | Returns Admin API specific headers needed to interact with the API. If `customHeaders` is 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?:`[SFAPIClientRequestOptions](#sfapiclientrequestoptions-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?:`[SFAPIClientRequestOptions](#sfapiclientrequestoptions-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. | + +## `AdminAPIClientRequestOptions` properties + +| Name | Type | Description | +| -------------- | ------------------------ | ---------------------------------------------------- | +| variables? | `Record` | Variable values needed in the graphQL operation | +| apiVersion? | `string` | The Admin API version to use in the API request | +| customHeaders? | `Record` | Customized headers to be included in the API request | + +## `ClientResponse` + +| Name | Type | Description | +| ----------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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`. | +| error? | `ClientResponse['error']` | 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? | `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. | + +### Client `request()` response examples + +
+ Successful response + +### API response + +```json +{ + "data": { + "product": { + "id": "gid://shopify/Product/7608002183224", + "title": "Aera", + "handle": "aera-helmet" + } + }, + "extensions": { + "cost": { + "requestedQueryCost": 1, + "actualQueryCost": 1, + "throttleStatus": { + "maximumAvailable": 1000.0, + "currentlyAvailable": 999, + "restoreRate": 50.0 + } + } + } +} +``` + +
+ +
+ Error responses + +### Network error + +```json +{ + "networkStatusCode": 401, + "message": "Unauthorized" +} +``` + +### Admin API graphQL error + +```json +{ + "networkStatusCode": 200, + "message": "An error occurred while fetching from the API. Review the `graphQLErrors` object for details.", + "graphQLErrors": [ + { + "message": "Field 'testField' doesn't exist on type 'Product'", + "locations": [ + { + "line": 17, + "column": 3 + } + ], + "path": ["fragment ProductFragment", "testField"], + "extensions": { + "code": "undefinedField", + "typeName": "Product", + "fieldName": "testField" + } + } + ] +} +``` + +
+ +## Usage examples + +### Query for a product + +```typescript +const productQuery = ` + query ProductQuery($id: ID!) { + product(id: $id) { + id + title + handle + } + } +`; + +const {data, error, extensions} = await client.request(productQuery, { + variables: { + id: 'gid://shopify/Product/7608002183224', + }, +}); +``` + +### Dynamically set the Admin API version per data fetch + +```typescript +const productQuery = ` + query ProductQuery($id: ID!) { + product(id: $id) { + id + title + handle + } + } +`; + +const {data, error, extensions} = await client.request(productQuery, { + variables: { + id: 'gid://shopify/Product/7608002183224', + }, + apiVersion: '2023-01', +}); +``` + +### Add custom headers to API request + +```typescript +const productQuery = ` + query ProductQuery($id: ID!) { + product(id: $id) { + id + title + handle + } + } +`; + +const {data, error, extensions} = await client.request(productQuery, { + variables: { + id: 'gid://shopify/Product/7608002183224', + }, + customHeaders: { + 'X-GraphQL-Cost-Include-Fields': true, + }, +}); +``` + +### Using `client.fetch()` to get API data + +```typescript +const shopQuery = ` + query shop { + shop { + name + } + } +`; + +const response = await client.fetch(shopQuery); + +if (response.ok) { + const {errors, data, extensions} = await response.json(); +} +``` diff --git a/packages/admin-api-client/babel.config.json b/packages/admin-api-client/babel.config.json new file mode 100644 index 000000000..5182c456c --- /dev/null +++ b/packages/admin-api-client/babel.config.json @@ -0,0 +1,10 @@ +{ + "presets": [ + ["@babel/preset-env"], + ["@shopify/babel-preset", {"typescript": true}] + ], + "plugins": [ + ["@babel/plugin-transform-runtime"], + ["@babel/plugin-transform-async-to-generator"] + ] +} diff --git a/packages/admin-api-client/package.json b/packages/admin-api-client/package.json new file mode 100644 index 000000000..157e8805e --- /dev/null +++ b/packages/admin-api-client/package.json @@ -0,0 +1,110 @@ +{ + "name": "@shopify/admin-api-client", + "version": "0.0.0", + "description": "Shopify Admin API Client - A lightweight JS client to interact with Shopify's Admin API", + "repository": { + "type": "git", + "url": "git+https://github.com/Shopify/shopify-api-js.git" + }, + "author": "Shopify", + "license": "MIT", + "main": "./dist/umd/admin-api-client.min.js", + "browser": "./dist/umd/admin-api-client.min.js", + "module": "./dist/index.mjs", + "types": "./dist/admin-api-client.d.ts", + "exports": { + ".": { + "module": { + "types": "./dist/ts/index.d.ts", + "default": "./dist/index.mjs" + }, + "import": { + "types": "./dist/ts/index.d.ts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/ts/index.d.ts", + "default": "./dist/index.js" + }, + "default": "./dist/index.mjs" + } + }, + "scripts": { + "lint": "eslint . --ext .js,.ts", + "build": "yarn run rollup", + "test": "jest", + "test:ci": "yarn test", + "rollup": "rollup -c --bundleConfigAsCjs", + "clean": "rimraf dist/*", + "changeset": "changeset", + "version": "changeset version", + "release": "yarn build && changeset publish" + }, + "jest": { + "setupFilesAfterEnv": [ + "./src/tests/setupTests.ts" + ], + "transform": { + ".*": "babel-jest" + } + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "shopify", + "node", + "graphql", + "admin API" + ], + "files": [ + "**/*.d.ts", + "**/*.d.ts.map", + "**/*.js", + "**/*.js.map", + "**/*.mjs", + "**/*.mjs.map", + "!node_modules" + ], + "dependencies": { + "@shopify/graphql-client": "^0.3.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" + }, + "bugs": { + "url": "https://github.com/Shopify/shopify-api-js/issues" + }, + "homepage": "https://github.com/Shopify/shopify-api-js/packages/admin-api-client#readme" +} diff --git a/packages/admin-api-client/rollup.config.cjs b/packages/admin-api-client/rollup.config.cjs new file mode 100644 index 000000000..c50eaafdd --- /dev/null +++ b/packages/admin-api-client/rollup.config.cjs @@ -0,0 +1,101 @@ +import dts from "rollup-plugin-dts"; +import typescript from "@rollup/plugin-typescript"; +import resolve from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import terser from "@rollup/plugin-terser"; +import replace from "@rollup/plugin-replace"; + +import * as pkg from "./package.json"; + +export const mainSrcInput = "src/index.ts"; + +export function getPlugins({ tsconfig, minify } = {}) { + return [ + replace({ + preventAssignment: true, + ROLLUP_REPLACE_CLIENT_VERSION: pkg.version, + }), + resolve(), + commonjs(), + typescript({ + tsconfig: tsconfig ? tsconfig : "./tsconfig.build.json", + outDir: "./dist/ts", + }), + ...(minify === true ? [terser({ keep_fnames: new RegExp("fetch") })] : []), + ]; +} + +const packageName = pkg.name.substring(1); +const repositoryName = pkg.repository.url.split(":")[1].split(".")[0]; +export const bannerConfig = { + banner: `/*! ${packageName} -- Copyright (c) 2023-present, Shopify Inc. -- license (MIT): https://github.com/${repositoryName}/blob/main/LICENSE */`, +}; + +const config = [ + { + input: mainSrcInput, + plugins: getPlugins({ + minify: true, + tsconfig: "./tsconfig.build.umd.json", + }), + output: [ + { + file: "./dist/umd/storefront-api-client.min.js", + format: "umd", + sourcemap: true, + name: "ShopifyStorefrontAPIClient", + ...bannerConfig, + }, + ], + }, + { + input: mainSrcInput, + plugins: getPlugins({ + tsconfig: "./tsconfig.build.umd.json", + }), + output: [ + { + file: "./dist/umd/storefront-api-client.js", + format: "umd", + sourcemap: true, + name: "ShopifyStorefrontAPIClient", + ...bannerConfig, + }, + ], + }, + { + input: mainSrcInput, + plugins: getPlugins(), + output: [ + { + dir: "./dist", + format: "es", + sourcemap: true, + preserveModules: true, + preserveModulesRoot: "src", + entryFileNames: "[name].mjs", + }, + ], + }, + { + input: mainSrcInput, + plugins: getPlugins(), + output: [ + { + dir: "./dist", + format: "cjs", + sourcemap: true, + exports: "named", + preserveModules: true, + preserveModulesRoot: "src", + }, + ], + }, + { + input: "./dist/ts/index.d.ts", + output: [{ file: "dist/storefront-api-client.d.ts", format: "es" }], + plugins: [dts.default()], + }, +]; + +export default config; diff --git a/packages/admin-api-client/src/admin-api-client.ts b/packages/admin-api-client/src/admin-api-client.ts new file mode 100644 index 000000000..997e334bb --- /dev/null +++ b/packages/admin-api-client/src/admin-api-client.ts @@ -0,0 +1,138 @@ +import { + createGraphQLClient, + CustomFetchAPI, + RequestParams as GQLClientRequestParams, +} from "@shopify/graphql-client"; + +import { + AdminAPIClient, + AdminAPIClientConfig, + AdminAPIClientRequestOptions, +} from "./types"; +import { + DEFAULT_SDK_VARIANT, + DEFAULT_CLIENT_VERSION, + SDK_VARIANT_HEADER, + SDK_VARIANT_SOURCE_HEADER, + SDK_VERSION_HEADER, + DEFAULT_CONTENT_TYPE, + ACCESS_TOKEN_HEADER, +} from "./constants"; +import { + getCurrentSupportedAPIVersions, + validateRequiredStoreDomain, + validateRequiredAccessToken, + validateApiVersion, + validateServerSideUsage, +} from "./utilities"; + +const httpRegEx = new RegExp("^(https?:)?//"); + +export function createAdminAPIClient({ + storeDomain, + apiVersion, + accessToken, + clientName, + customFetchAPI: clientFetchAPI, +}: { + storeDomain: string; + apiVersion: string; + accessToken: string; + clientName?: string; + customFetchAPI?: CustomFetchAPI; +}): AdminAPIClient { + const currentSupportedAPIVersions = getCurrentSupportedAPIVersions(); + + validateServerSideUsage(); + validateRequiredStoreDomain(storeDomain); + validateApiVersion(currentSupportedAPIVersions, apiVersion); + validateRequiredAccessToken(accessToken); + + const trimmedStoreDomain = storeDomain.trim(); + const cleanedStoreDomain = httpRegEx.test(trimmedStoreDomain) + ? trimmedStoreDomain.substring(trimmedStoreDomain.indexOf("//") + 2) + : trimmedStoreDomain; + + const generateApiUrl = (version?: string) => { + if (version) { + validateApiVersion(currentSupportedAPIVersions, version); + } + + const urlApiVersion = (version ?? apiVersion).trim(); + + return `https://${cleanedStoreDomain}${ + cleanedStoreDomain.endsWith("/") ? "" : "/" + }admin/api/${urlApiVersion}/graphql.json`; + }; + + const config: AdminAPIClientConfig = { + storeDomain: trimmedStoreDomain, + apiVersion, + accessToken, + headers: { + "Content-Type": DEFAULT_CONTENT_TYPE, + Accept: DEFAULT_CONTENT_TYPE, + [SDK_VARIANT_HEADER]: DEFAULT_SDK_VARIANT, + [SDK_VERSION_HEADER]: DEFAULT_CLIENT_VERSION, + ...(clientName ? { [SDK_VARIANT_SOURCE_HEADER]: clientName } : {}), + [ACCESS_TOKEN_HEADER]: accessToken, + }, + apiUrl: generateApiUrl(), + clientName, + }; + + const graphqlClient = createGraphQLClient({ + headers: config.headers, + url: config.apiUrl, + fetchAPI: clientFetchAPI, + }); + + const getHeaders: AdminAPIClient["getHeaders"] = (customHeaders) => { + return customHeaders + ? { ...customHeaders, ...config.headers } + : config.headers; + }; + + const getApiUrl: AdminAPIClient["getApiUrl"] = (propApiVersion?: string) => { + return propApiVersion ? generateApiUrl(propApiVersion) : config.apiUrl; + }; + + const getGQLClientRequestProps = ( + operation: string, + options?: AdminAPIClientRequestOptions + ): GQLClientRequestParams => { + const props: GQLClientRequestParams = [operation]; + + if (options) { + const { variables, apiVersion: propApiVersion, customHeaders } = options; + + props.push({ + variables, + headers: customHeaders ? getHeaders(customHeaders) : undefined, + url: propApiVersion ? getApiUrl(propApiVersion) : undefined, + }); + } + + return props; + }; + + const fetch: AdminAPIClient["fetch"] = (...props) => { + const requestProps = getGQLClientRequestProps(...props); + return graphqlClient.fetch(...requestProps); + }; + + const request: AdminAPIClient["request"] = (...props) => { + const requestProps = getGQLClientRequestProps(...props); + return graphqlClient.request(...requestProps); + }; + + const client: AdminAPIClient = { + config, + getHeaders, + getApiUrl, + fetch, + request, + }; + + return Object.freeze(client); +} diff --git a/packages/admin-api-client/src/constants.ts b/packages/admin-api-client/src/constants.ts new file mode 100644 index 000000000..e3f460673 --- /dev/null +++ b/packages/admin-api-client/src/constants.ts @@ -0,0 +1,11 @@ +export const DEFAULT_CONTENT_TYPE = "application/json"; +export const DEFAULT_SDK_VARIANT = "admin-api-client"; +// This is value is replaced with package.json version during rollup build process +export const DEFAULT_CLIENT_VERSION = "ROLLUP_REPLACE_CLIENT_VERSION"; + +export const ACCESS_TOKEN_HEADER = "X-Shopify-Access-Token"; +export const SDK_VARIANT_HEADER = "X-SDK-Variant"; +export const SDK_VERSION_HEADER = "X-SDK-Version"; +export const SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source"; + +export const ERROR_PREFIX = "Admin API Client:"; diff --git a/packages/admin-api-client/src/index.ts b/packages/admin-api-client/src/index.ts new file mode 100644 index 000000000..821a98ef5 --- /dev/null +++ b/packages/admin-api-client/src/index.ts @@ -0,0 +1 @@ +export { createAdminAPIClient } from "./admin-api-client"; 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 new file mode 100644 index 000000000..fb721ef9c --- /dev/null +++ b/packages/admin-api-client/src/tests/admin-api-client.test.ts @@ -0,0 +1,408 @@ +import { createGraphQLClient, GraphQLClient } from "@shopify/graphql-client"; +import { AdminAPIClient } from "types"; + +import { createAdminAPIClient } from "../admin-api-client"; +import { + SDK_VARIANT_HEADER, + DEFAULT_SDK_VARIANT, + SDK_VERSION_HEADER, + DEFAULT_CLIENT_VERSION, + SDK_VARIANT_SOURCE_HEADER, + ACCESS_TOKEN_HEADER, + DEFAULT_CONTENT_TYPE, +} from "../constants"; + +const mockApiVersions = [ + "2023-01", + "2023-04", + "2023-07", + "2023-10", + "2024-01", + "unstable", +]; + +jest.mock("../utilities/api-versions", () => { + return { + ...jest.requireActual("../utilities/api-versions"), + getCurrentSupportedAPIVersions: () => mockApiVersions, + }; +}); + +jest.mock("@shopify/graphql-client", () => { + return { + ...jest.requireActual("@shopify/graphql-client"), + createGraphQLClient: jest.fn(), + }; +}); + +describe("Admin API Client", () => { + describe("createAdminAPIClient()", () => { + const config = { + storeDomain: "https://test-store.myshopify.io", + apiVersion: "2023-10", + accessToken: "access-token", + }; + const mockApiUrl = `${config.storeDomain}/admin/api/2023-10/graphql.json`; + + const graphqlClientMock: GraphQLClient = { + config: { + url: mockApiUrl, + headers: {}, + retries: 0, + }, + fetch: jest.fn(), + request: jest.fn(), + }; + + beforeEach(() => { + (createGraphQLClient as jest.Mock).mockReturnValue(graphqlClientMock); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + describe("client initialization", () => { + it("calls the graphql client with headers and API URL", () => { + const clientName = "test-client"; + + createAdminAPIClient({ ...config, clientName }); + expect( + (createGraphQLClient as jest.Mock).mock.calls[0][0] + ).toHaveProperty("headers", { + "Content-Type": "application/json", + Accept: "application/json", + "X-Shopify-Access-Token": "access-token", + [SDK_VARIANT_HEADER]: DEFAULT_SDK_VARIANT, + [SDK_VERSION_HEADER]: DEFAULT_CLIENT_VERSION, + [SDK_VARIANT_SOURCE_HEADER]: clientName, + }); + expect( + (createGraphQLClient as jest.Mock).mock.calls[0][0] + ).toHaveProperty("url", mockApiUrl); + }); + + it("calls the graphql client with the provided customFetchAPI", () => { + const customFetchAPI = jest.fn(); + + createAdminAPIClient({ ...config, customFetchAPI }); + + expect(createGraphQLClient).toHaveBeenCalled(); + expect( + (createGraphQLClient as jest.Mock).mock.calls[0][0] + ).toHaveProperty("fetchAPI", customFetchAPI); + }); + + it("returns a client object that contains a config object, getters for header and API URL and request and fetch functions", () => { + const client = createAdminAPIClient(config); + + expect(client).toHaveProperty("config"); + expect(client).toMatchObject({ + getHeaders: expect.any(Function), + getApiUrl: expect.any(Function), + request: expect.any(Function), + fetch: expect.any(Function), + }); + }); + + describe("validations", () => { + it("throws an error when a store domain is not provided", () => { + expect(() => + createAdminAPIClient({ + ...config, + // @ts-ignore + storeDomain: undefined, + }) + ).toThrow( + new Error("Admin API Client: a valid store domain must be provided") + ); + }); + + it("throws an error when an empty string is provided as the store domain", () => { + expect(() => + createAdminAPIClient({ + ...config, + // @ts-ignore + storeDomain: " ", + }) + ).toThrow( + new Error("Admin API Client: a valid store domain must be provided") + ); + }); + + it("throws an error when the provided store domain is not a string", () => { + expect(() => + createAdminAPIClient({ + ...config, + // @ts-ignore + storeDomain: 123, + }) + ).toThrow( + new Error("Admin API Client: a valid store domain must be provided") + ); + }); + + it("throws an error when the api version is not provided", () => { + expect(() => + createAdminAPIClient({ + ...config, + // @ts-ignore + apiVersion: undefined, + }) + ).toThrow( + new Error( + `Admin API Client: the provided \`apiVersion\` is invalid. Current supported API versions: ${mockApiVersions.join( + ", " + )}` + ) + ); + }); + + it("throws an error when the api version is not a string", () => { + expect(() => + createAdminAPIClient({ + ...config, + // @ts-ignore + apiVersion: { year: 2022, month: 1 }, + }) + ).toThrow( + new Error( + `Admin API Client: the provided \`apiVersion\` is invalid. Current supported API versions: ${mockApiVersions.join( + ", " + )}` + ) + ); + }); + + it("console warns when a unsupported api version is provided", () => { + const consoleWarnSpy = jest + .spyOn(global.console, "warn") + .mockImplementation(jest.fn()); + + createAdminAPIClient({ + ...config, + apiVersion: "2022-07", + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Admin API Client: the provided \`apiVersion\` (\`2022-07\`) is deprecated or not supported. Current supported API versions: ${mockApiVersions.join( + ", " + )}` + ); + }); + + it("throws an error when an access token isn't provided", () => { + expect(() => + createAdminAPIClient({ + ...config, + // @ts-ignore + accessToken: undefined, + }) + ).toThrow( + new Error(`Admin API Client: an access token must be provided`) + ); + }); + + it("throws an error when run in a browser environment (window is defined)", () => { + // @ts-ignore + global.window = {}; + + expect(() => + // @ts-ignore + createAdminAPIClient({ + ...config, + accessToken: "access-token", + }) + ).toThrow( + new Error( + "Admin API Client: this client should not be used in the browser" + ) + ); + + // @ts-ignore + delete global.window; + }); + }); + }); + + describe("client config", () => { + it("returns a config object that includes the provided store domain", () => { + const client = createAdminAPIClient(config); + expect(client.config.storeDomain).toBe(config.storeDomain); + }); + + it("returns a config object that includes the provided access token", () => { + const client = createAdminAPIClient(config); + expect(client.config.accessToken).toBe(config.accessToken); + }); + + it("returns a config object that includes the provided client name", () => { + const clientName = "test-client"; + + const client = createAdminAPIClient({ ...config, clientName }); + expect(client.config.clientName).toBe(clientName); + }); + + describe("API url", () => { + const cleanedStoreDomain = "test-store.myshopify.io"; + const expectedAPIUrl = `https://${cleanedStoreDomain}/admin/api/${config.apiVersion}/graphql.json`; + + it("returns a config object that includes the secure API url constructed with the provided API version and a store domain that includes 'https'", () => { + const client = createAdminAPIClient({ + ...config, + storeDomain: `https://${cleanedStoreDomain}`, + }); + expect(client.config.apiUrl).toBe(expectedAPIUrl); + }); + + it("returns a config object that includes the secure API url constructed with the provided API version and a store domain that includes 'http'", () => { + const client = createAdminAPIClient({ + ...config, + storeDomain: `http://${cleanedStoreDomain}`, + }); + expect(client.config.apiUrl).toBe(expectedAPIUrl); + }); + + it("returns a config object that includes the secure API url constructed with the provided API version and a store domain that does not include a protocol", () => { + const client = createAdminAPIClient({ + ...config, + storeDomain: cleanedStoreDomain, + }); + expect(client.config.apiUrl).toBe(expectedAPIUrl); + }); + + it("returns a config object that includes a valid API url constructed with the provided spaced out API version and a store domain", () => { + const client = createAdminAPIClient({ + ...config, + storeDomain: ` ${cleanedStoreDomain} `, + apiVersion: ` ${config.apiVersion} `, + }); + expect(client.config.apiUrl).toBe(expectedAPIUrl); + }); + }); + + describe("config headers", () => { + it("returns a header object that includes the content-type header", () => { + const client = createAdminAPIClient(config); + expect(client.config.headers["Content-Type"]).toBe( + DEFAULT_CONTENT_TYPE + ); + }); + + it("returns a header object that includes the accept header", () => { + const client = createAdminAPIClient(config); + expect(client.config.headers.Accept).toBe(DEFAULT_CONTENT_TYPE); + }); + + it("returns a header object that includes the SDK variant header", () => { + const client = createAdminAPIClient(config); + expect(client.config.headers[SDK_VARIANT_HEADER]).toBe( + DEFAULT_SDK_VARIANT + ); + }); + + it("returns a header object that includes the SDK version header", () => { + const client = createAdminAPIClient(config); + expect(client.config.headers[SDK_VERSION_HEADER]).toBe( + DEFAULT_CLIENT_VERSION + ); + }); + + it("returns a header object that includes the access token headers when an access token is provided", () => { + const client = createAdminAPIClient(config); + expect(client.config.headers[ACCESS_TOKEN_HEADER]).toEqual( + config.accessToken + ); + }); + + it("returns a header object that includes the SDK variant source header when client name is provided", () => { + const clientName = "test-client"; + + const client = createAdminAPIClient({ ...config, clientName }); + expect(client.config.headers[SDK_VARIANT_SOURCE_HEADER]).toEqual( + clientName + ); + }); + + it("returns a header object that does not include the SDK variant source header when client name is not provided", () => { + const client = createAdminAPIClient(config); + expect( + client.config.headers[SDK_VARIANT_SOURCE_HEADER] + ).toBeUndefined(); + }); + }); + }); + + describe("getHeaders()", () => { + let client: AdminAPIClient; + + beforeEach(() => { + client = createAdminAPIClient(config); + }); + + it("returns the client's default headers if no custom headers are provided", () => { + const headers = client.getHeaders(); + expect(headers).toBe(client.config.headers); + }); + + it("returns a headers object that contains both the client default headers and the provided custom headers", () => { + const customHeaders = { + "X-GraphQL-Cost-Include-Fields": true, + }; + const headers = client.getHeaders(customHeaders); + expect(headers).toEqual({ ...customHeaders, ...client.config.headers }); + }); + }); + + describe("getApiUrl()", () => { + let client: AdminAPIClient; + + beforeEach(() => { + client = createAdminAPIClient(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}/admin/api/${version}/graphql.json` + ); + }); + + it("throws an error when the api version is not a string", () => { + const version = 123; + expect(() => + // @ts-ignore + client.getApiUrl(version) + ).toThrow( + new Error( + `Admin API Client: the provided \`apiVersion\` is invalid. Current supported API versions: ${mockApiVersions.join( + ", " + )}` + ) + ); + }); + + 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( + `Admin API Client: the provided \`apiVersion\` (\`2021-01\`) is deprecated or not supported. Current supported API versions: ${mockApiVersions.join( + ", " + )}` + ); + }); + }); + }); +}); diff --git a/packages/admin-api-client/src/tests/setupTests.ts b/packages/admin-api-client/src/tests/setupTests.ts new file mode 100644 index 000000000..50dec607e --- /dev/null +++ b/packages/admin-api-client/src/tests/setupTests.ts @@ -0,0 +1 @@ +import "regenerator-runtime/runtime"; diff --git a/packages/admin-api-client/src/types.ts b/packages/admin-api-client/src/types.ts new file mode 100644 index 000000000..26d180fbb --- /dev/null +++ b/packages/admin-api-client/src/types.ts @@ -0,0 +1,40 @@ +import { + OperationVariables, + Headers, + ClientResponse, + GraphQLClient, +} from "@shopify/graphql-client"; + +export interface AdminAPIClientConfig { + readonly storeDomain: string; + readonly apiVersion: string; + readonly accessToken: string; + readonly headers: Headers; + readonly apiUrl: string; + readonly clientName?: string; + readonly retries?: number; +} + +export interface AdminAPIClientRequestOptions { + variables?: OperationVariables; + apiVersion?: string; + customHeaders?: Headers; + retries?: number; +} + +export type AdminAPIClientRequestParams = [ + operation: string, + options?: AdminAPIClientRequestOptions +]; + +export interface AdminAPIClient { + readonly config: AdminAPIClientConfig; + getHeaders: (customHeaders?: Headers) => Headers; + getApiUrl: (apiVersion?: string) => string; + fetch: ( + ...props: AdminAPIClientRequestParams + ) => ReturnType; + request: ( + ...props: AdminAPIClientRequestParams + ) => Promise>; +} diff --git a/packages/admin-api-client/src/utilities/api-versions.ts b/packages/admin-api-client/src/utilities/api-versions.ts new file mode 100644 index 000000000..35823bab3 --- /dev/null +++ b/packages/admin-api-client/src/utilities/api-versions.ts @@ -0,0 +1,46 @@ +function getQuarterMonth(quarter: number) { + const month = quarter * 3 - 2; + return month === 10 ? month : `0${month}`; +} + +function getPrevousVersion(year: number, quarter: number, nQuarter: number) { + const versionQuarter = quarter - nQuarter; + + if (versionQuarter <= 0) { + return `${year - 1}-${getQuarterMonth(versionQuarter + 4)}`; + } + + return `${year}-${getQuarterMonth(versionQuarter)}`; +} + +export function getCurrentAPIVersion() { + const date = new Date(); + const month = date.getUTCMonth(); + const year = date.getUTCFullYear(); + + const quarter = Math.floor(month / 3 + 1); + + return { + year, + quarter, + version: `${year}-${getQuarterMonth(quarter)}`, + }; +} + +export function getCurrentSupportedAPIVersions() { + const { year, quarter, version: currentVersion } = getCurrentAPIVersion(); + + const nextVersion = + quarter === 4 + ? `${year + 1}-01` + : `${year}-${getQuarterMonth(quarter + 1)}`; + + return [ + getPrevousVersion(year, quarter, 3), + getPrevousVersion(year, quarter, 2), + getPrevousVersion(year, quarter, 1), + currentVersion, + nextVersion, + "unstable", + ]; +} diff --git a/packages/admin-api-client/src/utilities/index.ts b/packages/admin-api-client/src/utilities/index.ts new file mode 100644 index 000000000..c5d32706d --- /dev/null +++ b/packages/admin-api-client/src/utilities/index.ts @@ -0,0 +1,2 @@ +export * from "./api-versions"; +export * from "./validations"; diff --git a/packages/admin-api-client/src/utilities/validations.ts b/packages/admin-api-client/src/utilities/validations.ts new file mode 100644 index 000000000..fcd46afd5 --- /dev/null +++ b/packages/admin-api-client/src/utilities/validations.ts @@ -0,0 +1,48 @@ +import { ERROR_PREFIX } from "../constants"; + +export function validateRequiredStoreDomain(storeDomain: string | undefined) { + if ( + !storeDomain || + typeof storeDomain !== "string" || + storeDomain.trim().length < 1 + ) { + throw new Error(`${ERROR_PREFIX} a valid store domain must be provided`); + } +} + +export function validateApiVersion( + currentSupportedApiVersions: string[], + apiVersion: string +) { + const supportedVerbage = `Current supported API versions: ${currentSupportedApiVersions.join( + ", " + )}`; + + if (!apiVersion || typeof apiVersion !== "string") { + throw new Error( + `${ERROR_PREFIX} the provided \`apiVersion\` is invalid. ${supportedVerbage}` + ); + } + + const trimmedApiVersion = apiVersion.trim(); + + if (!currentSupportedApiVersions.includes(trimmedApiVersion)) { + console.warn( + `${ERROR_PREFIX} the provided \`apiVersion\` (\`${apiVersion}\`) is deprecated or not supported. ${supportedVerbage}` + ); + } +} + +export function validateRequiredAccessToken(accessToken: string) { + if (!accessToken) { + throw new Error(`${ERROR_PREFIX} an access token must be provided`); + } +} + +export function validateServerSideUsage() { + if (typeof window !== "undefined") { + throw new Error( + `${ERROR_PREFIX} this client should not be used in the browser` + ); + } +} diff --git a/packages/admin-api-client/tsconfig.build.json b/packages/admin-api-client/tsconfig.build.json new file mode 100644 index 000000000..763a46828 --- /dev/null +++ b/packages/admin-api-client/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["./node_modules", "./src/**/tests/*.ts"] +} diff --git a/packages/admin-api-client/tsconfig.build.umd.json b/packages/admin-api-client/tsconfig.build.umd.json new file mode 100644 index 000000000..4a8d809ac --- /dev/null +++ b/packages/admin-api-client/tsconfig.build.umd.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false + } +} diff --git a/packages/admin-api-client/tsconfig.json b/packages/admin-api-client/tsconfig.json new file mode 100644 index 000000000..5e4a73bed --- /dev/null +++ b/packages/admin-api-client/tsconfig.json @@ -0,0 +1,17 @@ +{ + "include": ["./src/**/*.ts"], + "exclude": ["./node_modules"], + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "target": "ES2022", + "lib": ["ESNext", "DOM"], + "rootDir": "src", + "baseUrl": "src", + "strict": true, + "pretty": true, + "allowSyntheticDefaultImports": true, + "strictPropertyInitialization": true + }, + "extends": "../../tsconfig.base.json", +} diff --git a/tsconfig.base.json b/tsconfig.base.json index b398e3ed3..14b268d74 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -23,6 +23,7 @@ }, "references": [ {"path": "./packages/shopify-api"}, - {"path": "./packages/graphql-client"} + {"path": "./packages/graphql-client"}, + {"path": "./packages/admin-api-client"} ] }