Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Declarative webhooks relative paths #3948

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 6 additions & 9 deletions packages/app/src/cli/models/app/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}

Expand All @@ -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'],
}

Expand Down Expand Up @@ -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'],
}

Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ class AppLoader<TConfig extends AppConfiguration, TModuleSpec extends ExtensionS
private remoteFlags: Flag[]
private dynamicallySpecifiedConfigs: DynamicallySpecifiedConfigLoading
private loadedConfiguration: ConfigurationLoaderResult<TConfig, TModuleSpec>
private fullAppConfiguration?: CurrentAppConfiguration

constructor(
{mode, loadedConfiguration}: AppLoaderConstructorArgs<TConfig, TModuleSpec>,
Expand All @@ -298,6 +299,7 @@ class AppLoader<TConfig extends AppConfiguration, TModuleSpec extends ExtensionS

async loaded() {
const {configuration, directory, configurationLoadResultMetadata, configSchema} = this.loadedConfiguration
this.fullAppConfiguration = configuration as CurrentAppConfiguration

await logMetadataFromAppLoadingProcess(configurationLoadResultMetadata)

Expand Down Expand Up @@ -463,6 +465,7 @@ class AppLoader<TConfig extends AppConfiguration, TModuleSpec extends ExtensionS
entryPath,
directory,
specification,
fullAppConfiguration: this.fullAppConfiguration,
})

if (usedKnownSpecification) {
Expand Down
10 changes: 7 additions & 3 deletions packages/app/src/cli/models/extensions/extension-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {bundleThemeExtension} from '../../services/extensions/bundle.js'
import {Identifiers} from '../app/identifiers.js'
import {uploadWasmBlob} from '../../services/deploy/upload.js'
import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
import {CurrentAppConfiguration} from '../app/app.js'
import {ok} from '@shopify/cli-kit/node/result'
import {constantize, slugify} from '@shopify/cli-kit/common/string'
import {hashString, randomUUID} from '@shopify/cli-kit/node/crypto'
Expand Down Expand Up @@ -47,6 +48,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
handle: string
specification: ExtensionSpecification
uid: string
fullAppConfiguration?: CurrentAppConfiguration

get graphQLType() {
return (this.specification.graphQLType ?? this.specification.identifier).toUpperCase()
Expand Down Expand Up @@ -118,6 +120,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
entryPath?: string
directory: string
specification: ExtensionSpecification
fullAppConfiguration?: CurrentAppConfiguration
}) {
this.configuration = options.configuration
this.configurationPath = options.configurationPath
Expand All @@ -130,6 +133,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
this.idEnvironmentVariableName = `SHOPIFY_${constantize(this.localIdentifier)}_ID`
this.outputPath = this.directory
this.uid = this.configuration.uid ?? randomUUID()
this.fullAppConfiguration = options.fullAppConfiguration

if (this.features.includes('esbuild') || this.type === 'tax_calculation') {
this.outputPath = joinPath(this.directory, 'dist', `${this.outputFileName}`)
Expand Down Expand Up @@ -186,9 +190,9 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi

async commonDeployConfig(apiKey: string): Promise<{[key: string]: unknown} | undefined> {
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
}
Expand Down
15 changes: 10 additions & 5 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<T = object> {
forward?: (obj: T, options?: CustomTransformationConfigOptions) => object
reverse?: (obj: T, options?: CustomTransformationConfigOptions) => object
}

type ExtensionExperience = 'extension' | 'configuration'
Expand Down Expand Up @@ -64,7 +69,7 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
* @param localContent - Content taken from the local filesystem
* @returns Transformed configuration to send to the platform in place of the locally provided content
*/
transformLocalToRemote?: (localContent: object) => object
transformLocalToRemote?: (localContent: object, options?: CustomTransformationConfigOptions) => object

/**
* If required, convert configuration from the platform to the format used locally in the filesystem.
Expand All @@ -73,7 +78,7 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
* @param options - Additional options to be used in the transformation
* @returns Transformed configuration to use in place of the platform provided content
*/
transformRemoteToLocal?: (remoteContent: object, options?: {flags?: Flag[]}) => object
transformRemoteToLocal?: (remoteContent: object, options?: CustomTransformationConfigOptions) => object

uidStrategy: UidStrategy
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

Expand All @@ -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),
})
}

Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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'],
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,22 @@ function transformToWebhookSubscriptionConfig(content: object) {
}
}

const WebhookSubscriptionTransformConfig: CustomTransformationConfig = {
forward: (content: object) => content,
type WebhookContent = zod.infer<typeof SingleWebhookSubscriptionSchema>
const WebhookSubscriptionTransformConfig: CustomTransformationConfig<WebhookContent> = {
alexanderMontague marked this conversation as resolved.
Show resolved Hide resolved
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',
})

Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
)
Loading