Skip to content

Commit d1d70cd

Browse files
committed
Add build.typegen_command support for non-JS Shopify Functions
1 parent 6068188 commit d1d70cd

File tree

8 files changed

+240
-6
lines changed

8 files changed

+240
-6
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {globalFlags} from '@shopify/cli-kit/node/cli'
77
import {renderSuccess} from '@shopify/cli-kit/node/ui'
88

99
export default class FunctionTypegen extends AppUnlinkedCommand {
10-
static summary = 'Generate GraphQL types for a JavaScript function.'
10+
static summary = 'Generate GraphQL types for a function.'
1111

12-
static descriptionWithMarkdown = `Creates GraphQL types based on your [input query](https://shopify.dev/docs/apps/functions/input-output#input) for a function written in JavaScript.`
12+
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.`
1313

1414
static description = this.descriptionWithoutMarkdown()
1515

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
289289
return config.build?.command
290290
}
291291

292+
get typegenCommand() {
293+
const config = this.configuration as unknown as FunctionConfigType
294+
return config.build?.typegen_command
295+
}
296+
292297
/**
293298
* Default entry paths to be watched in a dev session.
294299
* It returns the entry source file path if defined,

packages/app/src/cli/models/extensions/specifications/function.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,34 @@ describe('functionConfiguration', () => {
306306
})
307307
})
308308

309+
test('accepts configuration with typegen_command in build section', async () => {
310+
// Given
311+
const configWithTypegen = {
312+
name: 'function',
313+
type: 'function',
314+
metafields: [],
315+
description: 'my function',
316+
build: {
317+
command: 'zig build -Doptimize=ReleaseSmall',
318+
path: 'dist/index.wasm',
319+
wasm_opt: true,
320+
typegen_command: 'npx shopify-function-codegen --schema schema.graphql',
321+
},
322+
configuration_ui: false,
323+
api_version: '2022-07',
324+
}
325+
326+
// When
327+
const extension = await testFunctionExtension({
328+
dir: '/function',
329+
config: configWithTypegen as FunctionConfigType,
330+
})
331+
332+
// Then
333+
expect(extension.configuration.build?.typegen_command).toBe('npx shopify-function-codegen --schema schema.graphql')
334+
expect(extension.typegenCommand).toBe('npx shopify-function-codegen --schema schema.graphql')
335+
})
336+
309337
test('accepts configuration without build section', async () => {
310338
// Given
311339
const configWithoutBuild = {

packages/app/src/cli/models/extensions/specifications/function.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ const FunctionExtensionSchema = BaseSchema.extend({
2727
path: zod.string().optional(),
2828
watch: zod.union([zod.string(), zod.string().array()]).optional(),
2929
wasm_opt: zod.boolean().optional().default(true),
30+
typegen_command: zod
31+
.string()
32+
.transform((value) => (value.trim() === '' ? undefined : value))
33+
.optional(),
3034
})
3135
.optional(),
3236
name: zod.string(),

packages/app/src/cli/services/build/extension.test.ts

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {buildFunctionExtension} from './extension.js'
22
import {testFunctionExtension} from '../../models/app/app.test-data.js'
3-
import {buildJSFunction, runWasmOpt, runTrampoline} from '../function/build.js'
3+
import {buildGraphqlTypes, buildJSFunction, runWasmOpt, runTrampoline} from '../function/build.js'
44
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
55
import {FunctionConfigType} from '../../models/extensions/specifications/function.js'
66
import {beforeEach, describe, expect, test, vi} from 'vitest'
@@ -254,6 +254,128 @@ describe('buildFunctionExtension', () => {
254254
expect(runWasmOpt).not.toHaveBeenCalled()
255255
})
256256

257+
test('runs typegen_command before build for non-JS function', async () => {
258+
// Given
259+
const configWithTypegen = {
260+
name: 'MyFunction',
261+
type: 'product_discounts',
262+
description: '',
263+
build: {
264+
command: 'make build',
265+
path: 'dist/index.wasm',
266+
wasm_opt: true,
267+
typegen_command: 'npx shopify-function-codegen --schema schema.graphql',
268+
},
269+
configuration_ui: true,
270+
api_version: '2022-07',
271+
metafields: [],
272+
}
273+
extension = await testFunctionExtension({config: configWithTypegen})
274+
275+
// When
276+
await expect(
277+
buildFunctionExtension(extension, {
278+
stdout,
279+
stderr,
280+
signal,
281+
app,
282+
environment: 'production',
283+
}),
284+
).resolves.toBeUndefined()
285+
286+
// Then
287+
expect(buildGraphqlTypes).toHaveBeenCalledWith(extension, {
288+
stdout,
289+
stderr,
290+
signal,
291+
app,
292+
environment: 'production',
293+
})
294+
expect(exec).toHaveBeenCalledWith('make', ['build'], {
295+
stdout,
296+
stderr,
297+
cwd: extension.directory,
298+
signal,
299+
})
300+
})
301+
302+
test('runs typegen_command before build for JS function with custom build command', async () => {
303+
// Given
304+
const configWithTypegen = {
305+
name: 'MyFunction',
306+
type: 'product_discounts',
307+
description: '',
308+
build: {
309+
command: 'make build',
310+
path: 'dist/index.wasm',
311+
wasm_opt: true,
312+
typegen_command: 'custom-typegen --output types.ts',
313+
},
314+
configuration_ui: true,
315+
api_version: '2022-07',
316+
metafields: [],
317+
}
318+
extension = await testFunctionExtension({config: configWithTypegen, entryPath: 'src/index.js'})
319+
320+
// When
321+
await expect(
322+
buildFunctionExtension(extension, {
323+
stdout,
324+
stderr,
325+
signal,
326+
app,
327+
environment: 'production',
328+
}),
329+
).resolves.toBeUndefined()
330+
331+
// Then
332+
expect(buildGraphqlTypes).toHaveBeenCalledWith(extension, {
333+
stdout,
334+
stderr,
335+
signal,
336+
app,
337+
environment: 'production',
338+
})
339+
expect(exec).toHaveBeenCalledWith('make', ['build'], {
340+
stdout,
341+
stderr,
342+
cwd: extension.directory,
343+
signal,
344+
})
345+
})
346+
347+
test('does not run typegen when typegen_command is not set', async () => {
348+
// Given
349+
const configWithoutTypegen = {
350+
name: 'MyFunction',
351+
type: 'product_discounts',
352+
description: '',
353+
build: {
354+
command: 'make build',
355+
path: 'dist/index.wasm',
356+
wasm_opt: true,
357+
},
358+
configuration_ui: true,
359+
api_version: '2022-07',
360+
metafields: [],
361+
}
362+
extension = await testFunctionExtension({config: configWithoutTypegen})
363+
364+
// When
365+
await expect(
366+
buildFunctionExtension(extension, {
367+
stdout,
368+
stderr,
369+
signal,
370+
app,
371+
environment: 'production',
372+
}),
373+
).resolves.toBeUndefined()
374+
375+
// Then
376+
expect(buildGraphqlTypes).not.toHaveBeenCalled()
377+
})
378+
257379
test('handles function with build config but undefined path', async () => {
258380
// Given
259381
const configWithoutPath = {

packages/app/src/cli/services/build/extension.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {runThemeCheck} from './theme-check.js'
22
import {AppInterface} from '../../models/app/app.js'
33
import {bundleExtension} from '../extensions/bundle.js'
4-
import {buildJSFunction, runTrampoline, runWasmOpt} from '../function/build.js'
4+
import {buildGraphqlTypes, buildJSFunction, runTrampoline, runWasmOpt} from '../function/build.js'
55
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
66
import {FunctionConfigType} from '../../models/extensions/specifications/function.js'
77
import {exec} from '@shopify/cli-kit/node/system'
@@ -202,6 +202,9 @@ export async function bundleFunctionExtension(wasmPath: string, bundlePath: stri
202202

203203
async function runCommandOrBuildJSFunction(extension: ExtensionInstance, options: BuildFunctionExtensionOptions) {
204204
if (extension.buildCommand) {
205+
if (extension.typegenCommand) {
206+
await buildGraphqlTypes(extension, options)
207+
}
205208
return runCommand(extension.buildCommand, extension, options)
206209
} else {
207210
return buildJSFunction(extension as ExtensionInstance<FunctionConfigType>, options)
@@ -223,6 +226,9 @@ async function buildOtherFunction(extension: ExtensionInstance, options: BuildFu
223226
`)
224227
throw new AbortSilentError()
225228
}
229+
if (extension.typegenCommand) {
230+
await buildGraphqlTypes(extension, options)
231+
}
226232
return runCommand(extension.buildCommand, extension, options)
227233
}
228234

packages/app/src/cli/services/function/build.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ describe('buildGraphqlTypes', () => {
8585
})
8686
})
8787

88-
test('errors if function is not a JS function', async () => {
88+
test('errors if function is not a JS function and no typegen_command', async () => {
8989
// Given
9090
const ourFunction = await testFunctionExtension()
9191
ourFunction.entrySourceFilePath = 'src/main.rs'
@@ -96,6 +96,64 @@ describe('buildGraphqlTypes', () => {
9696
// Then
9797
await expect(got).rejects.toThrow(/GraphQL types can only be built for JavaScript functions/)
9898
})
99+
100+
test('runs custom typegen_command when provided', async () => {
101+
// Given
102+
const ourFunction = await testFunctionExtension({
103+
config: {
104+
name: 'test function',
105+
type: 'order_discounts',
106+
build: {
107+
command: 'zig build',
108+
wasm_opt: true,
109+
typegen_command: 'npx shopify-function-codegen --schema schema.graphql',
110+
},
111+
configuration_ui: true,
112+
api_version: '2024-01',
113+
},
114+
})
115+
ourFunction.entrySourceFilePath = 'src/main.rs'
116+
117+
// When
118+
const got = buildGraphqlTypes(ourFunction, {stdout, stderr, signal, app})
119+
120+
// Then
121+
await expect(got).resolves.toBeUndefined()
122+
expect(exec).toHaveBeenCalledWith('npx', ['shopify-function-codegen', '--schema', 'schema.graphql'], {
123+
cwd: ourFunction.directory,
124+
stderr,
125+
signal,
126+
})
127+
})
128+
129+
test('runs custom typegen_command for JS functions when provided', async () => {
130+
// Given
131+
const ourFunction = await testFunctionExtension({
132+
entryPath: 'src/index.js',
133+
config: {
134+
name: 'test function',
135+
type: 'order_discounts',
136+
build: {
137+
command: 'echo "hello"',
138+
wasm_opt: true,
139+
typegen_command: 'custom-typegen --output types.ts',
140+
},
141+
configuration_ui: true,
142+
api_version: '2024-01',
143+
},
144+
})
145+
146+
// When
147+
const got = buildGraphqlTypes(ourFunction, {stdout, stderr, signal, app})
148+
149+
// Then
150+
await expect(got).resolves.toBeUndefined()
151+
expect(exec).toHaveBeenCalledWith('custom-typegen', ['--output', 'types.ts'], {
152+
cwd: ourFunction.directory,
153+
stderr,
154+
signal,
155+
})
156+
})
99157
})
100158

101159
async function installShopifyLibrary(tmpDir: string) {

packages/app/src/cli/services/function/build.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,20 @@ async function buildJSFunctionWithTasks(
123123
}
124124

125125
export async function buildGraphqlTypes(
126-
fun: {directory: string; isJavaScript: boolean},
126+
fun: {directory: string; isJavaScript: boolean; typegenCommand?: string},
127127
options: JSFunctionBuildOptions,
128128
) {
129+
if (fun.typegenCommand) {
130+
const commandComponents = fun.typegenCommand.split(' ')
131+
return runWithTimer('cmd_all_timing_network_ms')(async () => {
132+
return exec(commandComponents[0]!, commandComponents.slice(1), {
133+
cwd: fun.directory,
134+
stderr: options.stderr,
135+
signal: options.signal,
136+
})
137+
})
138+
}
139+
129140
if (!fun.isJavaScript) {
130141
throw new AbortError('GraphQL types can only be built for JavaScript functions')
131142
}

0 commit comments

Comments
 (0)