diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 9dfa85c342..64e21185f9 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -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} diff --git a/packages/app/src/cli/flags.ts b/packages/app/src/cli/flags.ts index e9561dada8..048744046a 100644 --- a/packages/app/src/cli/flags.ts +++ b/packages/app/src/cli/flags.ts @@ -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, + }), } diff --git a/packages/app/src/cli/services/execute-operation.test.ts b/packages/app/src/cli/services/execute-operation.test.ts index 8f420b7331..73f5c40e8a 100644 --- a/packages/app/src/cli/services/execute-operation.test.ts +++ b/packages/app/src/cli/services/execute-operation.test.ts @@ -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, }) }) @@ -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) + }) + }) }) diff --git a/packages/app/src/cli/services/execute-operation.ts b/packages/app/src/cli/services/execute-operation.ts index aa3d46149b..69fa8b5ba2 100644 --- a/packages/app/src/cli/services/execute-operation.ts +++ b/packages/app/src/cli/services/execute-operation.ts @@ -21,6 +21,7 @@ interface ExecuteOperationInput { variables?: string outputFile?: string version?: string + headers?: string[] } async function parseVariables(variables?: string): Promise<{[key: string]: unknown} | undefined> { @@ -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 { - 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) @@ -56,10 +96,13 @@ export async function executeOperation(input: ExecuteOperationInput): Promise { @@ -68,13 +111,20 @@ export async function executeOperation(input: ExecuteOperationInput): Promise { + 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) diff --git a/packages/cli-kit/src/public/node/api/admin.ts b/packages/cli-kit/src/public/node/api/admin.ts index 107d91686f..6cf8d8a175 100644 --- a/packages/cli-kit/src/public/node/api/admin.ts +++ b/packages/cli-kit/src/public/node/api/admin.ts @@ -62,6 +62,8 @@ export interface AdminRequestOptions { responseOptions?: GraphQLResponseOptions /** Custom request behaviour for retries and timeouts. */ preferredBehaviour?: RequestModeInput + /** Custom HTTP headers to include with the request. */ + addedHeaders?: {[header: string]: string} } /** @@ -73,14 +75,14 @@ export interface AdminRequestOptions { export async function adminRequestDoc( options: AdminRequestOptions, ): Promise { - 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