diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index fcd9174262..010b50df6e 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2350,9 +2350,8 @@ describe('WebhooksSchema', () => { ], } const errorObj = { - validation: 'regex' as zod.ZodInvalidStringIssue['validation'], - code: zod.ZodIssueCode.invalid_string, - message: 'Invalid', + code: zod.ZodIssueCode.custom, + message: 'Must be a valid https, pubsub, or ARN URI', path: ['webhooks', 'subscriptions', 0, 'uri'], } @@ -2378,9 +2377,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: 'Invalid', + code: zod.ZodIssueCode.custom, + message: 'Must be a valid https, pubsub, or ARN URI', path: ['webhooks', 'subscriptions', 0, 'uri'], } @@ -2426,6 +2424,22 @@ describe('WebhooksSchema', () => { expect(parsedConfiguration.webhooks).toMatchObject(webhookConfig) }) + test('accepts a relative path uri', async () => { + const webhookConfig: WebhooksConfig = { + api_version: '2021-07', + subscriptions: [ + { + uri: '/webhooks', + topics: ['products/create'], + }, + ], + } + + const {abortOrReport, parsedConfiguration} = await setupParsing({}, webhookConfig) + expect(abortOrReport).not.toHaveBeenCalled() + expect(parsedConfiguration.webhooks).toMatchObject(webhookConfig) + }) + test('accepts combination of uris', async () => { const webhookConfig: WebhooksConfig = { api_version: '2021-07', @@ -2442,6 +2456,10 @@ describe('WebhooksSchema', () => { uri: 'pubsub://my-project-123:my-topic', topics: ['products/create', 'products/update'], }, + { + uri: '/webhooks', + topics: ['products/create', 'products/update'], + }, ], } @@ -2531,9 +2549,8 @@ describe('WebhooksSchema', () => { ], } const errorObj = { - validation: 'regex' as zod.ZodInvalidStringIssue['validation'], - code: zod.ZodIssueCode.invalid_string, - message: 'Invalid', + code: zod.ZodIssueCode.custom, + message: 'Must be a valid https, pubsub, or ARN URI', 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 cb32f2b7be..fa38d0e500 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -191,6 +191,7 @@ class AppLoader { private errors: AppErrors = new AppErrors() private specifications: ExtensionSpecification[] private remoteBetas: BetaFlag[] + private fullAppConfiguration?: AppConfiguration constructor({directory, configName, mode, specifications, remoteBetas}: AppLoaderConstructorArgs) { this.mode = mode ?? 'strict' @@ -220,6 +221,7 @@ class AppLoader { specifications: this.specifications, }) const {directory, configuration, configurationLoadResultMetadata, configSchema} = await configurationLoader.loaded() + this.fullAppConfiguration = configuration await logMetadataFromAppLoadingProcess(configurationLoadResultMetadata) const dotenv = await loadDotEnv(directory, configuration.path) @@ -334,6 +336,7 @@ class AppLoader { entryPath, directory, specification, + fullAppConfig: this.fullAppConfiguration, }) const validateResult = await extensionInstance.validate() diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 31014769da..388f3ef6a2 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -10,6 +10,7 @@ import { import {bundleThemeExtension} from '../../services/extensions/bundle.js' import {Identifiers} from '../app/identifiers.js' import {uploadWasmBlob} from '../../services/deploy/upload.js' +import {AppConfiguration} from '../app/app.js' import {ok} from '@shopify/cli-kit/node/result' import {constantize, slugify} from '@shopify/cli-kit/common/string' import {randomUUID} from '@shopify/cli-kit/node/crypto' @@ -42,6 +43,7 @@ export class ExtensionInstance { const deployConfig = await this.specification.deployConfig?.(this.configuration, this.directory, apiKey, undefined) - const transformedConfig = this.specification.transform?.(this.configuration) as {[key: string]: unknown} | undefined + const transformedConfig = this.specification.transform?.(this.configuration, 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 3bc08d7043..89f5b094c5 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -21,7 +21,7 @@ export interface TransformationConfig { } export interface CustomTransformationConfig { - forward?: (obj: object) => object + forward?: (obj: object, fullAppConfiguration?: object) => object reverse?: (obj: object) => object } @@ -55,8 +55,8 @@ export interface ExtensionSpecification) => Promise hasExtensionPointTarget?(config: TConfiguration, target: string): boolean appModuleFeatures: (config?: TConfiguration) => ExtensionFeature[] - transform?: (content: object) => object - reverseTransform?: (content: object) => object + transform?: (content: object, fullAppConfiguration?: object) => object + reverseTransform?: (content: object, fullAppConfiguration?: object) => object } /** diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_webhook.test.ts b/packages/app/src/cli/models/extensions/specifications/app_config_webhook.test.ts index 02b4d46f1a..fd9929cbcc 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_webhook.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_webhook.test.ts @@ -126,6 +126,39 @@ describe('webhooks', () => { api_version: '2021-01', }) }) + test('when a relative URI is used, it inherits the application_url', () => { + // Given + const object = { + webhooks: { + api_version: '2021-01', + subscriptions: [ + { + topics: ['products/update', 'products/delete'], + uri: '/products', + }, + ], + }, + } + const webhookSpec = spec + + // When + const result = webhookSpec.transform!(object, {application_url: 'https://my-app-url.com'}) + + // Then + expect(result).toEqual({ + api_version: '2021-01', + subscriptions: [ + { + topic: 'products/update', + uri: 'https://my-app-url.com/products', + }, + { + topic: 'products/delete', + uri: 'https://my-app-url.com/products', + }, + ], + }) + }) }) describe('reverseTransform', () => { test('should return the reversed transformed object', () => { @@ -246,5 +279,62 @@ describe('webhooks', () => { }, }) }) + test('when subscriptions share the application_url base, simplify with a relative path', () => { + // Given + const object = { + api_version: '2021-01', + subscriptions: [ + { + topic: 'products/update', + uri: 'https://my-app-url.com/products', + }, + { + topic: 'products/delete', + uri: 'https://my-app-url.com/products', + }, + { + topic: 'orders/update', + uri: 'https://my-app-url.com/orders', + }, + { + topic: 'customers/create', + uri: 'https://customers-url.com', + }, + { + topic: 'customers/delete', + uri: 'pubsub://absolute-feat-test:pub-sub-topic', + }, + ], + } + const webhookSpec = spec + + // When + const result = webhookSpec.reverseTransform!(object, {application_url: 'https://my-app-url.com'}) + + // Then + expect(result).toMatchObject({ + webhooks: { + api_version: '2021-01', + subscriptions: [ + { + topics: ['products/update', 'products/delete'], + uri: '/products', + }, + { + topics: ['orders/update'], + uri: '/orders', + }, + { + topics: ['customers/create'], + uri: 'https://customers-url.com', + }, + { + topics: ['customers/delete'], + uri: 'pubsub://absolute-feat-test:pub-sub-topic', + }, + ], + }, + }) + }) }) }) 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 2f619c3159..19808e8b60 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 @@ -25,7 +25,7 @@ const WebhooksSchema = zod.object({ subscriptions: zod.array(WebhookSubscriptionSchema).optional(), }) -export const WebhooksSchemaWithDeclarative = WebhooksSchema.superRefine(webhookValidator) +const WebhooksSchemaWithDeclarative = WebhooksSchema.superRefine(webhookValidator) export const WebhookSchema = zod.object({ webhooks: WebhooksSchemaWithDeclarative, @@ -34,8 +34,8 @@ export const WebhookSchema = zod.object({ export const WebhooksSpecIdentifier = 'webhooks' const WebhookTransformConfig: CustomTransformationConfig = { - forward: (content: object) => transformWebhookConfig(content), - reverse: (content: object) => transformToWebhookConfig(content), + forward: transformWebhookConfig, + reverse: transformToWebhookConfig, } const spec = createConfigExtensionSpecification({ 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 97c4a87977..bf98974a7c 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,10 +1,14 @@ +import {SpecsAppConfiguration} from '../types/app_config.js' import {WebhooksConfig, NormalizedWebhookSubscription} from '../types/app_config_webhook.js' import {deepMergeObjects, getPathValue} from '@shopify/cli-kit/common/object' -export function transformWebhookConfig(content: object) { +export function transformWebhookConfig(content: object, fullAppConfig?: object) { const webhooks = getPathValue(content, 'webhooks') as WebhooksConfig if (!webhooks) return content + const appConfig = fullAppConfig as SpecsAppConfiguration + const applicationUrl = appConfig?.application_url + const webhookSubscriptions = [] // eslint-disable-next-line @typescript-eslint/naming-convention const {api_version, subscriptions = []} = webhooks @@ -12,13 +16,14 @@ export function transformWebhookConfig(content: object) { // eslint-disable-next-line no-warning-comments // TODO: pass along compliance_topics once we're ready to store them in its own module for (const {uri, topics, compliance_topics: _, ...optionalFields} of subscriptions) { - webhookSubscriptions.push(topics.map((topic) => ({uri, topic, ...optionalFields}))) + const uriWithRelativePath = uri.startsWith('/') && applicationUrl ? `${applicationUrl}${uri}` : uri + webhookSubscriptions.push(topics.map((topic) => ({uri: uriWithRelativePath, topic, ...optionalFields}))) } return webhookSubscriptions.length > 0 ? {subscriptions: webhookSubscriptions.flat(), api_version} : {api_version} } -export function transformToWebhookConfig(content: object) { +export function transformToWebhookConfig(content: object, fullAppConfig?: object) { let webhooks = {} const apiVersion = getPathValue(content, 'api_version') as string webhooks = {...(apiVersion ? {webhooks: {api_version: apiVersion}} : {})} @@ -27,15 +32,22 @@ export function transformToWebhookConfig(content: object) { const webhooksSubscriptions: WebhooksConfig['subscriptions'] = [] + const appConfig = fullAppConfig as SpecsAppConfiguration + const applicationUrl = appConfig?.application_url + // eslint-disable-next-line @typescript-eslint/naming-convention for (const {uri, topic, sub_topic, ...optionalFields} of serverWebhooks) { - const currSubscription = webhooksSubscriptions.find((sub) => sub.uri === uri && sub.sub_topic === sub_topic) + const uriWithRelativePath = uri.includes(applicationUrl) ? uri.replace(applicationUrl, '') : uri + + const currSubscription = webhooksSubscriptions.find( + (sub) => sub.uri === uriWithRelativePath && sub.sub_topic === sub_topic, + ) if (currSubscription) { currSubscription.topics.push(topic) } else { webhooksSubscriptions.push({ topics: [topic], - uri, + uri: uriWithRelativePath, ...(sub_topic ? {sub_topic} : {}), ...optionalFields, }) 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 fc3db473cb..169bdb67cb 100644 --- a/packages/app/src/cli/models/extensions/specifications/validation/common.ts +++ b/packages/app/src/cli/models/extensions/specifications/validation/common.ts @@ -15,8 +15,13 @@ export const ensureHttpsOnlyUrl = validateUrl(zod.string(), { message: 'Only https urls are allowed', }).refine((url) => !url.endsWith('/'), {message: 'URL can’t end with a forward slash'}) -export const UriValidation = zod.union([ - zod.string().regex(httpsRegex), - zod.string().regex(pubSubRegex), - zod.string().regex(arnRegex), -]) +export const UriValidation = zod.string().refine( + (uri) => { + if (uri.startsWith('/')) return true + + return httpsRegex.test(uri) || pubSubRegex.test(uri) || arnRegex.test(uri) + }, + { + message: 'Must be a valid https, pubsub, or ARN URI', + }, +) diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 4811e7ff6d..53df5925ae 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -47,7 +47,10 @@ export function remoteAppConfigurationExtensionContent( if (!configString) return const config = configString ? JSON.parse(configString) : {} - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.reverseTransform?.(config) ?? config) + remoteAppConfig = deepMergeObjects( + remoteAppConfig, + configSpec.reverseTransform?.(config, remoteAppConfig) ?? config, + ) }) return {...remoteAppConfig} }