From d39cf9f41f26f58301d62e33a2a3f6b8eb1abaae Mon Sep 17 00:00:00 2001 From: Lucy Xiang Date: Tue, 22 Oct 2024 15:21:59 -0400 Subject: [PATCH] Add GQL support for theme access tokens --- packages/cli-kit/src/private/node/api.ts | 9 ++++- .../cli-kit/src/private/node/api/graphql.ts | 8 ++-- .../src/private/node/api/headers.test.ts | 39 ++++++++++--------- .../cli-kit/src/private/node/api/headers.ts | 2 +- .../cli-kit/src/public/node/api/admin.test.ts | 29 ++++++++++++++ packages/cli-kit/src/public/node/api/admin.ts | 34 +++++++++++++--- .../src/public/node/api/graphql.test.ts | 1 + .../cli-kit/src/public/node/api/graphql.ts | 2 +- 8 files changed, 93 insertions(+), 31 deletions(-) diff --git a/packages/cli-kit/src/private/node/api.ts b/packages/cli-kit/src/private/node/api.ts index d4ab5d91cd0..d0f846170f3 100644 --- a/packages/cli-kit/src/private/node/api.ts +++ b/packages/cli-kit/src/private/node/api.ts @@ -70,7 +70,7 @@ async function makeVerboseRequest( } const sanitizedHeaders = sanitizedHeadersOutput(responseHeaders) - if (err.response.errors?.some((error) => error.extensions.code === '429') || err.response.status === 429) { + if (err.response.status === 429 || errorsInclude429(err)) { let delayMs: number | undefined try { @@ -120,6 +120,13 @@ async function makeVerboseRequest( } } +function errorsInclude429(errors: ClientError): boolean { + if (typeof errors.response.errors === 'string') { + return false + } + return errors.response.errors?.some((error) => error.extensions?.code === '429') ?? false +} + export async function simpleRequestWithDebugLog( {request, url}: RequestOptions, errorHandler?: (error: unknown, requestId: string | undefined) => unknown, diff --git a/packages/cli-kit/src/private/node/api/graphql.ts b/packages/cli-kit/src/private/node/api/graphql.ts index c9f77fddbac..7264aec3051 100644 --- a/packages/cli-kit/src/private/node/api/graphql.ts +++ b/packages/cli-kit/src/private/node/api/graphql.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-base-to-string */ import {GraphQLClientError, sanitizedHeadersOutput} from './headers.js' import {stringifyMessage, outputContent, outputToken, outputDebug} from '../../../public/node/output.js' import {AbortError} from '../../../public/node/error.js' @@ -6,7 +5,8 @@ import {ClientError, RequestDocument, Variables} from 'graphql-request' export function debugLogRequestInfo( api: string, - query: RequestDocument, + query: string, + url: string, variables?: Variables, headers: {[key: string]: string} = {}, ) { @@ -14,8 +14,8 @@ export function debugLogRequestInfo( ${outputToken.raw(query.toString().trim())} ${variables ? `\nWith variables:\n${sanitizeVariables(variables)}\n` : ''} With request headers: -${sanitizedHeadersOutput(headers)} -`) +${sanitizedHeadersOutput(headers)}\n +to ${url}`) } function sanitizeVariables(variables: Variables): string { diff --git a/packages/cli-kit/src/private/node/api/headers.test.ts b/packages/cli-kit/src/private/node/api/headers.test.ts index 8bf60ab3c58..b67b5e10ed9 100644 --- a/packages/cli-kit/src/private/node/api/headers.test.ts +++ b/packages/cli-kit/src/private/node/api/headers.test.ts @@ -52,25 +52,28 @@ describe('common API methods', () => { }) }) - test.each(['shpat', 'shpua', 'shpca'])(`when custom app token starts with %s, do not prepend 'Bearer'`, (prefix) => { - // Given - vi.mocked(randomUUID).mockReturnValue('random-uuid') - vi.mocked(firstPartyDev).mockReturnValue(false) - const token = `${prefix}_my_token` - // When - const headers = buildHeaders(token) + test.each(['shpat', 'shpua', 'shpca', 'shptka'])( + `when custom app token starts with %s, do not prepend 'Bearer'`, + (prefix) => { + // Given + vi.mocked(randomUUID).mockReturnValue('random-uuid') + vi.mocked(firstPartyDev).mockReturnValue(false) + const token = `${prefix}_my_token` + // When + const headers = buildHeaders(token) - // Then - const version = CLI_KIT_VERSION - expect(headers).toEqual({ - 'Content-Type': 'application/json', - 'Keep-Alive': 'timeout=30', - 'X-Shopify-Access-Token': token, - 'User-Agent': `Shopify CLI; v=${version}`, - authorization: token, - 'Sec-CH-UA-PLATFORM': process.platform, - }) - }) + // Then + const version = CLI_KIT_VERSION + expect(headers).toEqual({ + 'Content-Type': 'application/json', + 'Keep-Alive': 'timeout=30', + 'X-Shopify-Access-Token': token, + 'User-Agent': `Shopify CLI; v=${version}`, + authorization: token, + 'Sec-CH-UA-PLATFORM': process.platform, + }) + }, + ) test('sanitizedHeadersOutput removes the headers that include the token', () => { // Given diff --git a/packages/cli-kit/src/private/node/api/headers.ts b/packages/cli-kit/src/private/node/api/headers.ts index 17781fdefb9..7d5ac55ee9a 100644 --- a/packages/cli-kit/src/private/node/api/headers.ts +++ b/packages/cli-kit/src/private/node/api/headers.ts @@ -56,7 +56,7 @@ export function buildHeaders(token?: string): {[key: string]: string} { ...(firstPartyDev() && {'X-Shopify-Cli-Employee': '1'}), } if (token) { - const authString = token.match(/^shp(at|ua|ca)/) ? token : `Bearer ${token}` + const authString = token.match(/^shp(at|ua|ca|tka)/) ? token : `Bearer ${token}` headers.authorization = authString headers['X-Shopify-Access-Token'] = authString diff --git a/packages/cli-kit/src/public/node/api/admin.test.ts b/packages/cli-kit/src/public/node/api/admin.test.ts index f3e6ab26189..f1d72e44ff6 100644 --- a/packages/cli-kit/src/public/node/api/admin.test.ts +++ b/packages/cli-kit/src/public/node/api/admin.test.ts @@ -57,10 +57,39 @@ describe('admin-graphql-api', () => { query: 'query', api: 'Admin', url: 'https://store.myshopify.com/admin/api/2022-01/graphql.json', + addedHeaders: {}, token, variables: {variables: 'variables'}, }) }) + + test('request is called with correct parameters when it is a theme access session', async () => { + // Given + const themeAccessToken = 'shptka_token' + const themeAccessSession = { + ...Session, + token: themeAccessToken, + } + + vi.mocked(graphqlRequest).mockResolvedValue(mockedResult) + vi.mocked(graphqlRequestDoc).mockResolvedValue(mockedResult) + + // When + await admin.adminRequest('query', themeAccessSession, {variables: 'variables'}) + + // Then + expect(graphqlRequest).toHaveBeenLastCalledWith({ + query: 'query', + api: 'Admin', + addedHeaders: { + 'X-Shopify-Access-Token': 'shptka_token', + 'X-Shopify-Shop': 'store', + }, + url: `https://${defaultThemeKitAccessDomain}/cli/admin/api/2022-01/graphql.json`, + token: themeAccessToken, + variables: {variables: 'variables'}, + }) + }) }) describe('admin-rest-api', () => { diff --git a/packages/cli-kit/src/public/node/api/admin.ts b/packages/cli-kit/src/public/node/api/admin.ts index 8244d4724c4..a4282a74552 100644 --- a/packages/cli-kit/src/public/node/api/admin.ts +++ b/packages/cli-kit/src/public/node/api/admin.ts @@ -2,10 +2,16 @@ import {graphqlRequest, graphqlRequestDoc, GraphQLResponseOptions, GraphQLVariab import {AdminSession} from '../session.js' import {outputContent, outputToken} from '../../../public/node/output.js' import {BugError, AbortError} from '../error.js' -import {restRequestBody, restRequestHeaders, restRequestUrl} from '../../../private/node/api/rest.js' +import { + restRequestBody, + restRequestHeaders, + restRequestUrl, + isThemeAccessSession, +} from '../../../private/node/api/rest.js' import {fetch} from '../http.js' import {PublicApiVersions} from '../../../cli/api/graphql/admin/generated/public_api_versions.js' import {normalizeStoreFqdn} from '../context/fqdn.js' +import {defaultThemeKitAccessDomain, environmentVariables} from '../../../private/node/constants.js' import {ClientError, Variables} from 'graphql-request' import {TypedDocumentNode} from '@graphql-typed-document-node/core' @@ -21,8 +27,9 @@ export async function adminRequest(query: string, session: AdminSession, vari const api = 'Admin' const version = await fetchLatestSupportedApiVersion(session) const store = await normalizeStoreFqdn(session.storeFqdn) - const url = adminUrl(store, version) - return graphqlRequest({query, api, url, token: session.token, variables}) + const url = adminUrl(store, version, session) + const addedHeaders: {[header: string]: string} = headers(session) + return graphqlRequest({query, api, addedHeaders, url, token: session.token, variables}) } /** @@ -47,15 +54,23 @@ export async function adminRequestDoc( apiVersion = await fetchLatestSupportedApiVersion(session) } const store = await normalizeStoreFqdn(session.storeFqdn) + const addedHeaders: {[header: string]: string} = headers(session) const opts = { - url: adminUrl(store, apiVersion), + url: adminUrl(store, apiVersion, session), api: 'Admin', token: session.token, + addedHeaders, } const result = graphqlRequestDoc({...opts, query, variables, responseOptions}) return result } +function headers(session: AdminSession): {[header: string]: string} { + return isThemeAccessSession(session) + ? {'X-Shopify-Shop': session.storeFqdn, 'X-Shopify-Access-Token': session.token} + : {} +} + /** * GraphQL query to retrieve the latest supported API version. * @@ -121,11 +136,18 @@ async function fetchApiVersions(session: AdminSession): Promise { * * @param store - Store FQDN. * @param version - API version. + * @param session - User session. * @returns - Admin API URL. */ -export function adminUrl(store: string, version: string | undefined): string { +export function adminUrl(store: string, version: string | undefined, session?: AdminSession): string { const realVersion = version ?? 'unstable' - return `https://${store}/admin/api/${realVersion}/graphql.json` + const themeKitAccessDomain = process.env[environmentVariables.themeKitAccessDomain] ?? defaultThemeKitAccessDomain + + const url = + session && isThemeAccessSession(session) + ? `https://${themeKitAccessDomain}/cli/admin/api/${realVersion}/graphql.json` + : `https://${store}/admin/api/${realVersion}/graphql.json` + return url } interface ApiVersion { diff --git a/packages/cli-kit/src/public/node/api/graphql.test.ts b/packages/cli-kit/src/public/node/api/graphql.test.ts index 4ef7b35671a..4e5e4d2f178 100644 --- a/packages/cli-kit/src/public/node/api/graphql.test.ts +++ b/packages/cli-kit/src/public/node/api/graphql.test.ts @@ -101,6 +101,7 @@ describe('graphqlRequestDoc', () => { `query QueryName { example }`, + 'mockedAddress', mockVariables, expect.anything(), ) diff --git a/packages/cli-kit/src/public/node/api/graphql.ts b/packages/cli-kit/src/public/node/api/graphql.ts index 6b7998e69d6..bb6f788d8d4 100644 --- a/packages/cli-kit/src/public/node/api/graphql.ts +++ b/packages/cli-kit/src/public/node/api/graphql.ts @@ -55,7 +55,7 @@ async function performGraphQLRequest(options: PerformGraphQLRequestOpti ...buildHeaders(token), } - debugLogRequestInfo(api, queryAsString, variables, headers) + debugLogRequestInfo(api, queryAsString, url, variables, headers) const clientOptions = {agent: await httpsAgent(), headers} const client = new GraphQLClient(url, clientOptions)