Skip to content

Commit

Permalink
Add GQL support for theme access tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
lucyxiang committed Oct 23, 2024
1 parent 5b3cd4d commit d39cf9f
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 31 deletions.
9 changes: 8 additions & 1 deletion packages/cli-kit/src/private/node/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function makeVerboseRequest<T extends {headers: Headers; status: number}>(
}
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 {
Expand Down Expand Up @@ -120,6 +120,13 @@ async function makeVerboseRequest<T extends {headers: Headers; status: number}>(
}
}

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<T extends {headers: Headers; status: number}>(
{request, url}: RequestOptions<T>,
errorHandler?: (error: unknown, requestId: string | undefined) => unknown,
Expand Down
8 changes: 4 additions & 4 deletions packages/cli-kit/src/private/node/api/graphql.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
/* 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'
import {ClientError, RequestDocument, Variables} from 'graphql-request'

export function debugLogRequestInfo(
api: string,
query: RequestDocument,
query: string,
url: string,
variables?: Variables,
headers: {[key: string]: string} = {},
) {
outputDebug(outputContent`Sending ${outputToken.json(api)} GraphQL request:
${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 {
Expand Down
39 changes: 21 additions & 18 deletions packages/cli-kit/src/private/node/api/headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-kit/src/private/node/api/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/cli-kit/src/public/node/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
34 changes: 28 additions & 6 deletions packages/cli-kit/src/public/node/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -21,8 +27,9 @@ export async function adminRequest<T>(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})
}

/**
Expand All @@ -47,15 +54,23 @@ export async function adminRequestDoc<TResult, TVariables extends Variables>(
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<TResult, TVariables>({...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.
*
Expand Down Expand Up @@ -121,11 +136,18 @@ async function fetchApiVersions(session: AdminSession): Promise<ApiVersion[]> {
*
* @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 {
Expand Down
1 change: 1 addition & 0 deletions packages/cli-kit/src/public/node/api/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ describe('graphqlRequestDoc', () => {
`query QueryName {
example
}`,
'mockedAddress',
mockVariables,
expect.anything(),
)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-kit/src/public/node/api/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async function performGraphQLRequest<TResult>(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)

Expand Down

0 comments on commit d39cf9f

Please sign in to comment.