Skip to content

Commit a0e2060

Browse files
committed
POC of adding header support to app execute
1 parent efc420b commit a0e2060

File tree

5 files changed

+269
-6
lines changed

5 files changed

+269
-6
lines changed

packages/app/src/cli/commands/app/execute.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export default class Execute extends AppLinkedCommand {
2828
variables: flags.variables,
2929
outputFile: flags['output-file'],
3030
...(flags.version && {version: flags.version}),
31+
...(flags.header && {headers: flags.header}),
3132
})
3233

3334
return {app: appContextResult.app}

packages/app/src/cli/flags.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,9 @@ export const operationFlags = {
106106
description: 'The file name where results should be written, instead of STDOUT.',
107107
env: 'SHOPIFY_FLAG_OUTPUT_FILE',
108108
}),
109+
header: Flags.string({
110+
description: 'Custom HTTP header to include with the request, in "Key: Value" format. Can be specified multiple times.',
111+
env: 'SHOPIFY_FLAG_HEADER',
112+
multiple: true,
113+
}),
109114
}

packages/app/src/cli/services/execute-operation.test.ts

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ describe('executeOperation', () => {
6262
session: mockAdminSession,
6363
variables: undefined,
6464
version: '2024-07',
65-
responseOptions: {handleErrors: false},
65+
responseOptions: {
66+
handleErrors: false,
67+
onResponse: expect.any(Function),
68+
},
69+
addedHeaders: undefined,
6670
})
6771
})
6872

@@ -251,4 +255,205 @@ describe('executeOperation', () => {
251255
}),
252256
)
253257
})
258+
259+
test('passes custom headers correctly when provided', async () => {
260+
const query = 'query { shop { name } }'
261+
const headers = ['X-Custom-Header: custom-value', 'Authorization: Bearer token123']
262+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
263+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
264+
265+
await executeOperation({
266+
organization: mockOrganization,
267+
remoteApp: mockRemoteApp,
268+
storeFqdn,
269+
query,
270+
headers,
271+
})
272+
273+
expect(adminRequestDoc).toHaveBeenCalledWith(
274+
expect.objectContaining({
275+
addedHeaders: {
276+
'X-Custom-Header': 'custom-value',
277+
Authorization: 'Bearer token123',
278+
},
279+
}),
280+
)
281+
})
282+
283+
test('throws AbortError when header format is invalid (missing colon)', async () => {
284+
const query = 'query { shop { name } }'
285+
const headers = ['InvalidHeader']
286+
287+
await expect(
288+
executeOperation({
289+
organization: mockOrganization,
290+
remoteApp: mockRemoteApp,
291+
storeFqdn,
292+
query,
293+
headers,
294+
}),
295+
).rejects.toThrow(/Invalid header format/)
296+
})
297+
298+
test('throws AbortError when header key is empty', async () => {
299+
const query = 'query { shop { name } }'
300+
const headers = [': value-only']
301+
302+
await expect(
303+
executeOperation({
304+
organization: mockOrganization,
305+
remoteApp: mockRemoteApp,
306+
storeFqdn,
307+
query,
308+
headers,
309+
}),
310+
).rejects.toThrow(/Invalid header format/)
311+
})
312+
313+
test('handles headers with whitespace correctly', async () => {
314+
const query = 'query { shop { name } }'
315+
const headers = [' X-Header : value with spaces ']
316+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
317+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
318+
319+
await executeOperation({
320+
organization: mockOrganization,
321+
remoteApp: mockRemoteApp,
322+
storeFqdn,
323+
query,
324+
headers,
325+
})
326+
327+
expect(adminRequestDoc).toHaveBeenCalledWith(
328+
expect.objectContaining({
329+
addedHeaders: {
330+
'X-Header': 'value with spaces',
331+
},
332+
}),
333+
)
334+
})
335+
336+
test('allows empty header value', async () => {
337+
const query = 'query { shop { name } }'
338+
const headers = ['X-Empty-Header:']
339+
const mockResult = {data: {shop: {name: 'Test Shop'}}}
340+
vi.mocked(adminRequestDoc).mockResolvedValue(mockResult)
341+
342+
await executeOperation({
343+
organization: mockOrganization,
344+
remoteApp: mockRemoteApp,
345+
storeFqdn,
346+
query,
347+
headers,
348+
})
349+
350+
expect(adminRequestDoc).toHaveBeenCalledWith(
351+
expect.objectContaining({
352+
addedHeaders: {
353+
'X-Empty-Header': '',
354+
},
355+
}),
356+
)
357+
})
358+
359+
test('includes response extensions in output when present', async () => {
360+
const query = 'query { shop { name } }'
361+
const mockResult = {shop: {name: 'Test Shop'}}
362+
const mockExtensions = {cost: {requestedQueryCost: 1, actualQueryCost: 1}}
363+
364+
vi.mocked(adminRequestDoc).mockImplementation(async (options) => {
365+
// Simulate the onResponse callback being called with extensions
366+
if (options.responseOptions?.onResponse) {
367+
options.responseOptions.onResponse({
368+
data: mockResult,
369+
extensions: mockExtensions,
370+
headers: new Headers(),
371+
status: 200,
372+
} as any)
373+
}
374+
return mockResult
375+
})
376+
377+
const mockOutput = mockAndCaptureOutput()
378+
379+
await executeOperation({
380+
organization: mockOrganization,
381+
remoteApp: mockRemoteApp,
382+
storeFqdn,
383+
query,
384+
})
385+
386+
const output = mockOutput.info()
387+
const parsedOutput = JSON.parse(output)
388+
389+
expect(parsedOutput).toEqual({
390+
data: mockResult,
391+
extensions: mockExtensions,
392+
})
393+
})
394+
395+
test('outputs only data when no extensions are present', async () => {
396+
const query = 'query { shop { name } }'
397+
const mockResult = {shop: {name: 'Test Shop'}}
398+
399+
vi.mocked(adminRequestDoc).mockImplementation(async (options) => {
400+
// Simulate the onResponse callback being called without extensions
401+
if (options.responseOptions?.onResponse) {
402+
options.responseOptions.onResponse({
403+
data: mockResult,
404+
extensions: undefined,
405+
headers: new Headers(),
406+
status: 200,
407+
} as any)
408+
}
409+
return mockResult
410+
})
411+
412+
const mockOutput = mockAndCaptureOutput()
413+
414+
await executeOperation({
415+
organization: mockOrganization,
416+
remoteApp: mockRemoteApp,
417+
storeFqdn,
418+
query,
419+
})
420+
421+
const output = mockOutput.info()
422+
const parsedOutput = JSON.parse(output)
423+
424+
// Should output just the result, not wrapped in {data: ...}
425+
expect(parsedOutput).toEqual(mockResult)
426+
})
427+
428+
test('includes extensions in file output when present', async () => {
429+
await inTemporaryDirectory(async (tmpDir) => {
430+
const outputFile = joinPath(tmpDir, 'results.json')
431+
const query = 'query { shop { name } }'
432+
const mockResult = {shop: {name: 'Test Shop'}}
433+
const mockExtensions = {cost: {requestedQueryCost: 1, actualQueryCost: 1}}
434+
435+
vi.mocked(adminRequestDoc).mockImplementation(async (options) => {
436+
if (options.responseOptions?.onResponse) {
437+
options.responseOptions.onResponse({
438+
data: mockResult,
439+
extensions: mockExtensions,
440+
headers: new Headers(),
441+
status: 200,
442+
} as any)
443+
}
444+
return mockResult
445+
})
446+
447+
await executeOperation({
448+
organization: mockOrganization,
449+
remoteApp: mockRemoteApp,
450+
storeFqdn,
451+
query,
452+
outputFile,
453+
})
454+
455+
const expectedContent = JSON.stringify({data: mockResult, extensions: mockExtensions}, null, 2)
456+
expect(writeFile).toHaveBeenCalledWith(outputFile, expectedContent)
457+
})
458+
})
254459
})

packages/app/src/cli/services/execute-operation.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface ExecuteOperationInput {
2121
variables?: string
2222
outputFile?: string
2323
version?: string
24+
headers?: string[]
2425
}
2526

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

41+
function parseHeaders(headers?: string[]): {[header: string]: string} | undefined {
42+
if (!headers || headers.length === 0) return undefined
43+
44+
const parsedHeaders: {[header: string]: string} = {}
45+
46+
for (const header of headers) {
47+
const separatorIndex = header.indexOf(':')
48+
if (separatorIndex === -1) {
49+
throw new AbortError(
50+
outputContent`Invalid header format: ${outputToken.yellow(header)}`,
51+
'Headers must be in "Key: Value" format.',
52+
)
53+
}
54+
55+
const key = header.slice(0, separatorIndex).trim()
56+
const value = header.slice(separatorIndex + 1).trim()
57+
58+
if (!key) {
59+
throw new AbortError(
60+
outputContent`Invalid header format: ${outputToken.yellow(header)}`,
61+
"Header key can't be empty.",
62+
)
63+
}
64+
65+
parsedHeaders[key] = value
66+
}
67+
68+
return parsedHeaders
69+
}
70+
4071
export async function executeOperation(input: ExecuteOperationInput): Promise<void> {
41-
const {organization, remoteApp, storeFqdn, query, variables, version: userSpecifiedVersion, outputFile} = input
72+
const {
73+
organization,
74+
remoteApp,
75+
storeFqdn,
76+
query,
77+
variables,
78+
version: userSpecifiedVersion,
79+
outputFile,
80+
headers,
81+
} = input
4282

4383
const adminSession = await createAdminSessionAsApp(remoteApp, storeFqdn)
4484

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

5898
const parsedVariables = await parseVariables(variables)
99+
const parsedHeaders = parseHeaders(headers)
59100

60101
validateSingleOperation(query)
61102

62103
try {
104+
let extensions: unknown
105+
63106
const result = await renderSingleTask({
64107
title: outputContent`Executing GraphQL operation`,
65108
task: async () => {
@@ -68,13 +111,20 @@ export async function executeOperation(input: ExecuteOperationInput): Promise<vo
68111
session: adminSession,
69112
variables: parsedVariables,
70113
version,
71-
responseOptions: {handleErrors: false},
114+
responseOptions: {
115+
handleErrors: false,
116+
onResponse: (response) => {
117+
extensions = response.extensions
118+
},
119+
},
120+
addedHeaders: parsedHeaders,
72121
})
73122
},
74123
renderOptions: {stdout: process.stderr},
75124
})
76125

77-
const resultString = JSON.stringify(result, null, 2)
126+
const output = extensions ? {data: result, extensions} : result
127+
const resultString = JSON.stringify(output, null, 2)
78128

79129
if (outputFile) {
80130
await writeFile(outputFile, resultString)

packages/cli-kit/src/public/node/api/admin.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export interface AdminRequestOptions<TResult, TVariables extends Variables> {
6262
responseOptions?: GraphQLResponseOptions<TResult>
6363
/** Custom request behaviour for retries and timeouts. */
6464
preferredBehaviour?: RequestModeInput
65+
/** Custom HTTP headers to include with the request. */
66+
addedHeaders?: {[header: string]: string}
6567
}
6668

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

7880
let apiVersion = version ?? LatestApiVersionByFQDN.get(session.storeFqdn)
7981
if (!apiVersion) {
8082
apiVersion = await fetchLatestSupportedApiVersion(session, preferredBehaviour)
8183
}
8284
let storeDomain = session.storeFqdn
83-
const addedHeaders = themeAccessHeaders(session)
85+
const addedHeaders = {...themeAccessHeaders(session), ...customHeaders}
8486

8587
if (serviceEnvironment() === 'local') {
8688
addedHeaders['x-forwarded-host'] = storeDomain

0 commit comments

Comments
 (0)