diff --git a/.changeset/app-execute-json.md b/.changeset/app-execute-json.md new file mode 100644 index 00000000000..1ebd329f8cc --- /dev/null +++ b/.changeset/app-execute-json.md @@ -0,0 +1,6 @@ +--- +'@shopify/app': minor +'@shopify/cli': minor +--- + +Add typed JSON output to `app execute` with GraphQL error details and file receipts. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 79b22e26edf..c82d99fc605 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -1687,6 +1687,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-execute.interface.ts", "syntaxKind": "PropertySignature", @@ -1715,7 +1724,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface appexecute {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The myshopify.com domain of the store to execute against. The app must be installed on the store. If not specified, you will be prompted to select a store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface appexecute {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * The myshopify.com domain of the store to execute against. The app must be installed on the store. If not specified, you will be prompted to select a store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } }, "appfunctionbuild": { diff --git a/packages/app/src/cli/commands/app/execute.test.ts b/packages/app/src/cli/commands/app/execute.test.ts new file mode 100644 index 00000000000..edf33597d4a --- /dev/null +++ b/packages/app/src/cli/commands/app/execute.test.ts @@ -0,0 +1,151 @@ +import Execute from './execute.js' +import {prepareExecuteContext} from '../../utilities/execute-command-helpers.js' +import {createAdminSessionAsApp, resolveApiVersion} from '../../services/graphql/common.js' +import { + testAppLinked, + testOrganization, + testOrganizationApp, + testOrganizationStore, + testProject, +} from '../../models/app/app.test-data.js' +import {Config} from '@oclif/core' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {ClientError} from 'graphql-request' +// eslint-disable-next-line @shopify/typescript-prefer-build-client-schema -- Local execution fixture without an introspection response. +import {GraphQLError, buildSchema, graphql} from 'graphql' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {handler} from '@shopify/cli-kit/node/error' +import {unstyled} from '@shopify/cli-kit/node/output' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {joinPath} from '@shopify/cli-kit/node/path' +import * as ui from '@shopify/cli-kit/node/ui' + +vi.mock('../../utilities/execute-command-helpers.js') +vi.mock('../../services/graphql/common.js') +vi.mock('@shopify/cli-kit/node/api/admin') + +const query = 'query { shop { name } }' +const app = testAppLinked() + +beforeEach(() => { + const remoteApp = testOrganizationApp() + vi.mocked(prepareExecuteContext).mockResolvedValue({ + query, + appContextResult: { + app, + remoteApp, + organization: testOrganization(), + developerPlatformClient: remoteApp.developerPlatformClient, + project: testProject(), + specifications: [], + activeConfig: {} as never, + }, + store: testOrganizationStore({shopDomain: 'shop.myshopify.com'}), + }) + vi.mocked(createAdminSessionAsApp).mockResolvedValue({token: 'test-token', storeFqdn: 'shop.myshopify.com'}) + vi.mocked(resolveApiVersion).mockResolvedValue('2026-10') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +async function runCommand(flags: string[]) { + const argv = ['--query', query, '--store', 'shop.myshopify.com', ...flags] + const command = new Execute(argv, await Config.load()) + const textTask = flags.includes('--json') + ? undefined + : vi.spyOn(ui, 'renderSingleTask').mockImplementation(async ({task}) => task(() => {})) + try { + return await runWithCommandEventsForCommand(argv, () => command.run()) + } finally { + textTask?.mockRestore() + } +} + +test('preserves native GraphQL aliases, order, and UTF-8 on stdout and in output files', async () => { + const response = await graphql({ + schema: buildSchema('type Query { name: String! nested: Query }'), + source: 'query { last: name __proto__: name constructor: name nested { __proto__: name constructor: name } }', + rootValue: {name: 'Café', nested: {name: 'Nested'}}, + }) + expect(response.errors).toBeUndefined() + const extensions = JSON.parse('{"__proto__":{"trace":"preserved"},"constructor":false,"nullable":null,"empty":[]}') + vi.mocked(adminRequestDoc).mockImplementation(async ({responseOptions}) => { + responseOptions?.onResponse?.({data: response.data, extensions, status: 200, headers: new Headers()}) + return response.data + }) + const json = JSON.stringify({data: response.data, extensions}, null, 2) + const text = JSON.stringify(response.data, null, 2) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(['--json']) + expect(stdout()).toBe(`${json}\n`) + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual([ + expect.objectContaining({type: 'progress', status: 'started', message: 'Authenticating'}), + expect.objectContaining({type: 'progress', status: 'completed', message: 'Authenticating'}), + expect.objectContaining({type: 'progress', status: 'started', message: 'Executing GraphQL operation'}), + expect.objectContaining({type: 'progress', status: 'completed', message: 'Executing GraphQL operation'}), + ]) + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand([]) + expect(stdout()).toBe(`${text}\n`) + expect(stderr()).toContain('Operation succeeded.') + }) + await inTemporaryDirectory(async (directory) => { + const path = joinPath(directory, 'result.json') + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(['--json', '--output-file', path]) + expect(JSON.parse(stdout())).toEqual({path, format: 'json'}) + }) + await expect(readFile(path)).resolves.toBe(json) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(['--output-file', path]) + expect(stdout()).toBe('') + expect(stderr()).toContain('Operation succeeded.') + expect(unstyled(stderr()).replace(/[│\s]/g, '')).toContain(`Resultswrittento${path}`) + }) + await expect(readFile(path)).resolves.toBe(text) + }) +}) + +test('uses one shared fatal error document and preserves an existing output file', async () => { + await inTemporaryDirectory(async (directory) => { + const outputFile = joinPath(directory, 'result.json') + await writeFile(outputFile, 'existing result') + const details = { + errors: [new GraphQLError('Denied', {extensions: {code: 'ACCESS_DENIED'}})], + data: {shop: null}, + extensions: {cost: {actualQueryCost: 1}}, + } + vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError({...details, status: 200}, {query})) + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(['--json', '--output-file', outputFile]).catch(handler) + expect(JSON.parse(stdout())).toEqual({ + error: {type: 'abort', message: 'GraphQL operation failed.', details: JSON.parse(JSON.stringify(details))}, + }) + expect(stderr()).not.toContain('GraphQL operation failed.') + }) + await expect(readFile(outputFile)).resolves.toBe('existing result') + }) +}) + +test('preserves the text error banner without a fatal error for a GraphQL failure', async () => { + const errors = [new GraphQLError('Field invalidField does not exist')] + vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError({errors, status: 200}, {query})) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runCommand([])).resolves.toEqual({app}) + expect(stdout()).toBe('') + expect(stderr()).toContain('GraphQL operation failed.') + expect(stderr()).toContain('invalidField') + }) +}) diff --git a/packages/app/src/cli/commands/app/execute.ts b/packages/app/src/cli/commands/app/execute.ts index 104759340c9..51c692f3e77 100644 --- a/packages/app/src/cli/commands/app/execute.ts +++ b/packages/app/src/cli/commands/app/execute.ts @@ -1,8 +1,10 @@ import {appFlags, operationFlags} from '../../flags.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' import {executeOperation} from '../../services/execute-operation.js' +import {appExecuteJsonOutputSchema} from '../../services/execute-operation/types.js' +import {renderExecuteOperationResult} from '../../services/execute-operation/result.js' import {prepareExecuteContext} from '../../utilities/execute-command-helpers.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' export default class Execute extends AppLinkedCommand { static summary = 'Execute GraphQL queries and mutations.' @@ -11,12 +13,17 @@ export default class Execute extends AppLinkedCommand { For operations that process large amounts of data, use [\`bulk execute\`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) instead.` + static get jsonOutputSchema() { + return appExecuteJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { ...globalFlags, ...appFlags, ...operationFlags, + ...jsonFlag, } async run(): Promise { @@ -24,16 +31,16 @@ export default class Execute extends AppLinkedCommand { const {query, appContextResult, store} = await prepareExecuteContext(flags) - await executeOperation({ + const result = await executeOperation({ organization: appContextResult.organization, remoteApp: appContextResult.remoteApp, store, query, variables: flags.variables, variableFile: flags['variable-file'], - outputFile: flags['output-file'], ...(flags.version && {version: flags.version}), }) + await renderExecuteOperationResult(result, flags.json ? 'json' : 'text', flags['output-file']) return {app: appContextResult.app} } diff --git a/packages/app/src/cli/services/execute-operation.test.ts b/packages/app/src/cli/services/execute-operation.test.ts index 4bc426cac54..29afb35d2ea 100644 --- a/packages/app/src/cli/services/execute-operation.test.ts +++ b/packages/app/src/cli/services/execute-operation.test.ts @@ -1,13 +1,13 @@ import {executeOperation} from './execute-operation.js' import {createAdminSessionAsApp, resolveApiVersion, validateMutationStore} from './graphql/common.js' import {OrganizationApp, OrganizationSource, OrganizationStore} from '../models/organization.js' -import {renderSuccess, renderError, renderSingleTask} from '@shopify/cli-kit/node/ui' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' import {ClientError} from 'graphql-request' -import {inTemporaryDirectory, writeFile, readFile} from '@shopify/cli-kit/node/fs' +import {GraphQLError} from 'graphql' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' +import {describe, test, expect, vi, beforeEach} from 'vitest' vi.mock('./graphql/common.js') vi.mock('@shopify/cli-kit/node/ui') @@ -47,22 +47,19 @@ describe('executeOperation', () => { }) }) - afterEach(() => { - mockAndCaptureOutput().clear() - }) - test('executes GraphQL operation successfully', async () => { const query = 'query { shop { name } }' - const mockResult = {data: {shop: {name: 'Test Shop'}}} + const mockResult = {shop: {name: 'Test Shop'}} vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) - await executeOperation({ + const result = await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, query, }) + expect(result).toEqual({status: 'success', result: {data: mockResult}}) expect(createAdminSessionAsApp).toHaveBeenCalledWith(mockRemoteApp, storeFqdn) expect(resolveApiVersion).toHaveBeenCalledWith({adminSession: mockAdminSession}) expect(adminRequestDoc).toHaveBeenCalledWith({ @@ -71,14 +68,14 @@ describe('executeOperation', () => { session: mockAdminSession, variables: undefined, version: '2024-07', - responseOptions: {handleErrors: false}, + responseOptions: {handleErrors: false, onResponse: expect.any(Function)}, }) }) test('passes variables correctly when provided', async () => { const query = 'mutation UpdateProduct($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' const variables = '{"input":{"id":"gid://shopify/Product/123","title":"Updated"}}' - const mockResult = {data: {productUpdate: {product: {id: 'gid://shopify/Product/123'}}}} + const mockResult = {productUpdate: {product: {id: 'gid://shopify/Product/123'}}} vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) await executeOperation({ @@ -120,7 +117,7 @@ describe('executeOperation', () => { await writeFile(variableFile, JSON.stringify(variables)) const query = 'mutation UpdateProduct($input: ProductInput!) { productUpdate(input: $input) { product { id } } }' - const mockResult = {data: {productUpdate: {product: {id: 'gid://shopify/Product/123'}}}} + const mockResult = {productUpdate: {product: {id: 'gid://shopify/Product/123'}}} vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) await executeOperation({ @@ -182,7 +179,7 @@ describe('executeOperation', () => { test('uses specified API version when provided', async () => { const query = 'query { shop { name } }' const version = '2024-01' - const mockResult = {data: {shop: {name: 'Test Shop'}}} + const mockResult = {shop: {name: 'Test Shop'}} vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) vi.mocked(resolveApiVersion).mockResolvedValue(version) @@ -202,66 +199,45 @@ describe('executeOperation', () => { ) }) - test('writes formatted JSON results to stdout by default', async () => { - const query = 'query { shop { name } }' - const mockResult = {data: {shop: {name: 'Test Shop'}}} - vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) - - const mockOutput = mockAndCaptureOutput() + test('retains response extensions without adding another request', async () => { + const data = {store: {name: 'Test Shop'}, value: null, enabled: false} + const extensions = {cost: {requestedQueryCost: 2}} + vi.mocked(adminRequestDoc).mockImplementation(async ({responseOptions}) => { + responseOptions?.onResponse?.({data, extensions, status: 200, headers: new Headers()}) + return data + }) - await executeOperation({ + const result = await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, - query, + query: 'query { store: shop { name } }', }) - const expectedOutput = JSON.stringify(mockResult, null, 2) - expect(mockOutput.info()).toContain(expectedOutput) - }) - - test('writes results to file when outputFile is provided', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputFile = joinPath(tmpDir, 'results.json') - const query = 'query { shop { name } }' - const mockResult = {data: {shop: {name: 'Test Shop'}}} - vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) - - await executeOperation({ - organization: mockOrganization, - remoteApp: mockRemoteApp, - store: mockStore, - query, - outputFile, - }) - - const expectedContent = JSON.stringify(mockResult, null, 2) - await expect(readFile(outputFile)).resolves.toBe(expectedContent) - expect(renderSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.stringContaining(outputFile), - }), - ) - }) + expect(result).toEqual({status: 'success', result: {data, extensions}}) + expect(adminRequestDoc).toHaveBeenCalledOnce() }) - test('renders success message after successful execution', async () => { - const query = 'query { shop { name } }' - const mockResult = {data: {shop: {name: 'Test Shop'}}} - vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) + test('retains partial query data and native error extensions', async () => { + const response = { + errors: [new GraphQLError('Access denied', {extensions: {code: 'ACCESS_DENIED'}})], + data: {shop: null}, + extensions: {cost: {actualQueryCost: 1}}, + status: 200, + } + vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError(response, {query: 'query { shop { name } }'})) - await executeOperation({ + const result = await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, - query, + query: 'query { shop { name } }', }) - expect(renderSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - headline: 'Operation succeeded.', - }), - ) + expect(result).toEqual({ + status: 'failed', + details: {errors: response.errors, data: response.data, extensions: response.extensions}, + }) }) test('throws when API request fails', async () => { @@ -279,27 +255,6 @@ describe('executeOperation', () => { ).rejects.toThrow('API request failed') }) - test('handles GraphQL errors in response', async () => { - const query = 'query { shop { name } }' - const mockResult = { - data: null, - errors: [{message: 'Field "name" not found'}], - } - vi.mocked(adminRequestDoc).mockResolvedValue(mockResult) - - await executeOperation({ - organization: mockOrganization, - remoteApp: mockRemoteApp, - store: mockStore, - query, - }) - - // Should still format and output the result with errors - const mockOutput = mockAndCaptureOutput() - const expectedOutput = JSON.stringify(mockResult, null, 2) - expect(mockOutput.info()).toContain(expectedOutput) - }) - test('handles ClientError from GraphQL validation failures', async () => { const query = 'query { invalidField }' const graphqlErrors = [ @@ -311,19 +266,14 @@ describe('executeOperation', () => { vi.mocked(adminRequestDoc).mockRejectedValue(clientError) - await executeOperation({ + const result = await executeOperation({ organization: mockOrganization, remoteApp: mockRemoteApp, store: mockStore, query, }) - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - headline: 'GraphQL operation failed.', - body: expect.stringContaining('invalidField'), - }), - ) + expect(result).toEqual({status: 'failed', details: {errors: graphqlErrors}}) }) test('throws AbortError when attempting mutation on non-dev store', async () => { diff --git a/packages/app/src/cli/services/execute-operation.ts b/packages/app/src/cli/services/execute-operation.ts index 00807b16071..21517d18caa 100644 --- a/packages/app/src/cli/services/execute-operation.ts +++ b/packages/app/src/cli/services/execute-operation.ts @@ -4,15 +4,16 @@ import { resolveApiVersion, validateMutationStore, } from './graphql/common.js' +import {ExecuteOperationResult} from './execute-operation/types.js' import {OrganizationApp, Organization, OrganizationStore} from '../models/organization.js' -import {renderSuccess, renderError, renderSingleTask} from '@shopify/cli-kit/node/ui' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {AdminSession} from '@shopify/cli-kit/node/session' -import {outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' import {ClientError} from 'graphql-request' import {parse} from 'graphql' -import {writeFile, readFile, fileExists} from '@shopify/cli-kit/node/fs' +import {readFile, fileExists} from '@shopify/cli-kit/node/fs' interface ExecuteOperationInput { organization: Organization @@ -21,7 +22,6 @@ interface ExecuteOperationInput { query: string variables?: string variableFile?: string - outputFile?: string version?: string } @@ -61,8 +61,8 @@ async function parseVariables( return undefined } -export async function executeOperation(input: ExecuteOperationInput): Promise { - const {remoteApp, store, query, variables, variableFile, version: userSpecifiedVersion, outputFile} = input +export async function executeOperation(input: ExecuteOperationInput): Promise { + const {remoteApp, store, query, variables, variableFile, version: userSpecifiedVersion} = input const {adminSession, version} = await renderSingleTask({ title: outputContent`Authenticating`, @@ -80,47 +80,38 @@ export async function executeOperation(input: ExecuteOperationInput): Promise | undefined + const data = await renderSingleTask({ title: outputContent`Executing GraphQL operation`, task: async () => { - return adminRequestDoc({ + return adminRequestDoc | null, Record>({ query: parse(query), session: adminSession, variables: parsedVariables, version, - responseOptions: {handleErrors: false}, + responseOptions: { + handleErrors: false, + onResponse: (response) => { + extensions = response.extensions as Record | undefined + }, + }, }) }, renderOptions: {stdout: process.stderr}, }) - const resultString = JSON.stringify(result, null, 2) - - if (outputFile) { - await writeFile(outputFile, resultString) - renderSuccess({ - headline: 'Operation succeeded.', - body: `Results written to ${outputFile}`, - }) - } else { - renderSuccess({ - headline: 'Operation succeeded.', - }) - outputResult(resultString) - } + return {status: 'success', result: {data, ...(extensions === undefined ? {} : {extensions})}} } catch (error) { if (error instanceof ClientError) { - // GraphQL errors from user's query - render as error - const errorResult = { - errors: error.response.errors, + const {errors, extensions, data} = error.response + return { + status: 'failed', + details: { + ...(errors === undefined ? {} : {errors}), + ...(extensions === undefined ? {} : {extensions}), + ...(data === undefined ? {} : {data}), + }, } - const errorString = JSON.stringify(errorResult, null, 2) - - renderError({ - headline: 'GraphQL operation failed.', - body: errorString, - }) - return } // Network/system errors - let them propagate throw error diff --git a/packages/app/src/cli/services/execute-operation/result.ts b/packages/app/src/cli/services/execute-operation/result.ts new file mode 100644 index 00000000000..9c43f99f87d --- /dev/null +++ b/packages/app/src/cli/services/execute-operation/result.ts @@ -0,0 +1,41 @@ +import {appExecuteJsonOutputSchema, ExecuteOperationResult} from './types.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {writeFile} from '@shopify/cli-kit/node/fs' +import {outputResult} from '@shopify/cli-kit/node/output' +import {resolvePath} from '@shopify/cli-kit/node/path' +import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' + +export async function renderExecuteOperationResult( + result: ExecuteOperationResult, + format: 'json' | 'text', + outputFile?: string, +): Promise { + if (result.status === 'failed') { + if (format === 'json') { + const error = new AbortError('GraphQL operation failed.') + error.details = result.details + throw error + } + renderError({ + headline: 'GraphQL operation failed.', + body: JSON.stringify({errors: result.details.errors}, null, 2), + }) + return + } + + const resultString = + format === 'json' ? appExecuteJsonOutputSchema.encode(result.result) : JSON.stringify(result.result.data, null, 2) + if (outputFile) { + await writeFile(outputFile, resultString) + if (format === 'json') { + outputResult(appExecuteJsonOutputSchema.encode({path: resolvePath(outputFile), format: 'json'})) + } else { + renderSuccess({headline: 'Operation succeeded.', body: `Results written to ${outputFile}`}) + } + } else if (format === 'json') { + outputResult(resultString) + } else { + renderSuccess({headline: 'Operation succeeded.'}) + outputResult(resultString) + } +} diff --git a/packages/app/src/cli/services/execute-operation/types.test.ts b/packages/app/src/cli/services/execute-operation/types.test.ts new file mode 100644 index 00000000000..c1c32986e53 --- /dev/null +++ b/packages/app/src/cli/services/execute-operation/types.test.ts @@ -0,0 +1,9 @@ +import {appExecuteJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' + +test('keeps native objects strict only at the CLI wrapper', () => { + expect(appExecuteJsonOutputSchema.validate({data: null})).toEqual({data: null}) + expect(() => appExecuteJsonOutputSchema.validate({data: []})).toThrow() + expect(() => appExecuteJsonOutputSchema.validate({data: {}, extensions: []})).toThrow() + expect(() => appExecuteJsonOutputSchema.validate({data: {}, internal: true})).toThrow() +}) diff --git a/packages/app/src/cli/services/execute-operation/types.ts b/packages/app/src/cli/services/execute-operation/types.ts new file mode 100644 index 00000000000..1d911ceb6a4 --- /dev/null +++ b/packages/app/src/cli/services/execute-operation/types.ts @@ -0,0 +1,34 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' +import {zod} from '@shopify/cli-kit/node/schema' + +// Keep native keys and order, including "__proto__" omitted by Zod record parsing, +// while the record schema validates that each payload is an object. +const nativeGraphQLObjectSchema = zod.unknown().and(zod.record(zod.unknown())) + +const graphQLResultSchema = zod + .object({ + data: nativeGraphQLObjectSchema.nullable().describe('Native GraphQL query data, preserving fields and aliases.'), + extensions: nativeGraphQLObjectSchema.optional().describe('Native GraphQL response extensions, when supplied.'), + }) + .strict() + +const fileReceiptSchema = zod + .object({ + path: zod.string().refine(isAbsolutePath, 'Expected an absolute filesystem path.'), + format: zod.literal('json'), + }) + .strict() + .describe('Receipt for a written JSON file containing the GraphQL result, including data and optional extensions.') + +export const appExecuteJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppExecuteResult', + schema: zod.union([graphQLResultSchema, fileReceiptSchema]), + definitions: {AppExecuteGraphQLResult: graphQLResultSchema, AppExecuteFileReceipt: fileReceiptSchema}, +}) + +export type AppExecuteResult = InferJsonOutputSchema + +export type ExecuteOperationResult = + | {status: 'success'; result: Extract} + | {status: 'failed'; details: {errors?: unknown; extensions?: unknown; data?: unknown}} diff --git a/packages/cli/README.md b/packages/cli/README.md index 95b19a8e8b7..15b6ac20915 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1554,15 +1554,19 @@ Execute GraphQL queries and mutations. ``` USAGE - $ shopify app execute [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--no-input] [--output-file ] [--path ] [-q ] [--query-file ] [--reset | ] [-s ] - [--variable-file | -v ] [--verbose] [--version ] + $ shopify app execute [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] + [--no-color] [--no-input] [--output-file ] [--path ] [-q ] [--query-file ] [--reset | ] + [-s ] [--variable-file | -v ] [--verbose] [--version ] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -q, --query= The GraphQL query or mutation, as a string. [env: SHOPIFY_FLAG_QUERY] @@ -1631,6 +1635,75 @@ DESCRIPTION For operations that process large amounts of data, use "`bulk execute`" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) instead. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppExecuteResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/AppExecuteGraphQLResult" + }, + { + "$ref": "#/definitions/AppExecuteFileReceipt" + } + ], + "title": "AppExecuteResult", + "definitions": { + "AppExecuteGraphQLResult": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "allOf": [ + {}, + { + "type": "object", + "additionalProperties": {} + } + ] + }, + { + "type": "null" + } + ], + "description": "Native GraphQL query data, preserving fields and aliases." + }, + "extensions": { + "$ref": "#/definitions/AppExecuteGraphQLResult/properties/data/anyOf/0", + "description": "Native GraphQL response extensions, when supplied." + } + }, + "required": [ + "data" + ], + "additionalProperties": false + }, + "AppExecuteFileReceipt": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "format": { + "type": "string", + "const": "json" + } + }, + "required": [ + "path", + "format" + ], + "additionalProperties": false, + "description": "Receipt for a written JSON file containing the GraphQL result, including data and optional extensions." + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app function build` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 31e82b9ad49..ceb7535306c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1695,7 +1695,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Executes an Admin API GraphQL query or mutation on the specified store. Mutations are only allowed on dev stores.\n\n For operations that process large amounts of data, use \"`bulk execute`\" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) instead.", + "description": "Executes an Admin API GraphQL query or mutation on the specified store. Mutations are only allowed on dev stores.\n\n For operations that process large amounts of data, use \"`bulk execute`\" (https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) instead.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppExecuteResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/AppExecuteGraphQLResult\"\n },\n {\n \"$ref\": \"#/definitions/AppExecuteFileReceipt\"\n }\n ],\n \"title\": \"AppExecuteResult\",\n \"definitions\": {\n \"AppExecuteGraphQLResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"data\": {\n \"anyOf\": [\n {\n \"allOf\": [\n {},\n {\n \"type\": \"object\",\n \"additionalProperties\": {}\n }\n ]\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"Native GraphQL query data, preserving fields and aliases.\"\n },\n \"extensions\": {\n \"$ref\": \"#/definitions/AppExecuteGraphQLResult/properties/data/anyOf/0\",\n \"description\": \"Native GraphQL response extensions, when supplied.\"\n }\n },\n \"required\": [\n \"data\"\n ],\n \"additionalProperties\": false\n },\n \"AppExecuteFileReceipt\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"format\": {\n \"type\": \"string\",\n \"const\": \"json\"\n }\n },\n \"required\": [\n \"path\",\n \"format\"\n ],\n \"additionalProperties\": false,\n \"description\": \"Receipt for a written JSON file containing the GraphQL result, including data and optional extensions.\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Executes an Admin API GraphQL query or mutation on the specified store. Mutations are only allowed on dev stores.\n\n For operations that process large amounts of data, use [`bulk execute`](https://shopify.dev/docs/api/shopify-cli/app/app-bulk-execute) instead.", "flags": { "auth-alias": { @@ -1728,6 +1728,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 0fcd20c5ff1..8cbbc9b016b 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -11,7 +11,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/dev/clean.ts', 'packages/app/src/cli/commands/app/env/pull.ts', 'packages/app/src/cli/commands/app/env/show.ts', - 'packages/app/src/cli/commands/app/execute.ts', 'packages/app/src/cli/commands/app/function/build.ts', 'packages/app/src/cli/commands/app/function/info.ts', 'packages/app/src/cli/commands/app/function/replay.ts',