Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quick-functions-info.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Document and validate the `app function info --json` result contract.
11 changes: 11 additions & 0 deletions packages/app/src/cli/commands/app/function/info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import FunctionInfo from './info.js'
import {describe, expect, test} from 'vitest'

describe('FunctionInfo', () => {
test('includes the JSON result type in its help description', () => {
expect(FunctionInfo.descriptionWithMarkdown).toContain(
'With `--json`, the command returns `FunctionInfoResult`, described by these TypeScript types:',
)
expect(FunctionInfo.descriptionWithMarkdown).toContain('targeting: Record<string, FunctionTargeting>')
})
})
17 changes: 7 additions & 10 deletions packages/app/src/cli/commands/app/function/info.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import {chooseFunction, functionFlags, getOrGenerateSchemaPath} from '../../../services/function/common.js'
import {functionRunnerBinary, downloadBinary} from '../../../services/function/binaries.js'
import {functionInfo} from '../../../services/function/info.js'
import {presentFunctionInfoResult} from '../../../services/function/info-result.js'
import {functionInfoJsonOutputSchema} from '../../../services/function/info-types.js'
import {localAppContext} from '../../../services/app-context.js'
import {appFlags} from '../../../flags.js'
import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../../utilities/app-unlinked-command.js'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {outputResult} from '@shopify/cli-kit/node/output'
import {AlertCustomSection, renderInfo} from '@shopify/cli-kit/node/ui'

export default class FunctionInfo extends AppUnlinkedCommand {
static summary = 'Print basic information about your function.'

static get jsonOutputSchema() {
return functionInfoJsonOutputSchema
}

static descriptionWithMarkdown = `The information returned includes the following:

- The function handle
Expand Down Expand Up @@ -51,18 +55,11 @@ export default class FunctionInfo extends AppUnlinkedCommand {
)

const result = functionInfo(ourFunction, {
format: flags.json ? 'json' : 'text',
functionRunnerPath: functionRunner.path,
schemaPath,
})

if (flags.json) {
outputResult(result as string)
} else {
renderInfo({
customSections: result as AlertCustomSection[],
})
}
presentFunctionInfoResult(result, flags.json ? 'json' : 'text')

return {app}
}
Expand Down
81 changes: 81 additions & 0 deletions packages/app/src/cli/services/function/info-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
buildBuildSection,
buildConfigurationSection,
buildTargetingSection,
buildTextFormatSections,
encodeFunctionInfoJson,
} from './info-result.js'
import {type FunctionInfoResult} from './info-types.js'
import {describe, expect, test} from 'vitest'

const result: FunctionInfoResult = {
handle: 'my-function',
name: 'My Function',
apiVersion: '2024-01',
targeting: {
'purchase.payment-customization.run': {
inputQueryPath: '/path/to/function/query.graphql',
export: 'run',
},
},
schemaPath: '/path/to/schema.graphql',
wasmPath: '/path/to/function.wasm',
functionRunnerPath: '/path/to/runner',
}

describe('function info result presentation', () => {
test('encodes a JSON document that matches the declared schema', () => {
expect(JSON.parse(encodeFunctionInfoJson(result))).toEqual(result)
})

test('omits absent optional fields from the JSON document', () => {
const requiredResult: FunctionInfoResult = {
name: result.name,
targeting: result.targeting,
wasmPath: result.wasmPath,
functionRunnerPath: result.functionRunnerPath,
}

expect(JSON.parse(encodeFunctionInfoJson(requiredResult))).toEqual(requiredResult)
})

test('builds configuration rows from the result', () => {
expect(buildConfigurationSection(result).body).toMatchObject({
tabularData: [
['Handle', 'my-function'],
['Name', 'My Function'],
['API Version', '2024-01'],
],
firstColumnSubdued: true,
})
})

test('builds targeting rows from the result', () => {
const section = buildTargetingSection(result.targeting)

expect(section?.title).toBe('\nTARGETING\n')
expect((section?.body as {tabularData: unknown[][]}).tabularData).toHaveLength(3)
})

test('omits the targeting section when there are no targets', () => {
expect(buildTargetingSection({})).toBeUndefined()
})

test('builds path rows from the result', () => {
expect(buildBuildSection(result).body).toMatchObject({
tabularData: [
['Schema Path', {filePath: '/path/to/schema.graphql'}],
['Wasm Path', {filePath: '/path/to/function.wasm'}],
],
})
})

test('builds every text section', () => {
expect(buildTextFormatSections(result).map((section) => section.title)).toEqual([
'CONFIGURATION\n',
'\nTARGETING\n',
'\nBUILD\n',
'\nFUNCTION RUNNER\n',
])
})
})
85 changes: 85 additions & 0 deletions packages/app/src/cli/services/function/info-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {functionInfoJsonOutputSchema, type FunctionInfoResult, type FunctionTargeting} from './info-types.js'
import {outputContent, outputResult, outputToken} from '@shopify/cli-kit/node/output'
import {renderInfo, type AlertCustomSection, type InlineToken} from '@shopify/cli-kit/node/ui'

type FunctionInfoOutputFormat = 'json' | 'text'

export function presentFunctionInfoResult(result: FunctionInfoResult, format: FunctionInfoOutputFormat): void {
if (format === 'json') {
outputResult(encodeFunctionInfoJson(result))
return
}

renderInfo({customSections: buildTextFormatSections(result)})
}

export function encodeFunctionInfoJson(result: FunctionInfoResult): string {
return JSON.stringify(functionInfoJsonOutputSchema.schema.parse(result), null, 2)
}

export function buildConfigurationSection(result: FunctionInfoResult): AlertCustomSection {
return {
title: 'CONFIGURATION\n',
body: {
tabularData: [
['Handle', result.handle ?? 'N/A'],
['Name', result.name],
['API Version', result.apiVersion ?? 'N/A'],
],
firstColumnSubdued: true,
},
}
}

export function buildTargetingSection(targeting: Record<string, FunctionTargeting>): AlertCustomSection | undefined {
if (Object.keys(targeting).length === 0) return undefined

const targetingData: InlineToken[][] = []
Object.entries(targeting).forEach(([target, config]) => {
targetingData.push([outputContent`${outputToken.cyan(target)}`.value, ''])
if (config.inputQueryPath) {
targetingData.push([{subdued: ' Input Query Path'}, {filePath: config.inputQueryPath}])
}
if (config.export) {
targetingData.push([{subdued: ' Export'}, config.export])
}
})

return {
title: '\nTARGETING\n',
body: {tabularData: targetingData},
}
}

export function buildBuildSection(result: FunctionInfoResult): AlertCustomSection {
return {
title: '\nBUILD\n',
body: {
tabularData: [
['Schema Path', {filePath: result.schemaPath ?? 'N/A'}],
['Wasm Path', {filePath: result.wasmPath}],
],
firstColumnSubdued: true,
},
}
}

function buildFunctionRunnerSection(functionRunnerPath: string): AlertCustomSection {
return {
title: '\nFUNCTION RUNNER\n',
body: {
tabularData: [['Path', {filePath: functionRunnerPath}]],
firstColumnSubdued: true,
},
}
}

export function buildTextFormatSections(result: FunctionInfoResult): AlertCustomSection[] {
const sections = [buildConfigurationSection(result)]
const targetingSection = buildTargetingSection(result.targeting)

if (targetingSection) sections.push(targetingSection)

sections.push(buildBuildSection(result), buildFunctionRunnerSection(result.functionRunnerPath))
return sections
}
38 changes: 38 additions & 0 deletions packages/app/src/cli/services/function/info-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {functionInfoJsonOutputSchema} from './info-types.js'
import {renderJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
import {describe, expect, test} from 'vitest'

describe('functionInfoJsonOutputSchema', () => {
test('accepts the function info JSON result', () => {
expect(
functionInfoJsonOutputSchema.schema.safeParse({
name: 'My Function',
targeting: {
'purchase.payment-customization.run': {
inputQueryPath: '/path/to/query.graphql',
export: 'run',
},
},
wasmPath: '/path/to/function.wasm',
functionRunnerPath: '/path/to/runner',
}).success,
).toBe(true)
})

test('renders the named result and targeting types', () => {
expect(renderJsonOutputSchema(functionInfoJsonOutputSchema)).toBe(`interface FunctionInfoResult {
handle?: string
name: string
apiVersion?: string
targeting: Record<string, FunctionTargeting>
schemaPath?: string
wasmPath: string
functionRunnerPath: string
}

interface FunctionTargeting {
inputQueryPath?: string
export?: string
}`)
})
})
28 changes: 28 additions & 0 deletions packages/app/src/cli/services/function/info-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
import {zod} from '@shopify/cli-kit/node/schema'

export const FunctionTargetingSchema = zod.object({
inputQueryPath: zod.string().optional(),
export: zod.string().optional(),
})

const FunctionInfoResultSchema = zod.object({
handle: zod.string().optional(),
name: zod.string(),
apiVersion: zod.string().optional(),
targeting: zod.record(FunctionTargetingSchema),
schemaPath: zod.string().optional(),
wasmPath: zod.string(),
functionRunnerPath: zod.string(),
})

export const functionInfoJsonOutputSchema = defineJsonOutputSchema({
name: 'FunctionInfoResult',
schema: FunctionInfoResultSchema,
definitions: {
FunctionTargeting: FunctionTargetingSchema,
},
})

export type FunctionTargeting = zod.infer<typeof FunctionTargetingSchema>
export type FunctionInfoResult = InferJsonOutputSchema<typeof functionInfoJsonOutputSchema>
Loading
Loading