Skip to content

Commit

Permalink
Add a function to import existing webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
shauns committed Sep 18, 2024
1 parent d7f7e8c commit d51b164
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export type ListWebhookSubscriptionsQuery = {
webhookSubscriptions: {
edges: {
node: {
id: string
topic: Types.WebhookSubscriptionTopic
endpoint:
| {__typename: 'WebhookEventBridgeEndpoint'}
Expand Down Expand Up @@ -51,7 +50,6 @@ export const ListWebhookSubscriptions = {
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
{kind: 'Field', name: {kind: 'Name', value: 'topic'}},
{
kind: 'Field',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ query ListWebhookSubscriptions {
webhookSubscriptions(first: 100) {
edges {
node {
id
topic
endpoint {
__typename
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/cli/commands/app/import-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ interface MigrationChoice {
}

const getMigrationChoices = (isShopifolk: boolean): MigrationChoice[] => [
{
label: 'Webhooks',
value: 'webhooks',
extensionTypes: ['webhooks'],
buildTomlObject: () => '',
},
{
label: 'Payments Extensions',
value: 'payments',
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/cli/services/import-extensions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {fetchAppAndIdentifiers, logMetadataForLoadedContext} from './context.js'
import {ensureExtensionDirectoryExists} from './extensions/common.js'
import {getExtensions} from './fetch-extensions.js'
import {importDeclarativeWebhooks} from './webhook/import-declarative-webhooks.js'
import {AppInterface} from '../models/app/app.js'
import {updateAppIdentifiers, IdentifiersExtensions} from '../models/app/identifiers.js'
import {ExtensionRegistration} from '../api/graphql/all_app_extension_registrations.js'
Expand All @@ -27,6 +28,11 @@ export async function importExtensions(options: ImportOptions) {

await logMetadataForLoadedContext(remoteApp)

if (options.extensionTypes.includes('webhooks')) {
await importDeclarativeWebhooks(options.app, remoteApp)
return
}

const initialRemoteExtensions = await developerPlatformClient.appExtensionRegistrations({
id: remoteApp.apiKey,
apiKey: remoteApp.apiKey,
Expand Down
197 changes: 197 additions & 0 deletions packages/app/src/cli/services/webhook/import-declarative-webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {AppInterface, getAppVersionedSchema, isCurrentAppSchema} from '../../models/app/app.js'
import {OrganizationApp} from '../../models/organization.js'
import {writeAppConfigurationFile} from '../app/write-app-configuration-file.js'
import {
ListWebhookSubscriptions,
ListWebhookSubscriptionsQuery,
} from '../../api/graphql/admin/generated/list-webhook-subscriptions.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {outputContent, outputInfo, outputSuccess, outputToken} from '@shopify/cli-kit/node/output'
import {encodeToml} from '@shopify/cli-kit/node/toml'
import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui'
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
import {adminAsAppRequest} from '@shopify/cli-kit/node/api/admin-as-app'

export async function importDeclarativeWebhooks(app: AppInterface, remoteApp: OrganizationApp) {
const config = app.configuration
if (!isCurrentAppSchema(config)) {
throw new AbortError('This command can only be run in the current app')
}
const devStoreUrl = config.build?.dev_store_url
if (!devStoreUrl) {
throw new AbortError('This command can only be run in the current app')
}

const apiKey = remoteApp.apiKey
const apiSecret = remoteApp.apiSecretKeys[0]!.secret

if (!apiKey || !apiSecret) {
throw new AbortError('API key and secret are required')
}

const session = await ensureAuthenticatedAdminAsApp(devStoreUrl, apiKey, apiSecret)

const res = await adminAsAppRequest(ListWebhookSubscriptions, session, '2024-07')

// Process webhook subscriptions from the query result
const subscriptionItems = formatWebhookSubscriptions(res, config.application_url)

const outputObject = {
webhooks: {
subscriptions: subscriptionItems,
},
}

const tomlString = encodeToml(outputObject)

outputInfo(outputContent`${outputToken.green('Loaded webhooks from your app:')}`)

outputInfo(outputContent`
${outputToken.raw(tomlString)}`)

const shouldAddToConfig = await renderConfirmationPrompt({
message: 'Do you want to add these webhooks to your config file?',
confirmationMessage: 'Yes, add to config',
cancellationMessage: 'No, skip',
})

if (shouldAddToConfig) {
const schema = getAppVersionedSchema(app.specifications)

const mergedConfig = {
...config,
webhooks: {
api_version: '2024-07',
...config.webhooks,
subscriptions: [...(config.webhooks?.subscriptions ?? []), ...subscriptionItems],
},
}

await writeAppConfigurationFile(mergedConfig, schema)

outputSuccess(outputContent`Webhooks configuration has been added to ${outputToken.path(mergedConfig.path)}`)
} else {
outputInfo('Webhook configuration was not added to the config file.')
}
}

function formatWebhookSubscriptions(res: ListWebhookSubscriptionsQuery, applicationUrlFromConfig: string) {
const webhookSubscriptions = res.webhookSubscriptions.edges
.map((edge) => {
const node = edge.node
return {
topic: `${node.topic}`,
endpoint: node.endpoint.__typename === 'WebhookHttpEndpoint' ? `${node.endpoint.callbackUrl}` : null,
}
})
.filter((subscription) => subscription.endpoint !== null) as {topic: string; endpoint: string}[]

// Group webhooks by endpoint URL
const groupedWebhooks = webhookSubscriptions.reduce<{endpoint: string; topics: string[]}[]>((acc, subscription) => {
const existingGroup = acc.find((group) => group.endpoint === subscription.endpoint)
if (existingGroup) {
existingGroup.topics.push(subscription.topic)
} else {
acc.push({endpoint: subscription.endpoint, topics: [subscription.topic]})
}
return acc
}, [])

// If a common prefix is found, use it as the application URL
let applicationUrl = applicationUrlFromConfig
const commonPrefix = groupedWebhooks.reduce((prefix, group, index) => {
if (index === 0) return group.endpoint
const prefixLength = prefix.length
for (let i = 0; i < prefixLength; i++) {
if (group.endpoint[i] !== prefix[i]) {
return prefix.slice(0, i)
}
}
return prefix
}, '')
if (commonPrefix && commonPrefix.includes('://')) {
try {
const url = new URL(commonPrefix)
applicationUrl = `${url.protocol}//${url.hostname}`
outputInfo(outputContent`🌍 Using ${outputToken.raw(applicationUrl)} as the application URL`)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
outputInfo(outputContent`🌍 Using ${outputToken.raw(applicationUrlFromConfig)} as the application URL`)
}
}

// Update endpoints to be relative paths
groupedWebhooks.forEach((group) => {
if (!group.endpoint.startsWith(applicationUrl)) {
return
}
group.endpoint = group.endpoint.slice(applicationUrl.length)
if (!group.endpoint.startsWith('/')) {
group.endpoint = `/${group.endpoint}`
}
})

// Sort topics within each group for consistency
groupedWebhooks.forEach((group) => {
group.topics.sort()
})

// Sort groups by endpoint for consistency
groupedWebhooks.sort((groupA, groupB) => groupA.endpoint.localeCompare(groupB.endpoint))

const subscriptionItems = groupedWebhooks.map((group) => {
return {
topics: group.topics,
uri: group.endpoint,
}
})
return subscriptionItems
}

if (import.meta.vitest) {
const {describe, test, expect} = import.meta.vitest

describe('formatWebhookSubscriptions', () => {
test('it formats webhook subscriptions', () => {
const res: ListWebhookSubscriptionsQuery = {
webhookSubscriptions: {
edges: [
{
node: {
topic: 'ORDERS_CREATE',
endpoint: {__typename: 'WebhookHttpEndpoint', callbackUrl: 'https://example.com/orders-webhooks'},
},
},
{
node: {
topic: 'ORDERS_UPDATED',
endpoint: {__typename: 'WebhookHttpEndpoint', callbackUrl: 'https://example.com/orders-webhooks'},
},
},
{
node: {
topic: 'ORDERS_DELETE',
endpoint: {__typename: 'WebhookHttpEndpoint', callbackUrl: 'https://example.com/orders-webhooks'},
},
},
{
node: {
topic: 'PRODUCTS_DELETE',
endpoint: {__typename: 'WebhookHttpEndpoint', callbackUrl: 'https://example.com/products-webhooks'},
},
},
],
},
}

const applicationUrlFromConfig = 'https://some-other-url.com'

const formattedSubscriptions = formatWebhookSubscriptions(res, applicationUrlFromConfig)

expect(formattedSubscriptions).toEqual([
{topics: ['ORDERS_CREATE', 'ORDERS_DELETE', 'ORDERS_UPDATED'], uri: '/orders-webhooks'},
{topics: ['PRODUCTS_DELETE'], uri: '/products-webhooks'},
])
})
})
}

0 comments on commit d51b164

Please sign in to comment.