Skip to content

Commit 5704092

Browse files
Merge pull request #8892 from Shopify/gonzalo/function-typegen-json
Add typed JSON output to app function typegen
2 parents d00cfeb + 7d1a5bc commit 5704092

12 files changed

Lines changed: 346 additions & 10 deletions

File tree

‎.changeset/function-json.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli': minor
3+
---
4+
5+
Add typed JSON output to `app function` commands

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2445,9 +2445,18 @@
24452445
"description": "The name of the app configuration.",
24462446
"isOptional": true,
24472447
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
2448+
},
2449+
{
2450+
"filePath": "docs-shopify.dev/commands/interfaces/app-function-typegen.interface.ts",
2451+
"syntaxKind": "PropertySignature",
2452+
"name": "-j, --json",
2453+
"value": "''",
2454+
"description": "Output the result as JSON. Automatically disables color output.",
2455+
"isOptional": true,
2456+
"environmentValue": "SHOPIFY_FLAG_JSON"
24482457
}
24492458
],
2450-
"value": "export interface appfunctiontypegen {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 path to your function directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
2459+
"value": "export interface appfunctiontypegen {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 path to your function directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
24512460
}
24522461
},
24532462
"appgenerateextension": {
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import FunctionTypegen from './typegen.js'
2+
import {chooseFunction} from '../../../services/function/common.js'
3+
import {localAppContext} from '../../../services/app-context.js'
4+
import {testApp, testFunctionExtension, testProject} from '../../../models/app/app.test-data.js'
5+
import {functionTypegenJsonOutputSchema} from '../../../services/function/typegen/types.js'
6+
import {Config} from '@oclif/core'
7+
import {afterEach, expect, test, vi} from 'vitest'
8+
import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs'
9+
import {joinPath} from '@shopify/cli-kit/node/path'
10+
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
11+
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
12+
import {ExternalError, AbortError, handler} from '@shopify/cli-kit/node/error'
13+
import {exec} from '@shopify/cli-kit/node/system'
14+
import {packageManagerBinaryCommandForDirectory} from '@shopify/cli-kit/node/node-package-manager'
15+
import {unstyled} from '@shopify/cli-kit/node/output'
16+
17+
// eslint-disable-next-line no-restricted-imports
18+
import {resolve} from 'node:path'
19+
20+
vi.mock('../../../services/app-context.js')
21+
vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({
22+
...(await importOriginal<typeof import('@shopify/cli-kit/node/system')>()),
23+
exec: vi.fn(),
24+
}))
25+
vi.mock('@shopify/cli-kit/node/node-package-manager', async (importOriginal) => ({
26+
...(await importOriginal<typeof import('@shopify/cli-kit/node/node-package-manager')>()),
27+
packageManagerBinaryCommandForDirectory: vi.fn(),
28+
}))
29+
vi.mock('../../../services/function/common.js', async (importOriginal) => ({
30+
...(await importOriginal<typeof import('../../../services/function/common.js')>()),
31+
chooseFunction: vi.fn(),
32+
}))
33+
afterEach(() => vi.unstubAllEnvs())
34+
35+
async function command(argv: string[], directory: string, custom = false) {
36+
const app = testApp({directory})
37+
const extension = await testFunctionExtension({dir: directory, entryPath: 'src/index.js'})
38+
if (custom) extension.configuration.build!.typegen_command = 'custom-codegen --target test'
39+
vi.mocked(localAppContext).mockResolvedValue({app, project: testProject(), activeConfig: {} as never})
40+
vi.mocked(chooseFunction).mockResolvedValue(extension)
41+
vi.mocked(packageManagerBinaryCommandForDirectory).mockResolvedValue({
42+
command: 'codegen',
43+
args: ['--config', 'package.json'],
44+
})
45+
return {instance: new FunctionTypegen(argv, await Config.load()), extension, app}
46+
}
47+
48+
test.each([
49+
{custom: true, argv: ['--json', '--no-input']},
50+
{custom: true, argv: ['--json']},
51+
{custom: false, argv: ['--json', '--no-input']},
52+
{custom: false, argv: ['--json']},
53+
{custom: true, argv: ['-jcstaging']},
54+
{custom: false, argv: ['-jcstaging']},
55+
{custom: true, argv: ['-j']},
56+
{custom: false, argv: ['-j']},
57+
])('emits one result and codegen side events: %j', async ({custom, argv}) => {
58+
await inTemporaryDirectory(async (directory) => {
59+
const {instance, extension, app} = await command(argv, directory, custom)
60+
vi.mocked(exec).mockImplementation(async (_command, _args, options) => {
61+
if (options?.stdout && options.stdout !== 'inherit') options.stdout.write('Generating types\n')
62+
if (options?.stderr && options.stderr !== 'inherit') options.stderr.write('Codegen warning\n')
63+
await writeFile(joinPath(directory, 'generated.ts'), 'export type Input = {}\n')
64+
})
65+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
66+
await expect(runWithCommandEventsForCommand(argv, () => instance.run())).resolves.toEqual({app})
67+
expect(JSON.parse(stdout())).toEqual({
68+
status: 'success',
69+
function: {handle: extension.configuration.handle ?? null, name: extension.name, directory: resolve(directory)},
70+
})
71+
const events = stderr()
72+
.trim()
73+
.split('\n')
74+
.map((line) => JSON.parse(line))
75+
expect(events).toContainEqual(
76+
expect.objectContaining({type: 'diagnostic', message: expect.stringContaining('Codegen warning')}),
77+
)
78+
if (custom)
79+
expect(events).toContainEqual(
80+
expect.objectContaining({type: 'diagnostic', message: expect.stringContaining('Generating types')}),
81+
)
82+
expect(stderr()).not.toContain('GraphQL types generated successfully.')
83+
})
84+
await expect(readFile(joinPath(directory, 'generated.ts'))).resolves.toBe('export type Input = {}\n')
85+
expect(exec).toHaveBeenCalledWith(
86+
custom ? 'custom-codegen' : 'codegen',
87+
custom ? ['--target', 'test'] : ['--config', 'package.json'],
88+
expect.objectContaining({cwd: directory}),
89+
)
90+
})
91+
})
92+
93+
test.each([[], ['--no-input']])('keeps custom codegen text output and the success banner: %j', async (...argv) => {
94+
await inTemporaryDirectory(async (directory) => {
95+
const {instance} = await command(argv, directory, true)
96+
vi.mocked(exec).mockImplementation(async (_command, _args, options) => {
97+
if (options?.stdout && options.stdout !== 'inherit') options.stdout.write('Native codegen output\n')
98+
})
99+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
100+
await instance.run()
101+
expect(stdout()).toBe('Native codegen output\n')
102+
expect(unstyled(stderr())).toContain('GraphQL types generated successfully.')
103+
})
104+
})
105+
})
106+
107+
test('emits one fatal document when the custom generator fails', async () => {
108+
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
109+
await inTemporaryDirectory(async (directory) => {
110+
const {instance} = await command(['--json'], directory, true)
111+
vi.mocked(exec).mockRejectedValue(new ExternalError('Generator failed', 'custom-codegen', []))
112+
await withCapturedStandardStreams(async ({stdout}) => {
113+
await runWithCommandEventsForCommand(instance.argv, async () => {
114+
try {
115+
await instance.run()
116+
expect.unreachable('Expected typegen to fail')
117+
} catch (error) {
118+
if (!(error instanceof ExternalError)) throw error
119+
await handler(error)
120+
}
121+
})
122+
expect(JSON.parse(stdout())).toMatchObject({error: {type: 'external', message: 'Generator failed'}})
123+
expect(stdout()).not.toContain('"status":"success"')
124+
})
125+
})
126+
})
127+
128+
test('keeps the missing custom generator error for non-JavaScript functions', async () => {
129+
await inTemporaryDirectory(async (directory) => {
130+
const {instance, extension} = await command(['--json'], directory)
131+
extension.entrySourceFilePath = 'src/main.rs'
132+
await withCapturedStandardStreams(async ({stdout}) => {
133+
await expect(runWithCommandEventsForCommand(instance.argv, () => instance.run())).rejects.toThrow(AbortError)
134+
expect(stdout()).toBe('')
135+
})
136+
expect(exec).not.toHaveBeenCalled()
137+
})
138+
})
139+
140+
test('exposes the result schema and retains inherited flags', () => {
141+
expect(FunctionTypegen.flags.json).toBeDefined()
142+
expect(FunctionTypegen.flags['no-input']).toBeDefined()
143+
expect(FunctionTypegen.jsonOutputSchema).toBe(functionTypegenJsonOutputSchema)
144+
expect(FunctionTypegen.descriptionForHelp()).toContain('FunctionTypegenResult')
145+
})

‎packages/app/src/cli/commands/app/function/typegen.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
import {chooseFunction, functionFlags} from '../../../services/function/common.js'
2-
import {buildGraphqlTypes} from '../../../services/function/build.js'
2+
import {generateFunctionTypes} from '../../../services/function/typegen.js'
3+
import {functionTypegenJsonOutputSchema} from '../../../services/function/typegen/types.js'
4+
import {renderFunctionTypegenResult} from '../../../services/function/typegen/result.js'
35
import {appFlags} from '../../../flags.js'
46
import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../../utilities/app-unlinked-command.js'
57
import {localAppContext} from '../../../services/app-context.js'
6-
import {globalFlags} from '@shopify/cli-kit/node/cli'
7-
import {renderSuccess} from '@shopify/cli-kit/node/ui'
8+
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
9+
import {renderConcurrent} from '@shopify/cli-kit/node/ui'
10+
import {renderCommandEventAsJson, runWithCommandEvents} from '@shopify/cli-kit/node/command-events'
11+
import {Writable} from 'stream'
812

913
export default class FunctionTypegen extends AppUnlinkedCommand {
14+
static get jsonOutputSchema() {
15+
return functionTypegenJsonOutputSchema
16+
}
17+
1018
static summary = 'Generate GraphQL types for a function.'
1119

1220
static descriptionWithMarkdown = `Creates GraphQL types based on your [input query](https://shopify.dev/docs/apps/functions/input-output#input) for a function. Supports JavaScript functions out of the box, or any language via the \`build.typegen_command\` configuration.`
@@ -17,6 +25,7 @@ export default class FunctionTypegen extends AppUnlinkedCommand {
1725
...globalFlags,
1826
...appFlags,
1927
...functionFlags,
28+
...jsonFlag,
2029
}
2130

2231
public async run(): Promise<AppUnlinkedCommandOutput> {
@@ -29,8 +38,20 @@ export default class FunctionTypegen extends AppUnlinkedCommand {
2938

3039
const ourFunction = await chooseFunction(app, flags.path)
3140

32-
await buildGraphqlTypes(ourFunction, {stdout: process.stdout, stderr: process.stderr, app})
33-
renderSuccess({headline: 'GraphQL types generated successfully.'})
41+
const execute = async (stdout: Writable, stderr: Writable) => {
42+
const result = await generateFunctionTypes(ourFunction, {stdout, stderr, app})
43+
renderFunctionTypegenResult(result, flags.json)
44+
}
45+
if (flags.json) {
46+
await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () =>
47+
renderConcurrent({
48+
processes: [{prefix: ourFunction.localIdentifier, action: execute}],
49+
showTimestamps: false,
50+
}),
51+
)
52+
} else {
53+
await execute(process.stdout, process.stderr)
54+
}
3455

3556
return {app}
3657
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {generateFunctionTypes} from './typegen.js'
2+
import {buildGraphqlTypes} from './build.js'
3+
import {testApp, testFunctionExtension} from '../../models/app/app.test-data.js'
4+
import {expect, test, vi} from 'vitest'
5+
6+
// eslint-disable-next-line no-restricted-imports
7+
import {resolve} from 'node:path'
8+
9+
vi.mock('./build.js')
10+
11+
test('returns a typed Function identity after generation', async () => {
12+
const extension = await testFunctionExtension()
13+
const options = {app: testApp(), stdout: process.stdout, stderr: process.stderr}
14+
await expect(generateFunctionTypes(extension, options)).resolves.toEqual({
15+
status: 'success',
16+
function: {
17+
handle: extension.configuration.handle ?? null,
18+
name: extension.name,
19+
directory: resolve(extension.directory),
20+
},
21+
})
22+
expect(buildGraphqlTypes).toHaveBeenCalledWith(extension, options)
23+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {buildGraphqlTypes} from './build.js'
2+
import {type FunctionTypegenResult} from './typegen/types.js'
3+
import {type ExtensionBuildOptions} from '../build/extension.js'
4+
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
5+
import {FunctionConfigType} from '../../models/extensions/specifications/function.js'
6+
// eslint-disable-next-line no-restricted-imports
7+
import {resolve} from 'node:path'
8+
9+
export async function generateFunctionTypes(
10+
extension: ExtensionInstance<FunctionConfigType>,
11+
options: Pick<ExtensionBuildOptions, 'app' | 'stdout' | 'stderr' | 'signal'>,
12+
): Promise<FunctionTypegenResult> {
13+
await buildGraphqlTypes(extension, options)
14+
return {
15+
status: 'success',
16+
function: {
17+
handle: extension.configuration.handle ?? null,
18+
name: extension.name,
19+
directory: resolve(extension.directory),
20+
},
21+
}
22+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {functionTypegenJsonOutputSchema} from './types.js'
2+
import {expect, test} from 'vitest'
3+
4+
function fixture() {
5+
return {status: 'success' as const, function: {handle: null, name: 'My Function', directory: '/function'}}
6+
}
7+
8+
test.each([
9+
{field: 'directory', value: 'relative/path'},
10+
{field: 'handle', value: 123},
11+
{field: 'unexpected', value: true},
12+
])('rejects invalid Function $field values', ({field, value}) => {
13+
const result = fixture()
14+
expect(() =>
15+
functionTypegenJsonOutputSchema.validate({...result, function: {...result.function, [field]: value}}),
16+
).toThrow()
17+
})
18+
19+
test('rejects unknown root fields', () => {
20+
expect(() => functionTypegenJsonOutputSchema.validate({...fixture(), unexpected: true})).toThrow()
21+
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import {functionTypegenJsonOutputSchema, type FunctionTypegenResult} from './types.js'
2+
import {outputResult} from '@shopify/cli-kit/node/output'
3+
import {renderSuccess} from '@shopify/cli-kit/node/ui'
4+
5+
export function renderFunctionTypegenResult(result: FunctionTypegenResult, json: boolean): void {
6+
if (json) outputResult(functionTypegenJsonOutputSchema.encode(result))
7+
else renderSuccess({headline: 'GraphQL types generated successfully.'})
8+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {functionIdentitySchema} from '../types.js'
2+
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
3+
import {zod} from '@shopify/cli-kit/node/schema'
4+
5+
export const functionTypegenJsonOutputSchema = defineJsonOutputSchema({
6+
name: 'FunctionTypegenResult',
7+
schema: zod
8+
.object({
9+
status: zod.literal('success'),
10+
function: functionIdentitySchema,
11+
})
12+
.strict(),
13+
definitions: {Function: functionIdentitySchema},
14+
})
15+
16+
export type FunctionTypegenResult = InferJsonOutputSchema<typeof functionTypegenJsonOutputSchema>

0 commit comments

Comments
 (0)