diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index a0a0222f9f..82ed6cf2ec 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2768,9 +2768,8 @@ describe('WebhooksSchema', () => { ], } const errorObj = { - validation: 'regex' as zod.ZodInvalidStringIssue['validation'], - code: zod.ZodIssueCode.invalid_string, - message: "URI isn't correct URI format of https://, pubsub://{project}:topic or Eventbridge ARN", + code: zod.ZodIssueCode.custom, + message: "URI isn't correct URI format of https://, pubsub://{project-id}:{topic-id} or Eventbridge ARN", path: ['webhooks', 'subscriptions', 0, 'uri'], } @@ -2796,9 +2795,8 @@ describe('WebhooksSchema', () => { subscriptions: [{uri: 'my::URI-thing::Shopify::123', topics: ['products/create']}], } const errorObj = { - validation: 'regex' as zod.ZodInvalidStringIssue['validation'], - code: zod.ZodIssueCode.invalid_string, - message: "URI isn't correct URI format of https://, pubsub://{project}:topic or Eventbridge ARN", + code: zod.ZodIssueCode.custom, + message: "URI isn't correct URI format of https://, pubsub://{project-id}:{topic-id} or Eventbridge ARN", path: ['webhooks', 'subscriptions', 0, 'uri'], } @@ -2957,9 +2955,8 @@ describe('WebhooksSchema', () => { ], } const errorObj = { - validation: 'regex' as zod.ZodInvalidStringIssue['validation'], - code: zod.ZodIssueCode.invalid_string, - message: "URI isn't correct URI format of https://, pubsub://{project}:topic or Eventbridge ARN", + code: zod.ZodIssueCode.custom, + message: "URI isn't correct URI format of https://, pubsub://{project-id}:{topic-id} or Eventbridge ARN", path: ['webhooks', 'subscriptions', 0, 'uri'], } diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index b8975414f3..a76518ae90 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -284,6 +284,7 @@ class AppLoader + private fullAppConfiguration?: CurrentAppConfiguration constructor( {mode, loadedConfiguration}: AppLoaderConstructorArgs, @@ -298,6 +299,7 @@ class AppLoader { const deployConfig = await this.specification.deployConfig?.(this.configuration, this.directory, apiKey, undefined) - const transformedConfig = this.specification.transformLocalToRemote?.(this.configuration) as - | {[key: string]: unknown} - | undefined + const transformedConfig = this.specification.transformLocalToRemote?.(this.configuration, { + fullAppConfiguration: this.fullAppConfiguration, + }) as {[key: string]: unknown} | undefined const resultDeployConfig = deployConfig ?? transformedConfig ?? undefined return resultDeployConfig && Object.keys(resultDeployConfig).length > 0 ? resultDeployConfig : undefined } diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 1ff810948c..ff51349680 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -3,6 +3,7 @@ import {ExtensionInstance} from './extension-instance.js' import {blocks} from '../../constants.js' import {Flag} from '../../services/dev/fetch.js' +import {CurrentAppConfiguration} from '../app/app.js' import {Result} from '@shopify/cli-kit/node/result' import {capitalize} from '@shopify/cli-kit/common/string' import {zod} from '@shopify/cli-kit/node/schema' @@ -21,9 +22,13 @@ export interface TransformationConfig { [key: string]: string } -export interface CustomTransformationConfig { - forward?: (obj: object, options?: {flags?: Flag[]}) => object - reverse?: (obj: object, options?: {flags?: Flag[]}) => object +export interface CustomTransformationConfigOptions { + flags?: Flag[] + fullAppConfiguration?: CurrentAppConfiguration +} +export interface CustomTransformationConfig { + forward?: (obj: T, options?: CustomTransformationConfigOptions) => object + reverse?: (obj: T, options?: CustomTransformationConfigOptions) => object } type ExtensionExperience = 'extension' | 'configuration' @@ -64,7 +69,7 @@ export interface ExtensionSpecification object + transformLocalToRemote?: (localContent: object, options?: CustomTransformationConfigOptions) => object /** * If required, convert configuration from the platform to the format used locally in the filesystem. @@ -73,7 +78,7 @@ export interface ExtensionSpecification object + transformRemoteToLocal?: (remoteContent: object, options?: CustomTransformationConfigOptions) => object uidStrategy: UidStrategy } diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts b/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts index 77acfac969..639161a021 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_privacy_compliance_webhooks.ts @@ -2,13 +2,18 @@ import {WebhookSubscription, WebhooksConfig} from './types/app_config_webhook.js import {WebhooksSchema} from './app_config_webhook_schemas/webhooks_schema.js' import {ComplianceTopic} from './app_config_webhook_schemas/webhook_subscription_schema.js' import {mergeAllWebhooks} from './transform/app_config_webhook.js' -import {CustomTransformationConfig, createConfigExtensionSpecification} from '../specification.js' +import { + CustomTransformationConfig, + CustomTransformationConfigOptions, + createConfigExtensionSpecification, +} from '../specification.js' import {Flag} from '../../../services/dev/fetch.js' import {compact, getPathValue} from '@shopify/cli-kit/common/object' const PrivacyComplianceWebhooksTransformConfig: CustomTransformationConfig = { - forward: (content: object, _options?: {flags?: Flag[]}) => transformToPrivacyComplianceWebhooksModule(content), - reverse: (content: object, options?: {flags?: Flag[]}) => + forward: (content: object, options?: CustomTransformationConfigOptions) => + transformToPrivacyComplianceWebhooksModule(content, options), + reverse: (content: object, options?: CustomTransformationConfigOptions) => transformFromPrivacyComplianceWebhooksModule(content, options), } @@ -23,13 +28,14 @@ const appPrivacyComplienceSpec = createConfigExtensionSpecification({ export default appPrivacyComplienceSpec -function transformToPrivacyComplianceWebhooksModule(content: object) { +function transformToPrivacyComplianceWebhooksModule(content: object, options?: CustomTransformationConfigOptions) { const webhooks = getPathValue(content, 'webhooks') as WebhooksConfig + const appUrl = options?.fullAppConfiguration?.application_url return compact({ - customers_redact_url: getCustomersDeletionUri(webhooks), - customers_data_request_url: getCustomersDataRequestUri(webhooks), - shop_redact_url: getShopDeletionUri(webhooks), + customers_redact_url: relativeUri(getCustomersDeletionUri(webhooks), appUrl), + customers_data_request_url: relativeUri(getCustomersDataRequestUri(webhooks), appUrl), + shop_redact_url: relativeUri(getShopDeletionUri(webhooks), appUrl), }) } @@ -72,6 +78,10 @@ function getComplianceUri(webhooks: WebhooksConfig, complianceTopic: string): st return webhooks.subscriptions?.find((subscription) => subscription.compliance_topics?.includes(complianceTopic))?.uri } +function relativeUri(uri?: string, appUrl?: string) { + return appUrl && uri?.startsWith('/') ? `${appUrl}${uri}` : uri +} + function getCustomersDeletionUri(webhooks: WebhooksConfig) { return getComplianceUri(webhooks, 'customers/redact') || webhooks?.privacy_compliance?.customer_deletion_url } diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_webhook.ts b/packages/app/src/cli/models/extensions/specifications/app_config_webhook.ts index 2bc309dd9e..d9d48eb3f1 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_webhook.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_webhook.ts @@ -1,11 +1,16 @@ import {WebhooksSchema} from './app_config_webhook_schemas/webhooks_schema.js' import {transformToWebhookConfig, transformFromWebhookConfig} from './transform/app_config_webhook.js' -import {CustomTransformationConfig, createConfigExtensionSpecification} from '../specification.js' +import { + CustomTransformationConfig, + CustomTransformationConfigOptions, + createConfigExtensionSpecification, +} from '../specification.js' export const WebhooksSpecIdentifier = 'webhooks' const WebhookTransformConfig: CustomTransformationConfig = { - forward: (content: object) => transformFromWebhookConfig(content), + forward: (content: object, options?: CustomTransformationConfigOptions) => + transformFromWebhookConfig(content, options), reverse: (content: object) => transformToWebhookConfig(content), } diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_webhook_subscription.test.ts b/packages/app/src/cli/models/extensions/specifications/app_config_webhook_subscription.test.ts index 3cc7bb5df9..8ac6591311 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_webhook_subscription.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_webhook_subscription.test.ts @@ -1,4 +1,5 @@ import spec from './app_config_webhook_subscription.js' +import {CurrentAppConfiguration} from '../../app/app.js' import {describe, expect, test} from 'vitest' describe('webhook_subscription', () => { @@ -80,4 +81,24 @@ describe('webhook_subscription', () => { }) }) }) + + describe('forwardTransform', () => { + test('when a relative URI is used, it inherits the application_url', () => { + const object = { + topics: ['products/create'], + uri: '/products', + } + + const webhookSpec = spec + + const result = webhookSpec.transformLocalToRemote!(object, { + fullAppConfiguration: {application_url: 'https://my-app-url.com'} as CurrentAppConfiguration, + }) + + expect(result).toEqual({ + uri: 'https://my-app-url.com/products', + topics: ['products/create'], + }) + }) + }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_webhook_subscription.ts b/packages/app/src/cli/models/extensions/specifications/app_config_webhook_subscription.ts index 9fa393b4ed..efd02a7dd5 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_webhook_subscription.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_webhook_subscription.ts @@ -71,15 +71,22 @@ function transformToWebhookSubscriptionConfig(content: object) { } } -const WebhookSubscriptionTransformConfig: CustomTransformationConfig = { - forward: (content: object) => content, +type WebhookContent = zod.infer +const WebhookSubscriptionTransformConfig: CustomTransformationConfig = { + forward: ({uri, ...content}, options) => { + const appUrl = options?.fullAppConfiguration?.application_url + return { + uri: appUrl && uri.startsWith('/') ? `${appUrl}${uri}` : uri, + ...content, + } + }, reverse: (content: object) => transformToWebhookSubscriptionConfig(content), } const appWebhookSubscriptionSpec = createConfigExtensionSpecification({ identifier: WebhookSubscriptionSpecIdentifier, schema: SingleWebhookSubscriptionSchema, - transformConfig: WebhookSubscriptionTransformConfig, + transformConfig: WebhookSubscriptionTransformConfig as object, uidStrategy: 'dynamic', }) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_webhook.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_webhook.ts index 3413411348..1b09faeb9b 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_webhook.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_webhook.ts @@ -1,17 +1,23 @@ +import {CustomTransformationConfigOptions} from '../../specification.js' +import {SpecsAppConfiguration} from '../types/app_config.js' import {WebhooksConfig, NormalizedWebhookSubscription, WebhookSubscription} from '../types/app_config_webhook.js' import {deepCompare, deepMergeObjects, getPathValue} from '@shopify/cli-kit/common/object' -export function transformFromWebhookConfig(content: object) { +export function transformFromWebhookConfig(content: object, options?: CustomTransformationConfigOptions) { const webhooks = getPathValue(content, 'webhooks') as WebhooksConfig if (!webhooks) return content const webhookSubscriptions = [] // eslint-disable-next-line @typescript-eslint/naming-convention const {api_version, subscriptions = []} = webhooks + const appUrl = (options?.fullAppConfiguration as unknown as SpecsAppConfiguration)?.application_url // Compliance topics are handled from app_config_privacy_compliance_webhooks.ts for (const {uri, topics, compliance_topics: _, ...optionalFields} of subscriptions) { - if (topics) webhookSubscriptions.push(topics.map((topic) => ({uri, topic, ...optionalFields}))) + if (topics) { + const uriWithRelativePath = uri.startsWith('/') && appUrl ? `${appUrl}${uri}` : uri + webhookSubscriptions.push(topics.map((topic) => ({uri: uriWithRelativePath, topic, ...optionalFields}))) + } } return webhookSubscriptions.length > 0 ? {subscriptions: webhookSubscriptions.flat(), api_version} : {api_version} diff --git a/packages/app/src/cli/models/extensions/specifications/validation/common.ts b/packages/app/src/cli/models/extensions/specifications/validation/common.ts index a7d0a0be5c..60268d67ba 100644 --- a/packages/app/src/cli/models/extensions/specifications/validation/common.ts +++ b/packages/app/src/cli/models/extensions/specifications/validation/common.ts @@ -10,17 +10,13 @@ const arnRegex = export const removeTrailingSlash = (arg: unknown) => typeof arg === 'string' && arg.endsWith('/') ? arg.replace(/\/+$/, '') : arg -export const UriValidation = zod.union( - [ - zod.string({invalid_type_error: 'Value must be string'}).regex(httpsRegex, { - message: "URI isn't correct URI format of https://, pubsub://{project}:topic or Eventbridge ARN", - }), - zod.string({invalid_type_error: 'Value must be string'}).regex(pubSubRegex, { - message: "URI isn't correct URI format of https://, pubsub://{project}:topic or Eventbridge ARN", - }), - zod.string({invalid_type_error: 'Value must be string'}).regex(arnRegex, { - message: "URI isn't correct URI format of https://, pubsub://{project}:topic or Eventbridge ARN", - }), - ], - {invalid_type_error: 'Invalid URI format'}, +export const UriValidation = zod.string({invalid_type_error: 'Value must be string'}).refine( + (uri) => { + if (uri.startsWith('/')) return true + + return httpsRegex.test(uri) || pubSubRegex.test(uri) || arnRegex.test(uri) + }, + { + message: "URI isn't correct URI format of https://, pubsub://{project-id}:{topic-id} or Eventbridge ARN", + }, ) diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index 913eefafb9..25cefb48d4 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -1128,7 +1128,7 @@ redirect_urls = [ "https://example.com/callback1" ] api_version = "2023-07" [[webhooks.subscriptions]] - uri = "https://example.com/customers" + uri = "/customers" compliance_topics = [ "customers/redact", "customers/data_request" ] [pos] @@ -1171,13 +1171,139 @@ embedded = false subscriptions: [ { compliance_topics: ['customers/redact', 'customers/data_request'], - uri: 'https://example.com/customers', + uri: '/customers', + }, + ], + }, + pos: { + embedded: false, + }, + path: expect.stringMatching(/\/shopify.app.toml$/), + }) + expect(content).toEqual(expectedContent) + }) +}) + +test('simplifies the webhook config using relative paths', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + const developerPlatformClient = testDeveloperPlatformClient({ + appExtensionRegistrations: (_app: MinimalAppIdentifiers) => Promise.resolve(remoteExtensionRegistrations), + }) + const remoteExtensionRegistrations = { + app: { + extensionRegistrations: [], + configurationRegistrations: [ + { + type: 'WEBHOOK_SUBSCRIPTION', + id: '123', + uuid: '123', + title: 'Webhook subscription', + activeVersion: { + config: JSON.stringify({ + api_version: '2024-01', + topic: 'products/create', + uri: 'https://my-app-url.com/webhooks', + }), + }, + }, + { + type: 'WEBHOOK_SUBSCRIPTION', + id: '1234', + uuid: '1234', + title: 'Webhook subscription', + activeVersion: { + config: JSON.stringify({ + api_version: '2024-01', + topic: 'products/update', + uri: 'https://my-app-url.com/webhooks', + }), + }, + }, + ], + dashboardManagedExtensionRegistrations: [], + }, + } + const options: LinkOptions = { + directory: tmp, + developerPlatformClient, + } + + vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) + const remoteConfiguration = { + ...DEFAULT_REMOTE_CONFIGURATION, + application_url: 'https://my-app-url.com', + webhooks: { + api_version: '2023-07', + subscriptions: [ + { + topics: ['products/create'], + uri: 'https://my-app-url.com/webhooks', + }, + { + topics: ['products/update'], + uri: 'https://my-app-url.com/webhooks', + }, + ], + }, + } + vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue(remoteConfiguration) + + // When + const configuration = await link(options) + + // Then + const content = await readFile(joinPath(tmp, 'shopify.app.toml')) + const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "12345" +name = "app1" +application_url = "https://my-app-url.com" +embedded = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +use_legacy_install_flow = true + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + topics = [ "products/create", "products/update" ] + uri = "/webhooks" + +[pos] +embedded = false +` + + expect(configuration).toEqual({ + client_id: '12345', + name: 'app1', + application_url: 'https://my-app-url.com', + embedded: true, + access_scopes: { + use_legacy_install_flow: true, + }, + build: undefined, + auth: { + redirect_urls: ['https://example.com/callback1'], + }, + webhooks: { + api_version: '2023-07', + subscriptions: [ + { + topics: ['products/create', 'products/update'], + uri: '/webhooks', }, ], }, pos: { embedded: false, }, + scopes: undefined, path: expect.stringMatching(/\/shopify.app.toml$/), }) expect(content).toEqual(expectedContent) diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index bdc084c3ea..8c74838460 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -33,7 +33,6 @@ import {SpecsAppConfiguration} from '../../../models/extensions/specifications/t import {getTomls} from '../../../utilities/app/config/getTomls.js' import {loadLocalExtensionsSpecifications} from '../../../models/extensions/load-specifications.js' import {reduceWebhooks} from '../../../models/extensions/specifications/transform/app_config_webhook.js' -import {WebhooksConfig} from '../../../models/extensions/specifications/types/app_config_webhook.js' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {formatPackageManagerCommand} from '@shopify/cli-kit/node/output' import {deepMergeObjects, isEmpty} from '@shopify/cli-kit/common/object' @@ -333,13 +332,18 @@ async function loadConfigurationFileName( * but when we link we want to condense all webhooks together * so we have to do an additional reduce here */ -function condenseComplianceAndNonComplianceWebhooks(config: {[key: string]: unknown}): CurrentAppConfiguration { - const webhooksConfig = (config.webhooks as WebhooksConfig) ?? {} - if (webhooksConfig.subscriptions?.length) { +function condenseComplianceAndNonComplianceWebhooks(config: CurrentAppConfiguration) { + const webhooksConfig = config.webhooks + if (webhooksConfig?.subscriptions?.length) { + const appUrl = config?.application_url as string | undefined webhooksConfig.subscriptions = reduceWebhooks(webhooksConfig.subscriptions) + webhooksConfig.subscriptions = webhooksConfig.subscriptions.map(({uri, ...subscription}) => ({ + uri: appUrl && uri.includes(appUrl) ? uri.replace(appUrl, '') : uri, + ...subscription, + })) } - return config as unknown as CurrentAppConfiguration + return config } /** diff --git a/packages/app/src/cli/utilities/extensions/configuration.ts b/packages/app/src/cli/utilities/extensions/configuration.ts index 43edc1a00b..aba2f442a5 100644 --- a/packages/app/src/cli/utilities/extensions/configuration.ts +++ b/packages/app/src/cli/utilities/extensions/configuration.ts @@ -2,7 +2,6 @@ interface GetUIExensionResourceURLOptions { checkoutCartUrl?: string subscriptionProductUrl?: string } - export function getUIExtensionResourceURL( uiExtensionType: string, options: GetUIExensionResourceURLOptions,