Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/src/cli/commands/app/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default class Execute extends AppLinkedCommand {
variables: flags.variables,
outputFile: flags['output-file'],
...(flags.version && {version: flags.version}),
...(flags.header && {headers: flags.header}),
})

return {app: appContextResult.app}
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/cli/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,9 @@ export const operationFlags = {
description: 'The file name where results should be written, instead of STDOUT.',
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
}),
header: Flags.string({
description: 'Custom HTTP header to include with the request, in "Key: Value" format. Can be specified multiple times.',
env: 'SHOPIFY_FLAG_HEADER',
multiple: true,
}),
}
207 changes: 206 additions & 1 deletion packages/app/src/cli/services/execute-operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ describe('executeOperation', () => {
session: mockAdminSession,
variables: undefined,
version: '2024-07',
responseOptions: {handleErrors: false},
responseOptions: {
handleErrors: false,
onResponse: expect.any(Function),
},
addedHeaders: undefined,
})
})

Expand Down Expand Up @@ -251,4 +255,205 @@ describe('executeOperation', () => {
}),
)
})

test('passes custom headers correctly when provided', async () => {
const query = 'query { shop { name } }'
const headers = ['X-Custom-Header: custom-value', 'Authorization: Bearer token123']
const mockResult = {data: {shop: {name: 'Test Shop'}}}
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)

await executeOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
headers,
})

expect(adminRequestDoc).toHaveBeenCalledWith(
expect.objectContaining({
addedHeaders: {
'X-Custom-Header': 'custom-value',
Authorization: 'Bearer token123',
},
}),
)
})

test('throws AbortError when header format is invalid (missing colon)', async () => {
const query = 'query { shop { name } }'
const headers = ['InvalidHeader']

await expect(
executeOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
headers,
}),
).rejects.toThrow(/Invalid header format/)
})

test('throws AbortError when header key is empty', async () => {
const query = 'query { shop { name } }'
const headers = [': value-only']

await expect(
executeOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
headers,
}),
).rejects.toThrow(/Invalid header format/)
})

test('handles headers with whitespace correctly', async () => {
const query = 'query { shop { name } }'
const headers = [' X-Header : value with spaces ']
const mockResult = {data: {shop: {name: 'Test Shop'}}}
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)

await executeOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
headers,
})

expect(adminRequestDoc).toHaveBeenCalledWith(
expect.objectContaining({
addedHeaders: {
'X-Header': 'value with spaces',
},
}),
)
})

test('allows empty header value', async () => {
const query = 'query { shop { name } }'
const headers = ['X-Empty-Header:']
const mockResult = {data: {shop: {name: 'Test Shop'}}}
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)

await executeOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
headers,
})

expect(adminRequestDoc).toHaveBeenCalledWith(
expect.objectContaining({
addedHeaders: {
'X-Empty-Header': '',
},
}),
)
})

test('includes response extensions in output when present', async () => {
const query = 'query { shop { name } }'
const mockResult = {shop: {name: 'Test Shop'}}
const mockExtensions = {cost: {requestedQueryCost: 1, actualQueryCost: 1}}

vi.mocked(adminRequestDoc).mockImplementation(async (options) => {
// Simulate the onResponse callback being called with extensions
if (options.responseOptions?.onResponse) {
options.responseOptions.onResponse({
data: mockResult,
extensions: mockExtensions,
headers: new Headers(),
status: 200,
} as any)
}
return mockResult
})

const mockOutput = mockAndCaptureOutput()

await executeOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
})

const output = mockOutput.info()
const parsedOutput = JSON.parse(output)

expect(parsedOutput).toEqual({
data: mockResult,
extensions: mockExtensions,
})
})

test('outputs only data when no extensions are present', async () => {
const query = 'query { shop { name } }'
const mockResult = {shop: {name: 'Test Shop'}}

vi.mocked(adminRequestDoc).mockImplementation(async (options) => {
// Simulate the onResponse callback being called without extensions
if (options.responseOptions?.onResponse) {
options.responseOptions.onResponse({
data: mockResult,
extensions: undefined,
headers: new Headers(),
status: 200,
} as any)
}
return mockResult
})

const mockOutput = mockAndCaptureOutput()

await executeOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
})

const output = mockOutput.info()
const parsedOutput = JSON.parse(output)

// Should output just the result, not wrapped in {data: ...}
expect(parsedOutput).toEqual(mockResult)
})

test('includes extensions in file output when present', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const outputFile = joinPath(tmpDir, 'results.json')
const query = 'query { shop { name } }'
const mockResult = {shop: {name: 'Test Shop'}}
const mockExtensions = {cost: {requestedQueryCost: 1, actualQueryCost: 1}}

vi.mocked(adminRequestDoc).mockImplementation(async (options) => {
if (options.responseOptions?.onResponse) {
options.responseOptions.onResponse({
data: mockResult,
extensions: mockExtensions,
headers: new Headers(),
status: 200,
} as any)
}
return mockResult
})

await executeOperation({
organization: mockOrganization,
remoteApp: mockRemoteApp,
storeFqdn,
query,
outputFile,
})

const expectedContent = JSON.stringify({data: mockResult, extensions: mockExtensions}, null, 2)
expect(writeFile).toHaveBeenCalledWith(outputFile, expectedContent)
})
})
})
56 changes: 53 additions & 3 deletions packages/app/src/cli/services/execute-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface ExecuteOperationInput {
variables?: string
outputFile?: string
version?: string
headers?: string[]
}

async function parseVariables(variables?: string): Promise<{[key: string]: unknown} | undefined> {
Expand All @@ -37,8 +38,47 @@ async function parseVariables(variables?: string): Promise<{[key: string]: unkno
}
}

function parseHeaders(headers?: string[]): {[header: string]: string} | undefined {
if (!headers || headers.length === 0) return undefined

const parsedHeaders: {[header: string]: string} = {}

for (const header of headers) {
const separatorIndex = header.indexOf(':')
if (separatorIndex === -1) {
throw new AbortError(
outputContent`Invalid header format: ${outputToken.yellow(header)}`,
'Headers must be in "Key: Value" format.',
)
}

const key = header.slice(0, separatorIndex).trim()
const value = header.slice(separatorIndex + 1).trim()

if (!key) {
throw new AbortError(
outputContent`Invalid header format: ${outputToken.yellow(header)}`,
"Header key can't be empty.",
)
}

parsedHeaders[key] = value
}

return parsedHeaders
}

export async function executeOperation(input: ExecuteOperationInput): Promise<void> {
const {organization, remoteApp, storeFqdn, query, variables, version: userSpecifiedVersion, outputFile} = input
const {
organization,
remoteApp,
storeFqdn,
query,
variables,
version: userSpecifiedVersion,
outputFile,
headers,
} = input

const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn)

Expand All @@ -56,10 +96,13 @@ export async function executeOperation(input: ExecuteOperationInput): Promise<vo
})

const parsedVariables = await parseVariables(variables)
const parsedHeaders = parseHeaders(headers)

validateSingleOperation(query)

try {
let extensions: unknown

const result = await renderSingleTask({
title: outputContent`Executing GraphQL operation`,
task: async () => {
Expand All @@ -68,13 +111,20 @@ export async function executeOperation(input: ExecuteOperationInput): Promise<vo
session: adminSession,
variables: parsedVariables,
version,
responseOptions: {handleErrors: false},
responseOptions: {
handleErrors: false,
onResponse: (response) => {
extensions = response.extensions
},
},
addedHeaders: parsedHeaders,
})
},
renderOptions: {stdout: process.stderr},
})

const resultString = JSON.stringify(result, null, 2)
const output = extensions ? {data: result, extensions} : result
const resultString = JSON.stringify(output, null, 2)

if (outputFile) {
await writeFile(outputFile, resultString)
Expand Down
6 changes: 4 additions & 2 deletions packages/cli-kit/src/public/node/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface AdminRequestOptions<TResult, TVariables extends Variables> {
responseOptions?: GraphQLResponseOptions<TResult>
/** Custom request behaviour for retries and timeouts. */
preferredBehaviour?: RequestModeInput
/** Custom HTTP headers to include with the request. */
addedHeaders?: {[header: string]: string}
}

/**
Expand All @@ -73,14 +75,14 @@ export interface AdminRequestOptions<TResult, TVariables extends Variables> {
export async function adminRequestDoc<TResult, TVariables extends Variables>(
options: AdminRequestOptions<TResult, TVariables>,
): Promise<TResult> {
const {query, session, variables, version, responseOptions, preferredBehaviour} = options
const {query, session, variables, version, responseOptions, preferredBehaviour, addedHeaders: customHeaders} = options

let apiVersion = version ?? LatestApiVersionByFQDN.get(session.storeFqdn)
if (!apiVersion) {
apiVersion = await fetchLatestSupportedApiVersion(session, preferredBehaviour)
}
let storeDomain = session.storeFqdn
const addedHeaders = themeAccessHeaders(session)
const addedHeaders = {...themeAccessHeaders(session), ...customHeaders}

if (serviceEnvironment() === 'local') {
addedHeaders['x-forwarded-host'] = storeDomain
Expand Down
Loading