Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,30 @@ describe('deployConfig', async () => {
expect(got).toMatchObject({theme_extension: {files: {}}})
})

test('passes app configuration context to deployConfig', async () => {
const extensionInstance = await testThemeExtensions()
const originalDeployConfig = extensionInstance.specification.deployConfig
const deployConfig = vi.fn().mockResolvedValue({theme_extension: {files: {}}})
extensionInstance.specification.deployConfig = deployConfig

try {
await extensionInstance.deployConfig({
apiKey: 'apiKey',
appConfiguration: placeholderAppConfiguration,
})

expect(deployConfig).toHaveBeenCalledWith(
extensionInstance.configuration,
extensionInstance.directory,
'apiKey',
undefined,
{appConfiguration: placeholderAppConfiguration},
)
} finally {
extensionInstance.specification.deployConfig = originalDeployConfig
}
})

test('returns transformed config when defined', async () => {
const extensionInstance = await testAppConfigExtensions()

Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,15 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
apiKey,
appConfiguration,
}: ExtensionDeployConfigOptions): Promise<{[key: string]: unknown} | undefined> {
const deployConfig = await this.specification.deployConfig?.(this.configuration, this.directory, apiKey, undefined)
const deployConfig = await this.specification.deployConfig?.(
this.configuration,
this.directory,
apiKey,
undefined,
{
appConfiguration,
},
)
const transformedConfig = this.specification.transformLocalToRemote?.(this.configuration, appConfiguration) as
| {[key: string]: unknown}
| undefined
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export interface BuildAsset {
static?: boolean
}

interface ExtensionDeployConfigContext {
appConfiguration: AppConfiguration
}

/**
* Extension specification with all the needed properties and methods to load an extension.
*/
Expand All @@ -80,6 +84,7 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
directory: string,
apiKey: string,
moduleId?: string,
context?: ExtensionDeployConfigContext,
) => Promise<Record<string, unknown> | undefined>
validate?: (config: TConfiguration, configPath: string, directory: string) => Promise<Result<unknown, string>>
preDeployValidation?: (extension: ExtensionInstance<TConfiguration>) => Promise<void>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {placeholderAppConfiguration, testFlowActionExtension} from '../../app/app.test-data.js'
import {ExtensionInstance} from '../extension-instance.js'
import {BaseConfigType} from '../schemas.js'
import {ApplicationURLs} from '../../../services/dev/urls.js'
import {beforeEach, describe, expect, test} from 'vitest'

type FlowActionConfig = BaseConfigType & {
type: 'flow_action'
handle: string
name: string
runtime_url: string
validation_url?: string
config_page_url?: string
config_page_preview_url?: string
}

const tunnelUrls: ApplicationURLs = {
applicationUrl: 'https://my-tunnel.example.com',
redirectUrlWhitelist: [],
}

const urlFields = ['runtime_url', 'validation_url', 'config_page_url', 'config_page_preview_url'] as const

describe('FlowActionExtension', () => {
let extension: ExtensionInstance<FlowActionConfig>

const config: FlowActionConfig = {
type: 'flow_action',
handle: 'place-bid',
name: 'Place auction bid',
description: 'Place a bid on an auction',
runtime_url: '/api/execute',
validation_url: '/api/validate',
config_page_url: '/config',
config_page_preview_url: '/config/preview',
}

beforeEach(async () => {
extension = (await testFlowActionExtension()) as ExtensionInstance<FlowActionConfig>
extension.configuration = {...config}
})

test('accepts an absolute https runtime_url', () => {
// When
const parsed = extension.specification.parseConfigurationObject({
...config,
runtime_url: 'https://example.com/api/execute',
})

// Then
expect(parsed.state).toBe('ok')
})

test('accepts a relative runtime_url starting with /', () => {
// When
const parsed = extension.specification.parseConfigurationObject(config)

// Then
expect(parsed.state).toBe('ok')
})

test('rejects a non-https absolute runtime_url', () => {
// When
const parsed = extension.specification.parseConfigurationObject({
...config,
runtime_url: 'http://example.com/api/execute',
})

// Then
expect(parsed.state).toBe('error')
})

test('preserves absolute URLs and prepends the app URL to relative URLs in the deploy configuration', async () => {
// Given
extension.configuration = {
...extension.configuration,
runtime_url: '/api/execute',
validation_url: 'https://my-app.example.com/api/validate',
config_page_url: '/config',
config_page_preview_url: 'https://my-app.example.com/config/preview',
}

// When
const got = await extension.deployConfig({
apiKey: 'api-key',
appConfiguration: {
...placeholderAppConfiguration,
application_url: 'https://my-app.example.com',
},
})

// Then
expect(got).toEqual({
title: extension.configuration.name,
description: extension.configuration.description,
url: 'https://my-app.example.com/api/execute',
fields: [],
validation_url: 'https://my-app.example.com/api/validate',
custom_configuration_page_url: 'https://my-app.example.com/config',
custom_configuration_page_preview_url: 'https://my-app.example.com/config/preview',
schema_patch: '',
return_type_ref: undefined,
})
})

test.each(urlFields)('throws when deploying a relative %s without an app URL', async (field) => {
// Given
extension.configuration = {
...extension.configuration,
runtime_url: 'https://my-prod-host.example.com/api/execute',
validation_url: 'https://my-prod-host.example.com/api/validate',
config_page_url: 'https://my-prod-host.example.com/config',
config_page_preview_url: 'https://my-prod-host.example.com/config/preview',
}
extension.configuration[field] = `/${field}`

// When/Then
await expect(
extension.deployConfig({
apiKey: 'api-key',
appConfiguration: placeholderAppConfiguration,
}),
).rejects.toThrow(
`Flow action ${field} is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.`,
)
})

test('prepends the dev application URL to relative URL fields', () => {
// When
extension.patchWithAppDevURLs(tunnelUrls)

// Then
expect(extension.configuration.runtime_url).toBe('https://my-tunnel.example.com/api/execute')
expect(extension.configuration.validation_url).toBe('https://my-tunnel.example.com/api/validate')
expect(extension.configuration.config_page_url).toBe('https://my-tunnel.example.com/config')
expect(extension.configuration.config_page_preview_url).toBe('https://my-tunnel.example.com/config/preview')
})

test('leaves absolute dev URLs untouched', () => {
// Given
extension.configuration.runtime_url = 'https://my-prod-host.example.com/api/execute'
extension.configuration.validation_url = undefined
extension.configuration.config_page_url = undefined
extension.configuration.config_page_preview_url = undefined

// When
extension.patchWithAppDevURLs(tunnelUrls)

// Then
expect(extension.configuration.runtime_url).toBe('https://my-prod-host.example.com/api/execute')
expect(extension.configuration.validation_url).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
// import {prependApplicationUrl} from './validation/url_prepender.js'
import {BaseSchemaWithHandle} from '../schemas.js'
import {createExtensionSpecification} from '../specification.js'
import {validateRelativeUrl} from '../../app/validation/common.js'
import {
validateFieldShape,
startsWithHttps,
validateCustomConfigurationPageConfig,
validateReturnTypeConfig,
} from '../../../services/flow/validation.js'
import {serializeFields} from '../../../services/flow/serialize-fields.js'
import {loadSchemaFromPath} from '../../../services/flow/utils.js'
import {loadSchemaFromPath, resolveFlowActionUrl} from '../../../services/flow/utils.js'
import {zod} from '@shopify/cli-kit/node/schema'

const RELATIVE_URL_FIELDS = ['runtime_url', 'validation_url', 'config_page_url', 'config_page_preview_url'] as const

const FlowActionExtensionSchema = BaseSchemaWithHandle.extend({
type: zod.literal('flow_action'),
name: zod.string(),
runtime_url: zod.string().url().refine(startsWithHttps),
validation_url: zod.string().url().refine(startsWithHttps).optional(),
config_page_url: zod.string().url().refine(startsWithHttps).optional(),
config_page_preview_url: zod.string().url().refine(startsWithHttps).optional(),
runtime_url: validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})),
validation_url: validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(),
config_page_url: validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(),
config_page_preview_url: validateRelativeUrl(zod.string({invalid_type_error: 'Value must be string'})).optional(),
schema: zod.string().optional(),
return_type_ref: zod.string().optional(),
}).refine((config) => {
Expand Down Expand Up @@ -45,15 +48,38 @@ const flowActionSpecification = createExtensionSpecification({
// https://github.com/Shopify/cli/blob/73ac91c0f40be0a57d1b18cb34254b12d3a071af/packages/app/src/cli/services/deploy.ts#L107
// Should be removed after unified deployment is 100% rolled out
appModuleFeatures: (_) => [],
deployConfig: async (config, extensionPath) => {
/**
* During `app dev`, swap any relative URLs (starting with `/`) for the dev
* tunnel URL the CLI assigned. This lets developers write
* `runtime_url = "/api/execute"` in their TOML and have it resolved against
* the tunnel automatically — the same pattern app_proxy, webhooks, and
* events subscriptions already use.
*
*/
patchWithAppDevURLs: (config, urls) => {
for (const key of RELATIVE_URL_FIELDS) {
const value = config[key]
if (typeof value === 'string' && value.startsWith('/')) {
config[key] = resolveFlowActionUrl(key, value, urls.applicationUrl) ?? ''
}
}
},
deployConfig: async (config, extensionPath, _apiKey, _moduleId, context) => {
const appConfiguration = context?.appConfiguration
const appUrl = typeof appConfiguration?.application_url === 'string' ? appConfiguration.application_url : undefined

return {
title: config.name,
description: config.description,
url: config.runtime_url,
url: resolveFlowActionUrl('runtime_url', config.runtime_url, appUrl),
fields: serializeFields('flow_action', config.settings?.fields),
validation_url: config.validation_url,
custom_configuration_page_url: config.config_page_url,
custom_configuration_page_preview_url: config.config_page_preview_url,
validation_url: resolveFlowActionUrl('validation_url', config.validation_url, appUrl),
custom_configuration_page_url: resolveFlowActionUrl('config_page_url', config.config_page_url, appUrl),
custom_configuration_page_preview_url: resolveFlowActionUrl(
'config_page_preview_url',
config.config_page_preview_url,
appUrl,
),
schema_patch: await loadSchemaFromPath(extensionPath, config.schema),
return_type_ref: config.return_type_ref,
}
Expand Down
38 changes: 37 additions & 1 deletion packages/app/src/cli/services/flow/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,44 @@
import {loadSchemaFromPath} from './utils.js'
import {loadSchemaFromPath, resolveFlowActionUrl} from './utils.js'
import {describe, expect, test} from 'vitest'
import {readFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'

describe('resolveFlowActionUrl', () => {
test('returns undefined when the URL is not configured', () => {
expect(resolveFlowActionUrl('validation_url', undefined, 'https://my-app.example.com')).toBeUndefined()
})

test('returns absolute URLs unchanged', () => {
expect(
resolveFlowActionUrl('runtime_url', 'https://my-prod-host.example.com/api/execute', 'https://my-app.example.com'),
).toBe('https://my-prod-host.example.com/api/execute')
})

test('prepends the app URL to relative URLs', () => {
expect(resolveFlowActionUrl('runtime_url', '/api/execute', 'https://my-app.example.com/')).toBe(
'https://my-app.example.com/api/execute',
)
})

test('throws when a relative URL cannot be resolved without an app URL', () => {
expect(() => resolveFlowActionUrl('runtime_url', '/api/execute', undefined)).toThrow(
'Flow action runtime_url is a relative URL, but no application_url is configured. Set application_url in your app configuration or use an absolute HTTPS URL.',
)
})

test('throws when an absolute URL is not HTTPS', () => {
expect(() => resolveFlowActionUrl('runtime_url', 'http://my-prod-host.example.com/api/execute', undefined)).toThrow(
'Flow action runtime_url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
})

test('throws when a relative URL resolves against a non-HTTPS app URL', () => {
expect(() => resolveFlowActionUrl('runtime_url', '/api/execute', 'http://my-app.example.com')).toThrow(
'Flow action runtime_url must resolve to an HTTPS URL. Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
})
})

describe('loadSchemaFromPath', () => {
test('loading schema from valid file path should return file contents', async () => {
const extensionPath = __dirname.concat('/fixtures')
Expand Down
23 changes: 23 additions & 0 deletions packages/app/src/cli/services/flow/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
import {prependApplicationUrl} from '../../models/extensions/specifications/validation/url_prepender.js'
import {joinPath} from '@shopify/cli-kit/node/path'
import {glob, readFile} from '@shopify/cli-kit/node/fs'
import {AbortError} from '@shopify/cli-kit/node/error'

/**
* Loads the schema from the partner defined file.
*/
export const resolveFlowActionUrl = (fieldName: string, url: string | undefined, appUrl: string | undefined) => {
if (!url) return undefined

const resolvedUrl = prependApplicationUrl(url, appUrl)
if (resolvedUrl.startsWith('/')) {
throw new AbortError(
`Flow action ${fieldName} is a relative URL, but no application_url is configured. ` +
'Set application_url in your app configuration or use an absolute HTTPS URL.',
)
}

if (!resolvedUrl.startsWith('https://')) {
throw new AbortError(
`Flow action ${fieldName} must resolve to an HTTPS URL. ` +
'Set application_url to an HTTPS URL or use an absolute HTTPS URL.',
)
}

return resolvedUrl
}

export const loadSchemaFromPath = async (extensionPath: string, patchPath: string | undefined) => {
if (!patchPath) {
return ''
Expand Down
2 changes: 0 additions & 2 deletions packages/app/src/cli/services/flow/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ export const validateFieldShape = (
return baseFieldSchema.parse(configField)
}

export const startsWithHttps = (url: string) => url.startsWith('https://')

export const isSchemaTypeReference = (type: string) => type.startsWith('schema.')

export const validateCustomConfigurationPageConfig = (
Expand Down
Loading