diff --git a/.changeset/events-config-link-shape-tolerance.md b/.changeset/events-config-link-shape-tolerance.md new file mode 100644 index 00000000000..32c08b66ac6 --- /dev/null +++ b/.changeset/events-config-link-shape-tolerance.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Support single-subscription events modules when reading remote app configuration diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index f78346c1a2f..04edb1aecdd 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -1,4 +1,5 @@ import {transformToEventsConfig, transformFromEventsConfig} from './app_config_events.js' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' describe('transformFromEventsConfig', () => { @@ -115,6 +116,25 @@ describe('transformFromEventsConfig', () => { expect(result).toEqual(content) }) + test('prepends application_url to a relative URI in a single subscription object', () => { + const content = { + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, + }, + }) + }) + test('returns content as-is when events is undefined', () => { const content = {} const appConfiguration = {application_url: 'https://tunnel.example.com'} @@ -192,4 +212,108 @@ describe('transformToEventsConfig', () => { }, }) }) + test('strips the identifier from a single subscription object and returns it as a one-element array', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + identifier: 'id-1', + }, + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + }, + ], + }, + }) + }) + + test('merging multiple single-subscription modules accumulates one subscription array', () => { + const moduleOne = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + identifier: 'id-a', + }, + }, + } + const moduleTwo = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + identifier: 'id-b', + }, + }, + } + + const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, + ], + }, + }) + }) + + test('merging a list-shape module with a single-subscription module accumulates all subscriptions', () => { + const listModule = { + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a', identifier: 'id-a'}, + ], + }, + } + const singleModule = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + identifier: 'id-b', + }, + }, + } + + const merged = deepMergeObjects(transformToEventsConfig(listModule), transformToEventsConfig(singleModule)) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, + ], + }, + }) + }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index da5883ce1de..84070b42238 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -2,10 +2,15 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' import {CurrentAppConfiguration} from '../../../app/app.js' import {getPathValue} from '@shopify/cli-kit/common/object' +interface EventSubscription { + uri: string + [key: string]: unknown +} + interface EventsConfig { events?: { api_version?: string - subscription?: {uri: string; [key: string]: unknown}[] + subscription?: EventSubscription | EventSubscription[] } } @@ -27,14 +32,17 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob appUrl = (appConfiguration as CurrentAppConfiguration)?.application_url } + const subscription = eventsConfig.events.subscription + const resolved = wrapSubscriptions(subscription).map((sub) => ({ + ...sub, + uri: prependApplicationUrl(sub.uri, appUrl), + })) + return { ...eventsConfig, events: { ...eventsConfig.events, - subscription: eventsConfig.events.subscription.map((sub) => ({ - ...sub, - uri: prependApplicationUrl(sub.uri, appUrl), - })), + subscription: Array.isArray(subscription) ? resolved : resolved[0], }, } } @@ -44,18 +52,30 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob * Strips the server-managed 'identifier' field from subscriptions. */ export function transformToEventsConfig(content: object) { - const eventsConfig = getPathValue(content, 'events') as {api_version: string; subscription: object[]} + const eventsConfig = getPathValue(content, 'events') as { + api_version: string + subscription: {identifier: string} | {identifier: string}[] + } const apiVersion = getPathValue(eventsConfig, 'api_version') - const subscription = getPathValue(eventsConfig, 'subscription') as {identifier: string}[] + const subscription = getPathValue<{identifier: string} | {identifier: string}[]>(eventsConfig, 'subscription') - // Server always includes identifier - strip it for local TOML - const cleanedSubscriptions = subscription?.map((sub) => { - const {identifier, ...rest} = sub - return rest - }) + // Server always includes identifier - strip it for local TOML. + // Single-subscription modules are normalized to a one-element array so that + // merging multiple modules accumulates a single subscription list. + const cleanedSubscriptions = + subscription === undefined + ? undefined + : wrapSubscriptions(subscription).map((sub) => { + const {identifier, ...rest} = sub + return rest + }) const events = (apiVersion ?? cleanedSubscriptions) ? {api_version: apiVersion, subscription: cleanedSubscriptions} : {} return {events} } + +function wrapSubscriptions(subscription: T | T[]): T[] { + return Array.isArray(subscription) ? subscription : [subscription] +}