From eea7761c3b3fe7fd4cc02ec9a5251460b1e65294 Mon Sep 17 00:00:00 2001 From: Rick Sperko Date: Tue, 7 Jan 2025 21:42:19 +0000 Subject: [PATCH] Add support for Metafields to webhooks --- .../app/src/cli/models/app/app.test-data.ts | 1 + .../specifications/app_config_webhook.test.ts | 215 ++++++++++++++++++ .../webhook_subscription_schema.ts | 9 + .../app_config_webhook_subscription.test.ts | 178 +++++++++++++++ .../app_config_webhook_subscription.ts | 13 ++ .../types/app_config_webhook.ts | 4 + .../validation/app_config_webhook.ts | 20 +- 7 files changed, 439 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index 082522d78a6..23c4a1aebcb 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -428,6 +428,7 @@ export async function testSingleWebhookSubscriptionExtension({ topic, api_version: '2024-01', uri: 'https://my-app.com/webhooks', + metafields: [{namespace: 'custom', key: 'test'}], }, }: { emptyConfig?: boolean 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 06b899f4a3e..d76609596a8 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 @@ -1,6 +1,9 @@ import spec from './app_config_webhook.js' +import {webhookValidator} from './validation/app_config_webhook.js' +import {WebhookSubscriptionSchema} from './app_config_webhook_schemas/webhook_subscription_schema.js' import {placeholderAppConfiguration} from '../../app/app.test-data.js' import {describe, expect, test} from 'vitest' +import {zod} from '@shopify/cli-kit/node/schema' describe('webhooks', () => { describe('transform', () => { @@ -61,4 +64,216 @@ describe('webhooks', () => { }) }) }) + + describe('validation', () => { + interface TestWebhookConfig { + api_version: string + subscriptions: unknown[] + } + + function validateWebhooks(webhookConfig: TestWebhookConfig) { + const ctx = { + addIssue: (issue: zod.ZodIssue) => { + throw new Error(issue.message) + }, + path: [], + } as zod.RefinementCtx + + // First validate the schema for each subscription + for (const subscription of webhookConfig.subscriptions) { + const schemaResult = WebhookSubscriptionSchema.safeParse(subscription) + if (!schemaResult.success) { + return { + success: false, + error: new Error(schemaResult.error.issues[0]?.message ?? 'Invalid webhook subscription'), + } + } + } + + // Then validate business rules + try { + webhookValidator(webhookConfig, ctx) + return {success: true, error: undefined} + } catch (error) { + if (error instanceof Error) { + return {success: false, error} + } + throw error + } + } + + test('allows metafields when API version is 2025-04', () => { + // Given + const webhookConfig: TestWebhookConfig = { + api_version: '2025-04', + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [{namespace: 'custom', key: 'test'}], + }, + ], + } + + // When + const result = validateWebhooks(webhookConfig) + + // Then + expect(result.success).toBe(true) + }) + + test('allows metafields when API version is unstable', () => { + // Given + const webhookConfig: TestWebhookConfig = { + api_version: 'unstable', + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [{namespace: 'custom', key: 'test'}], + }, + ], + } + + // When + const result = validateWebhooks(webhookConfig) + + // Then + expect(result.success).toBe(true) + }) + + test('rejects metafields when API version is earlier than 2025-04', () => { + // Given + const webhookConfig: TestWebhookConfig = { + api_version: '2024-01', + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [{namespace: 'custom', key: 'test'}], + }, + ], + } + + // When + const result = validateWebhooks(webhookConfig) + + // Then + expect(result.success).toBe(false) + expect(result.error?.message).toBe( + 'Webhook metafields are only supported in API version 2025-04 or later, or with version "unstable"', + ) + }) + + test('validates metafields namespace and key are strings', () => { + // Given + const webhookConfig: TestWebhookConfig = { + api_version: '2025-04', + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [{namespace: 123, key: 'test'}], + }, + ], + } + + // When + const result = validateWebhooks(webhookConfig) + + // Then + expect(result.success).toBe(false) + expect(result.error?.message).toBe('Metafield namespace must be a string') + }) + + test('allows configuration without metafields in older API versions', () => { + // Given + const webhookConfig: TestWebhookConfig = { + api_version: '2024-01', + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + }, + ], + } + + // When + const result = validateWebhooks(webhookConfig) + + // Then + expect(result.success).toBe(true) + }) + + test('allows empty metafields array in supported API versions', () => { + // Given + const webhookConfig: TestWebhookConfig = { + api_version: '2025-04', + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [], + }, + ], + } + + // When + const result = validateWebhooks(webhookConfig) + + // Then + expect(result.success).toBe(true) + }) + + test('rejects metafields with invalid property types', () => { + // Given + const webhookConfig: TestWebhookConfig = { + api_version: '2025-04', + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [ + { + namespace: 123, + key: 'valid', + }, + ], + }, + ], + } + + // When + const result = validateWebhooks(webhookConfig) + + // Then + expect(result.success).toBe(false) + expect(result.error?.message).toBe('Metafield namespace must be a string') + }) + + test('rejects malformed metafields missing a required property', () => { + // Given + const webhookConfig: TestWebhookConfig = { + api_version: '2025-04', + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [ + { + namespace: 'custom', + }, + ], + }, + ], + } + + // When + const result = validateWebhooks(webhookConfig) + + // Then + expect(result.success).toBe(false) + expect(result.error?.message).toBe('Required') + }) + }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.ts b/packages/app/src/cli/models/extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.ts index dd05bfd897a..4a4cfb65eb4 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.ts @@ -18,6 +18,15 @@ export const WebhookSubscriptionSchema = zod.object({ }), include_fields: zod.array(zod.string({invalid_type_error: 'Value must be a string'})).optional(), filter: zod.string({invalid_type_error: 'Value must be a string'}).optional(), + metafields: zod + .array( + zod.object({ + namespace: zod.string({invalid_type_error: 'Metafield namespace must be a string'}), + key: zod.string({invalid_type_error: 'Metafield key must be a string'}), + }), + {invalid_type_error: 'Metafields must be an array of objects with namespace and key'}, + ) + .optional(), compliance_topics: zod .array( zod.enum([ComplianceTopic.CustomersRedact, ComplianceTopic.CustomersDataRequest, ComplianceTopic.ShopRedact]), 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 6310458ac75..df12963a213 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 {WebhookSubscriptionSchema} from './app_config_webhook_schemas/webhook_subscription_schema.js' import {AppConfigurationWithoutPath} from '../../app/app.js' import {describe, expect, test} from 'vitest' @@ -32,6 +33,40 @@ describe('webhook_subscription', () => { }, }) }) + + test('should preserve metafields during transformation', () => { + // Given + const object = { + api_version: '2025-04', + topic: 'orders/create', + uri: 'https://example.com/webhooks', + metafields: [ + {namespace: 'custom', key: 'test1'}, + {namespace: 'app', key: 'test2'}, + ], + } + + const webhookSpec = spec + + // When + const result = webhookSpec.transformRemoteToLocal!(object) + + // Then + expect(result).toMatchObject({ + webhooks: { + subscriptions: [ + { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [ + {namespace: 'custom', key: 'test1'}, + {namespace: 'app', key: 'test2'}, + ], + }, + ], + }, + }) + }) }) describe('forwardTransform', () => { @@ -52,5 +87,148 @@ describe('webhook_subscription', () => { topics: ['products/create'], }) }) + + test('should preserve metafields during forward transformation', () => { + const object = { + topics: ['orders/create'], + uri: 'https://example.com/webhooks', + metafields: [ + {namespace: 'custom', key: 'test1'}, + {namespace: 'app', key: 'test2'}, + ], + } + + const webhookSpec = spec + + const result = webhookSpec.transformLocalToRemote!(object, { + application_url: 'https://my-app-url.com/', + } as unknown as AppConfigurationWithoutPath) + + expect(result).toEqual({ + uri: 'https://example.com/webhooks', + topics: ['orders/create'], + metafields: [ + {namespace: 'custom', key: 'test1'}, + {namespace: 'app', key: 'test2'}, + ], + }) + }) + }) + + describe('metafields validation', () => { + test('transforms metafields correctly in local to remote', () => { + // Given + const object = { + topics: ['products/create'], + uri: '/products', + metafields: [ + { + namespace: 'custom', + key: 'test', + }, + ], + } + + const webhookSpec = spec + + // When + const result = webhookSpec.transformLocalToRemote!(object, { + application_url: 'https://my-app-url.com/', + } as unknown as AppConfigurationWithoutPath) + + // Then + expect(result).toEqual({ + uri: 'https://my-app-url.com/products', + topics: ['products/create'], + metafields: [ + { + namespace: 'custom', + key: 'test', + }, + ], + }) + }) + + test('preserves metafields in remote to local transform', () => { + // Given + const object = { + topic: 'products/create', + uri: 'https://my-app-url.com/products', + metafields: [ + { + namespace: 'custom', + key: 'test', + }, + ], + } + + const webhookSpec = spec + + // When + const result = webhookSpec.transformRemoteToLocal!(object) + + // Then + expect(result).toMatchObject({ + webhooks: { + subscriptions: [ + { + topics: ['products/create'], + uri: 'https://my-app-url.com/products', + metafields: [ + { + namespace: 'custom', + key: 'test', + }, + ], + }, + ], + }, + }) + }) + + test('rejects metafields with invalid property types', () => { + // Given + const object = { + topics: ['products/create'], + uri: '/products', + metafields: [ + { + namespace: 123, + key: 'valid', + }, + ], + } + + // When + const result = WebhookSubscriptionSchema.safeParse(object) + + // Then + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Metafield namespace must be a string') + } + }) + + test('rejects metafields with missing a required property', () => { + // Given + const object = { + topics: ['products/create'], + uri: '/products', + metafields: [ + { + namespace: 'custom', + }, + ], + } + + // When + const result = WebhookSubscriptionSchema.safeParse(object) + + // Then + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0]?.message).toMatch(/Required/) + } + }) }) }) 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 20de3a3552e..392f8aed02d 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 @@ -13,6 +13,10 @@ interface TransformedWebhookSubscription { compliance_topics?: string[] include_fields?: string[] filter?: string + metafields?: { + namespace: string + key: string + }[] } export const SingleWebhookSubscriptionSchema = zod.object({ @@ -23,6 +27,15 @@ export const SingleWebhookSubscriptionSchema = zod.object({ }), include_fields: zod.array(zod.string({invalid_type_error: 'Value must be a string'})).optional(), filter: zod.string({invalid_type_error: 'Value must be a string'}).optional(), + metafields: zod + .array( + zod.object({ + namespace: zod.string({invalid_type_error: 'Metafield namespace must be a string'}), + key: zod.string({invalid_type_error: 'Metafield key must be a string'}), + }), + {invalid_type_error: 'Metafields must be an array of objects with namespace and key'}, + ) + .optional(), }) /* this transforms webhooks remotely to be accepted by the TOML diff --git a/packages/app/src/cli/models/extensions/specifications/types/app_config_webhook.ts b/packages/app/src/cli/models/extensions/specifications/types/app_config_webhook.ts index 16266b6e5ab..0c7f376a05b 100644 --- a/packages/app/src/cli/models/extensions/specifications/types/app_config_webhook.ts +++ b/packages/app/src/cli/models/extensions/specifications/types/app_config_webhook.ts @@ -4,6 +4,10 @@ export interface WebhookSubscription { compliance_topics?: string[] include_fields?: string[] filter?: string + metafields?: { + namespace: string + key: string + }[] } interface PrivacyComplianceConfig { diff --git a/packages/app/src/cli/models/extensions/specifications/validation/app_config_webhook.ts b/packages/app/src/cli/models/extensions/specifications/validation/app_config_webhook.ts index 247274ead77..8ab91203d53 100644 --- a/packages/app/src/cli/models/extensions/specifications/validation/app_config_webhook.ts +++ b/packages/app/src/cli/models/extensions/specifications/validation/app_config_webhook.ts @@ -13,7 +13,18 @@ export function webhookValidator(schema: object, ctx: zod.RefinementCtx) { } function validateSubscriptions(webhookConfig: WebhooksConfig) { - const {subscriptions = []} = webhookConfig + // eslint-disable-next-line @typescript-eslint/naming-convention + const {subscriptions = [], api_version} = webhookConfig + + const hasMetafields = subscriptions.some((sub) => sub.metafields && sub.metafields.length > 0) + if (hasMetafields && !isVersionGreaterOrEqual(api_version, '2025-04')) { + return { + code: zod.ZodIssueCode.custom, + message: 'Webhook metafields are only supported in API version 2025-04 or later, or with version "unstable"', + path: ['api_version'], + } + } + const uniqueSubscriptionSet = new Set() const duplicatedSubscriptionsFields: string[] = [] @@ -76,3 +87,10 @@ function validateSubscriptions(webhookConfig: WebhooksConfig) { } } } + +function isVersionGreaterOrEqual(version: string, minVersion: string): boolean { + if (version === 'unstable') return true + const [versionYear = 0, versionMonth = 0] = version.split('-').map(Number) + const [minYear = 0, minMonth = 0] = minVersion.split('-').map(Number) + return versionYear > minYear || (versionYear === minYear && versionMonth >= minMonth) +}