Skip to content

Commit 6f5a44e

Browse files
committed
Generate types for ui-extension intents
1 parent d5b7839 commit 6f5a44e

4 files changed

Lines changed: 498 additions & 16 deletions

File tree

packages/app/src/cli/models/extensions/specifications/type-generation.test.ts

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,141 @@
1-
import {createToolsTypeDefinition} from './type-generation.js'
1+
import {createIntentsTypeDefinition, createToolsTypeDefinition} from './type-generation.js'
22
import {AbortError} from '@shopify/cli-kit/node/error'
33
import {describe, expect, test} from 'vitest'
44

5+
describe('createIntentsTypeDefinition', () => {
6+
test('returns empty string when intents array is empty', async () => {
7+
// When
8+
const result = await createIntentsTypeDefinition([])
9+
10+
// Then
11+
expect(result).toBe('')
12+
})
13+
14+
test('generates request and response types for a single intent schema', async () => {
15+
// Given
16+
const intents = [
17+
{
18+
action: 'create',
19+
type: 'application/email',
20+
inputSchema: {
21+
type: 'object',
22+
properties: {
23+
recipient: {type: 'string'},
24+
},
25+
required: ['recipient'],
26+
},
27+
outputSchema: {
28+
type: 'object',
29+
properties: {
30+
success: {type: 'boolean'},
31+
},
32+
},
33+
},
34+
]
35+
36+
// When
37+
const result = await createIntentsTypeDefinition(intents)
38+
39+
// Then
40+
expect(result).toBe(`interface CreateApplicationEmailIntentInput {
41+
recipient: string;
42+
[k: string]: unknown;
43+
}
44+
45+
type CreateApplicationEmailIntentValue = unknown
46+
interface CreateApplicationEmailIntentOutput {
47+
success?: boolean;
48+
[k: string]: unknown;
49+
}
50+
51+
interface CreateApplicationEmailIntentRequest {
52+
action: "create";
53+
type: "application/email";
54+
data: CreateApplicationEmailIntentInput;
55+
value?: CreateApplicationEmailIntentValue;
56+
}
57+
58+
type ShopifyGeneratedIntentResponse<Data = unknown> = {
59+
ok(data?: Data): Promise<void>;
60+
}
61+
62+
type ShopifyGeneratedIntentsApi =
63+
| {
64+
request: CreateApplicationEmailIntentRequest;
65+
response?: ShopifyGeneratedIntentResponse<CreateApplicationEmailIntentOutput>;
66+
}
67+
`)
68+
})
69+
70+
test('supports multiple intents with value schemas', async () => {
71+
// Given
72+
const intents = [
73+
{
74+
action: 'create',
75+
type: 'application/email',
76+
inputSchema: {
77+
type: 'object',
78+
properties: {
79+
recipient: {type: 'string'},
80+
},
81+
},
82+
},
83+
{
84+
action: 'edit',
85+
type: 'shopify/Product',
86+
valueSchema: {
87+
type: 'string',
88+
},
89+
inputSchema: {
90+
type: 'object',
91+
properties: {
92+
title: {type: 'string'},
93+
},
94+
},
95+
outputSchema: {
96+
type: 'object',
97+
properties: {
98+
id: {type: 'string'},
99+
},
100+
},
101+
},
102+
]
103+
104+
// When
105+
const result = await createIntentsTypeDefinition(intents)
106+
107+
// Then
108+
expect(result).toContain('interface CreateApplicationEmailIntentRequest')
109+
expect(result).toContain('type CreateApplicationEmailIntentOutput = unknown')
110+
expect(result).toContain('interface EditShopifyProductIntentRequest')
111+
expect(result).toContain('type EditShopifyProductIntentValue = string')
112+
expect(result).toContain('response?: ShopifyGeneratedIntentResponse<EditShopifyProductIntentOutput>;')
113+
})
114+
115+
test('throws AbortError when intent action/type pairs are duplicated', async () => {
116+
// Given
117+
const intents = [
118+
{
119+
action: 'create',
120+
type: 'application/email',
121+
inputSchema: {type: 'object'},
122+
},
123+
{
124+
action: 'create',
125+
type: 'application/email',
126+
inputSchema: {type: 'object'},
127+
},
128+
]
129+
130+
// When & Then
131+
await expect(createIntentsTypeDefinition(intents)).rejects.toThrow(
132+
new AbortError(
133+
'Intent "create:application/email" is defined multiple times. Intents must be unique within a target.',
134+
),
135+
)
136+
})
137+
})
138+
5139
describe('createToolsTypeDefinition', () => {
6140
test('returns empty string when tools array is empty', async () => {
7141
// When

packages/app/src/cli/models/extensions/specifications/type-generation.ts

Lines changed: 163 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -163,34 +163,85 @@ interface CreateTypeDefinitionOptions {
163163
targets: string[]
164164
apiVersion: string
165165
toolsTypeDefinition?: string
166+
intentsTypeDefinition?: string
166167
}
167168

168-
/**
169-
* Builds the shopify API type based on targets and optional tools type.
170-
* Returns null if no targets are provided.
171-
*/
172-
function buildShopifyType(targets: string[], toolsTypeDefinition?: string): string | null {
173-
const toolsSuffix = toolsTypeDefinition ? ' & { tools: ShopifyTools }' : ''
169+
interface ShopifyTypeOptions {
170+
includesTools: boolean
171+
includesIntents: boolean
172+
}
174173

174+
function buildBaseShopifyType(targets: string[]): string | null {
175175
if (targets.length === 1) {
176176
const target = targets[0] ?? ''
177-
return `import('@shopify/ui-extensions/${target}').Api${toolsSuffix}`
177+
return `import('@shopify/ui-extensions/${target}').Api`
178178
}
179179

180180
if (targets.length > 1) {
181181
const unionType = targets.map((target) => `import('@shopify/ui-extensions/${target}').Api`).join(' | ')
182-
return `(${unionType})${toolsSuffix}`
182+
return `(${unionType})`
183183
}
184184

185185
return null
186186
}
187187

188+
/**
189+
* Builds the shopify API type based on targets and optional generated tool / intent types.
190+
* Returns null if no targets are provided.
191+
*/
192+
function buildShopifyType(targets: string[], {includesTools, includesIntents}: ShopifyTypeOptions): string | null {
193+
const baseShopifyType = buildBaseShopifyType(targets)
194+
if (!baseShopifyType) return null
195+
196+
if (!includesTools && !includesIntents) {
197+
return baseShopifyType
198+
}
199+
200+
const wrappers = [
201+
...(includesIntents ? ['WithGeneratedIntents'] : []),
202+
...(includesTools ? ['WithGeneratedTools'] : []),
203+
]
204+
205+
return wrappers.reduce((shopifyType, wrapper) => `${wrapper}<${shopifyType}>`, baseShopifyType)
206+
}
207+
208+
function buildShopifyUtilityTypes({includesTools, includesIntents}: ShopifyTypeOptions): string {
209+
const utilityTypes: string[] = []
210+
211+
if (includesTools) {
212+
utilityTypes.push(`type WithGeneratedTools<T> = T extends {tools?: infer Tools}
213+
? Omit<T, 'tools'> & {tools: Omit<NonNullable<Tools>, 'register'> & ShopifyTools}
214+
: T & {tools: ShopifyTools}`)
215+
}
216+
217+
if (includesIntents) {
218+
utilityTypes.push(`type MergeGeneratedIntentResponse<Intents> = ShopifyGeneratedIntentsApi extends infer Generated
219+
? Generated extends {response?: infer GeneratedResponse}
220+
? Omit<Generated, 'response'> & {
221+
response?: Intents extends {response?: infer BaseResponse}
222+
? Omit<NonNullable<BaseResponse>, 'ok'> & NonNullable<GeneratedResponse>
223+
: NonNullable<GeneratedResponse>
224+
}
225+
: Generated
226+
: never`)
227+
228+
utilityTypes.push(`type WithGeneratedIntents<T> = T extends {intents?: infer Intents}
229+
? Omit<T, 'intents'> & {
230+
intents: Omit<NonNullable<Intents>, 'request' | 'response'> & MergeGeneratedIntentResponse<NonNullable<Intents>>
231+
}
232+
: T & {intents: ShopifyGeneratedIntentsApi}`)
233+
}
234+
235+
return utilityTypes.join('\n\n')
236+
}
237+
188238
export function createTypeDefinition({
189239
fullPath,
190240
typeFilePath,
191241
targets,
192242
apiVersion,
193243
toolsTypeDefinition,
244+
intentsTypeDefinition,
194245
}: CreateTypeDefinitionOptions): string | null {
195246
try {
196247
// Validate that all targets can be resolved
@@ -208,14 +259,20 @@ export function createTypeDefinition({
208259
}
209260

210261
const relativePath = relativizePath(fullPath, dirname(typeFilePath))
262+
const includesTools = Boolean(toolsTypeDefinition)
263+
const includesIntents = Boolean(intentsTypeDefinition)
211264

212-
const shopifyType = buildShopifyType(targets, toolsTypeDefinition)
265+
const shopifyType = buildShopifyType(targets, {includesTools, includesIntents})
213266
if (!shopifyType) return null
214267

268+
const shopifyUtilityTypes = buildShopifyUtilityTypes({includesTools, includesIntents})
269+
215270
const lines = [
216271
'//@ts-ignore',
217272
`declare module './${relativePath}' {`,
218-
...(toolsTypeDefinition ? [` ${toolsTypeDefinition}`] : []),
273+
...(toolsTypeDefinition ? [toolsTypeDefinition] : []),
274+
...(intentsTypeDefinition ? [intentsTypeDefinition] : []),
275+
...(shopifyUtilityTypes ? [shopifyUtilityTypes] : []),
219276
` const shopify: ${shopifyType};`,
220277
' const globalThis: { shopify: typeof shopify };',
221278
'}',
@@ -269,6 +326,92 @@ const ToolDefinitionSchema: zod.ZodType<ToolDefinition> = zod.object({
269326

270327
export const ToolsFileSchema = zod.array(ToolDefinitionSchema)
271328

329+
interface IntentTypeDefinition {
330+
action: string
331+
type: string
332+
inputSchema: object
333+
valueSchema?: object
334+
outputSchema?: object
335+
}
336+
337+
interface IntentSchemaFile {
338+
value?: object
339+
inputSchema: object
340+
outputSchema?: object
341+
}
342+
343+
export const IntentSchemaFileSchema: zod.ZodType<IntentSchemaFile> = zod.object({
344+
value: zod.object({}).passthrough().optional(),
345+
inputSchema: zod.object({}).passthrough(),
346+
outputSchema: zod.object({}).passthrough().optional(),
347+
})
348+
349+
function intentTypeBaseName(intent: Pick<IntentTypeDefinition, 'action' | 'type'>): string {
350+
return pascalize(`${intent.action} ${intent.type}`.replace(/[^a-zA-Z0-9]+/g, ' '))
351+
}
352+
353+
/**
354+
* Generates TypeScript types for shopify.intents.request and shopify.intents.response.ok
355+
* based on intent schema definitions.
356+
*/
357+
export async function createIntentsTypeDefinition(intents: IntentTypeDefinition[]): Promise<string> {
358+
if (intents.length === 0) return ''
359+
360+
const intentKeys = new Set<string>()
361+
const typePromises = intents.map(async (intent) => {
362+
const intentKey = `${intent.action}:${intent.type}`
363+
if (intentKeys.has(intentKey)) {
364+
throw new AbortError(`Intent "${intentKey}" is defined multiple times. Intents must be unique within a target.`)
365+
}
366+
intentKeys.add(intentKey)
367+
368+
const typeBaseName = intentTypeBaseName(intent)
369+
const inputTypeName = `${typeBaseName}IntentInput`
370+
const valueTypeName = `${typeBaseName}IntentValue`
371+
const outputTypeName = `${typeBaseName}IntentOutput`
372+
const requestTypeName = `${typeBaseName}IntentRequest`
373+
374+
const inputType = await formatJsonSchemaType(inputTypeName, intent.inputSchema)
375+
const valueType = await formatJsonSchemaType(valueTypeName, intent.valueSchema)
376+
const outputType = await formatJsonSchemaType(outputTypeName, intent.outputSchema)
377+
378+
const requestType = `interface ${requestTypeName} {
379+
action: ${JSON.stringify(intent.action)};
380+
type: ${JSON.stringify(intent.type)};
381+
data: ${inputTypeName};
382+
value?: ${valueTypeName};
383+
}`
384+
385+
return {
386+
inputType,
387+
valueType,
388+
outputType,
389+
requestType,
390+
requestTypeName,
391+
outputTypeName,
392+
}
393+
})
394+
395+
const types = await Promise.all(typePromises)
396+
397+
const generatedIntents = types
398+
.map(({requestTypeName, outputTypeName}) => {
399+
return ` | {
400+
request: ${requestTypeName};
401+
response?: ShopifyGeneratedIntentResponse<${outputTypeName}>;
402+
}`
403+
})
404+
.join('\n')
405+
406+
return `${types
407+
.map(
408+
({inputType, valueType, outputType, requestType}) => `${inputType}\n${valueType}\n${outputType}\n${requestType}`,
409+
)
410+
.join('\n\n')}\n\ntype ShopifyGeneratedIntentResponse<Data = unknown> = {
411+
ok(data?: Data): Promise<void>;
412+
}\n\ntype ShopifyGeneratedIntentsApi =\n${generatedIntents}\n`
413+
}
414+
272415
/**
273416
* Generates TypeScript types for shopify.tools.register based on tool definitions
274417
* @param tools - Array of tool definitions from tools.json
@@ -323,8 +466,15 @@ export async function createToolsTypeDefinition(tools: ToolDefinition[]): Promis
323466
.join('\n')}\ninterface ShopifyTools {\n${toolRegistrations}\n}\n`
324467
}
325468

469+
function renameGeneratedType(typeDefinition: string, name: string): string {
470+
return typeDefinition.replace(/^(interface|type|enum)\s+[A-Za-z0-9_]+/, `$1 ${name}`)
471+
}
472+
326473
async function formatJsonSchemaType(name: string, schema?: object): Promise<string> {
327-
const outputType = schema ? await compile(schema, name, {bannerComment: ''}) : `type ${name} = unknown`
328-
// The json-schema-to-typescript library adds an export keyword to the type definition, we need to remove it
329-
return outputType.startsWith('export ') ? outputType.slice(7) : outputType
474+
if (!schema) return `type ${name} = unknown`
475+
476+
const outputType = await compile(schema, name, {bannerComment: ''})
477+
const normalizedOutputType = outputType.startsWith('export ') ? outputType.slice(7) : outputType
478+
479+
return renameGeneratedType(normalizedOutputType, name)
330480
}

0 commit comments

Comments
 (0)