diff --git a/bin/get-graphql-schemas.js b/bin/get-graphql-schemas.js index 886ec935094..31685438520 100755 --- a/bin/get-graphql-schemas.js +++ b/bin/get-graphql-schemas.js @@ -38,6 +38,12 @@ const schemas = [ repo: 'shopify', pathToFile: 'areas/core/shopify/db/graphql/admin_schema_unstable_public.graphql', localPath: './packages/cli-kit/src/cli/api/graphql/admin/admin_schema.graphql', + }, + { + repo: 'shopify', + pathToFile: 'areas/core/shopify/db/graphql/webhooks_schema_unstable_public.graphql', + localPath: './packages/app/src/cli/api/graphql/webhooks/webhooks_schema.graphql', + branch: 'dd', } ] diff --git a/graphql.config.ts b/graphql.config.ts index b1a5c631f2d..e101f3635d2 100644 --- a/graphql.config.ts +++ b/graphql.config.ts @@ -1,7 +1,10 @@ function projectFactory(name: string, schemaName: string, project: string = 'app') { return { schema: `./packages/${project}/src/cli/api/graphql/${name}/${schemaName}`, - documents: [`./packages/${project}/src/cli/api/graphql/${name}/queries/**/*.graphql`,`./packages/${project}/src/cli/api/graphql/${name}/mutations/**/*.graphql`], + documents: [ + `./packages/${project}/src/cli/api/graphql/${name}/queries/**/*.graphql`, + `./packages/${project}/src/cli/api/graphql/${name}/mutations/**/*.graphql`, + ], extensions: { codegen: { generates: { @@ -78,5 +81,6 @@ export default { appDev: projectFactory('app-dev', 'app_dev_schema.graphql'), appManagement: projectFactory('app-management', 'app_management_schema.graphql'), admin: projectFactory('admin', 'admin_schema.graphql', 'cli-kit'), + webhooks: projectFactory('webhooks', 'webhooks_schema.graphql'), }, } diff --git a/nx.json b/nx.json index c824586b865..ec761c37fc1 100644 --- a/nx.json +++ b/nx.json @@ -54,6 +54,7 @@ "build:types", "graphql-codegen:generate:business-platform", "graphql-codegen:generate:partners", + "graphql-codegen:generate:webhooks", "graphql-codegen:postfix", "graphql-codegen:formatting" ], diff --git a/packages/app/project.json b/packages/app/project.json index 9bf718c9f18..696d41e380a 100644 --- a/packages/app/project.json +++ b/packages/app/project.json @@ -80,7 +80,8 @@ "{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/app-dev/generated/**/*.ts", - "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts" + "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts" ], "options": { "commands": [ @@ -88,7 +89,8 @@ "pnpm eslint 'src/cli/api/graphql/business-platform-destinations/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/business-platform-organizations/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/app-dev/generated/**/*.{ts,tsx}' --fix", - "pnpm eslint 'src/cli/api/graphql/app-management/generated/**/*.{ts,tsx}' --fix" + "pnpm eslint 'src/cli/api/graphql/app-management/generated/**/*.{ts,tsx}' --fix", + "pnpm eslint 'src/cli/api/graphql/webhooks/generated/**/*.{ts,tsx}' --fix" ], "cwd": "packages/app" } @@ -137,6 +139,17 @@ "cwd": "{workspaceRoot}" } }, + "graphql-codegen:generate:webhooks": { + "executor": "nx:run-commands", + "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/webhooks/**/*.graphql"], + "outputs": ["{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts"], + "options": { + "commands": [ + "pnpm exec graphql-codegen --project=webhooks" + ], + "cwd": "{workspaceRoot}" + } + }, "graphql-codegen:generate:app-management": { "executor": "nx:run-commands", "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/app-management/**/*.graphql"], @@ -155,7 +168,8 @@ "graphql-codegen:generate:business-platform-destinations", "graphql-codegen:generate:business-platform-organizations", "graphql-codegen:generate:app-dev", - "graphql-codegen:generate:app-management" + "graphql-codegen:generate:app-management", + "graphql-codegen:generate:webhooks" ], "inputs": [{ "dependentTasksOutputFiles": "**/*.ts" }], "outputs": [ @@ -163,7 +177,8 @@ "{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/app-dev/generated/**/*.ts", - "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts" + "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts" ], "options": { "commands": [ @@ -171,7 +186,8 @@ "find ./packages/app/src/cli/api/graphql/business-platform-destinations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/business-platform-organizations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/app-dev/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", - "find ./packages/app/src/cli/api/graphql/app-management/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" + "find ./packages/app/src/cli/api/graphql/app-management/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", + "find ./packages/app/src/cli/api/graphql/webhooks/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" ], "cwd": "{workspaceRoot}" } diff --git a/packages/app/src/cli/api/graphql/webhooks/generated/available-topics.ts b/packages/app/src/cli/api/graphql/webhooks/generated/available-topics.ts new file mode 100644 index 00000000000..5d800b5bdf6 --- /dev/null +++ b/packages/app/src/cli/api/graphql/webhooks/generated/available-topics.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type AvailableTopicsQueryVariables = Types.Exact<{ + apiVersion: Types.Scalars['String']['input'] +}> + +export type AvailableTopicsQuery = {availableTopics?: string[] | null} + +export const AvailableTopics = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'availableTopics'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'apiVersion'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'availableTopics'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'apiVersion'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'apiVersion'}}, + }, + ], + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/webhooks/generated/cli-testing.ts b/packages/app/src/cli/api/graphql/webhooks/generated/cli-testing.ts new file mode 100644 index 00000000000..a15253d2cc6 --- /dev/null +++ b/packages/app/src/cli/api/graphql/webhooks/generated/cli-testing.ts @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type CliTestingMutationVariables = Types.Exact<{ + address: Types.Scalars['String']['input'] + apiKey?: Types.InputMaybe + apiVersion: Types.Scalars['String']['input'] + deliveryMethod: Types.Scalars['String']['input'] + sharedSecret: Types.Scalars['String']['input'] + topic: Types.Scalars['String']['input'] +}> + +export type CliTestingMutation = { + cliTesting?: {headers?: string | null; samplePayload?: string | null; success: boolean; errors: string[]} | null +} + +export const CliTesting = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'CliTesting'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'address'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'apiKey'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'apiVersion'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'deliveryMethod'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'sharedSecret'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'topic'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'cliTesting'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'address'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'address'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'apiKey'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'apiKey'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'apiVersion'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'apiVersion'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'deliveryMethod'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'deliveryMethod'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'sharedSecret'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'sharedSecret'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'topic'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'topic'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'headers'}}, + {kind: 'Field', name: {kind: 'Name', value: 'samplePayload'}}, + {kind: 'Field', name: {kind: 'Name', value: 'success'}}, + {kind: 'Field', name: {kind: 'Name', value: 'errors'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/webhooks/generated/public-api-versions.ts b/packages/app/src/cli/api/graphql/webhooks/generated/public-api-versions.ts new file mode 100644 index 00000000000..c5cc702cd1f --- /dev/null +++ b/packages/app/src/cli/api/graphql/webhooks/generated/public-api-versions.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type PublicApiVersionsQueryVariables = Types.Exact<{[key: string]: never}> + +export type PublicApiVersionsQuery = {publicApiVersions: {handle: string}[]} + +export const PublicApiVersions = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'publicApiVersions'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'publicApiVersions'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'handle'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/webhooks/generated/types.d.ts b/packages/app/src/cli/api/graphql/webhooks/generated/types.d.ts new file mode 100644 index 00000000000..2b9d2b25a6f --- /dev/null +++ b/packages/app/src/cli/api/graphql/webhooks/generated/types.d.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention */ +export type Maybe = T | null +export type InputMaybe = Maybe +export type Exact = {[K in keyof T]: T[K]} +export type MakeOptional = Omit & {[SubKey in K]?: Maybe} +export type MakeMaybe = Omit & {[SubKey in K]: Maybe} +export type MakeEmpty = {[_ in K]?: never} +export type Incremental = T | {[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never} +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: {input: string; output: string} + String: {input: string; output: string} + Boolean: {input: boolean; output: boolean} + Int: {input: number; output: number} + Float: {input: number; output: number} +} diff --git a/packages/app/src/cli/api/graphql/webhooks/queries/available-topics.graphql b/packages/app/src/cli/api/graphql/webhooks/queries/available-topics.graphql new file mode 100644 index 00000000000..5fea722e663 --- /dev/null +++ b/packages/app/src/cli/api/graphql/webhooks/queries/available-topics.graphql @@ -0,0 +1,3 @@ +query availableTopics($apiVersion: String!) { + availableTopics(apiVersion: $apiVersion) +} diff --git a/packages/app/src/cli/api/graphql/webhooks/queries/cli-testing.graphql b/packages/app/src/cli/api/graphql/webhooks/queries/cli-testing.graphql new file mode 100644 index 00000000000..06b29c75bd8 --- /dev/null +++ b/packages/app/src/cli/api/graphql/webhooks/queries/cli-testing.graphql @@ -0,0 +1,8 @@ +mutation CliTesting($address: String!, $apiKey: String, $apiVersion: String!, $deliveryMethod: String!, $sharedSecret: String!, $topic: String!) { + cliTesting(address: $address, apiKey: $apiKey, apiVersion: $apiVersion, deliveryMethod: $deliveryMethod, sharedSecret: $sharedSecret, topic: $topic) { + headers + samplePayload + success + errors + } +} diff --git a/packages/app/src/cli/api/graphql/webhooks/queries/public-api-versions.graphql b/packages/app/src/cli/api/graphql/webhooks/queries/public-api-versions.graphql new file mode 100644 index 00000000000..03da5ff5ff4 --- /dev/null +++ b/packages/app/src/cli/api/graphql/webhooks/queries/public-api-versions.graphql @@ -0,0 +1,5 @@ +query publicApiVersions { + publicApiVersions { + handle + } +} diff --git a/packages/app/src/cli/commands/app/webhook/trigger.ts b/packages/app/src/cli/commands/app/webhook/trigger.ts index d6500fd8e22..2bf6e865998 100644 --- a/packages/app/src/cli/commands/app/webhook/trigger.ts +++ b/packages/app/src/cli/commands/app/webhook/trigger.ts @@ -115,6 +115,7 @@ export default class WebhookTrigger extends AppCommand { clientSecret: flags['client-secret'] || flags['shared-secret'], path: flags.path, config: flags.config, + organizationId: appContextResult.organization.id, } await webhookTriggerService(usedFlags) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 27c528eef38..46df212335b 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -156,6 +156,7 @@ export async function setupDevProcesses({ webs: reloadedApp.webs, backendPort: network.backendPort, frontendPort: network.frontendPort, + organizationId: remoteApp.organizationId, developerPlatformClient, storeFqdn, apiSecret, diff --git a/packages/app/src/cli/services/dev/processes/uninstall-webhook.ts b/packages/app/src/cli/services/dev/processes/uninstall-webhook.ts index f7d4cad2681..af73a463a70 100644 --- a/packages/app/src/cli/services/dev/processes/uninstall-webhook.ts +++ b/packages/app/src/cli/services/dev/processes/uninstall-webhook.ts @@ -10,6 +10,7 @@ interface SendWebhookOptions { storeFqdn: string apiSecret: string webhooksPath: string + organizationId: string } export interface SendWebhookProcess extends BaseProcess { @@ -23,6 +24,7 @@ export const sendWebhook: DevProcessFunction = async ({stdou address: `http://localhost:${options.deliveryPort}${options.webhooksPath}`, sharedSecret: options.apiSecret, storeFqdn: options.storeFqdn, + organizationId: options.organizationId, }) } @@ -31,12 +33,14 @@ export function setupSendUninstallWebhookProcess({ remoteAppUpdated, backendPort, frontendPort, + organizationId, ...options }: Pick & { remoteAppUpdated: boolean backendPort: number frontendPort: number webs: Web[] + organizationId: string }): SendWebhookProcess | undefined { const {backendConfig, frontendConfig} = frontAndBackendConfig(webs) const webhooksPath = @@ -52,6 +56,7 @@ export function setupSendUninstallWebhookProcess({ options: { deliveryPort: backendConfig ? backendPort : frontendPort, webhooksPath, + organizationId, ...options, }, } diff --git a/packages/app/src/cli/services/webhook/request-api-versions.test.ts b/packages/app/src/cli/services/webhook/request-api-versions.test.ts index 3d071fa90ed..4f3777fc4d4 100644 --- a/packages/app/src/cli/services/webhook/request-api-versions.test.ts +++ b/packages/app/src/cli/services/webhook/request-api-versions.test.ts @@ -5,7 +5,8 @@ import {describe, expect, test} from 'vitest' describe('requestApiVersions', () => { test('calls partners to request data and returns ordered array', async () => { // Given - When - const got = await requestApiVersions(testDeveloperPlatformClient()) + const organizationId = 'organizationId' + const got = await requestApiVersions(testDeveloperPlatformClient(), organizationId) // Then expect(got).toEqual(['2023', '2022', 'unstable']) diff --git a/packages/app/src/cli/services/webhook/request-api-versions.ts b/packages/app/src/cli/services/webhook/request-api-versions.ts index 2e94a9a510e..0a0d1eb9304 100644 --- a/packages/app/src/cli/services/webhook/request-api-versions.ts +++ b/packages/app/src/cli/services/webhook/request-api-versions.ts @@ -14,10 +14,14 @@ export const GetApiVersionsQuery = ` * Requests available api-versions in order to validate flags or present a list of options * * @param developerPlatformClient - The client to access the platform API + * @param organizationId - Organization ID required by the API to verify permissions * @returns List of public api-versions */ -export async function requestApiVersions(developerPlatformClient: DeveloperPlatformClient): Promise { - const {publicApiVersions: result}: PublicApiVersionsSchema = await developerPlatformClient.apiVersions() +export async function requestApiVersions( + developerPlatformClient: DeveloperPlatformClient, + organizationId: string, +): Promise { + const {publicApiVersions: result}: PublicApiVersionsSchema = await developerPlatformClient.apiVersions(organizationId) const unstableIdx = result.indexOf('unstable') if (unstableIdx === -1) { diff --git a/packages/app/src/cli/services/webhook/request-sample.test.ts b/packages/app/src/cli/services/webhook/request-sample.test.ts index 943cafead03..0a675556e34 100644 --- a/packages/app/src/cli/services/webhook/request-sample.test.ts +++ b/packages/app/src/cli/services/webhook/request-sample.test.ts @@ -9,10 +9,12 @@ const inputValues: SendSampleWebhookVariables = { address: 'https://example.org', shared_secret: 'A_SECRET', } +const organizationId = 'organizationId' + describe('getWebhookSample', () => { test('calls partners to request data without api-key', async () => { // Given/When - const got = await getWebhookSample(testDeveloperPlatformClient(), inputValues) + const got = await getWebhookSample(testDeveloperPlatformClient(), inputValues, organizationId) // Then expect(got.samplePayload).toEqual('{ "sampleField": "SampleValue" }') @@ -29,7 +31,7 @@ describe('getWebhookSample', () => { } // When - const got = await getWebhookSample(testDeveloperPlatformClient(), variables) + const got = await getWebhookSample(testDeveloperPlatformClient(), variables, organizationId) // Then expect(got.samplePayload).toEqual('{ "sampleField": "SampleValue" }') diff --git a/packages/app/src/cli/services/webhook/request-sample.ts b/packages/app/src/cli/services/webhook/request-sample.ts index 68d88edeec4..b6c6ab813c5 100644 --- a/packages/app/src/cli/services/webhook/request-sample.ts +++ b/packages/app/src/cli/services/webhook/request-sample.ts @@ -25,6 +25,7 @@ export interface UserErrors { fields: string[] } +// eslint-disable-next-line @shopify/cli/no-inline-graphql export const sendSampleWebhookMutation = ` mutation samplePayload($topic: String!, $api_version: String!, $address: String!, $delivery_method: String!, $shared_secret: String!, $api_key: String) { sendSampleWebhook(input: {topic: $topic, apiVersion: $api_version, address: $address, deliveryMethod: $delivery_method, sharedSecret: $shared_secret, apiKey: $api_key}) { @@ -53,14 +54,17 @@ export const sendSampleWebhookMutation = ` * - address - A destination for the webhook notification * - shared_secret - A secret to generate the HMAC header apps can use to validate the origin * - api_key - Client Api Key required to validate Event-Bridge addresses (optional) + * @param organizationId - Organization ID required by the API to verify permissions * @returns Empty if a remote delivery was requested, payload data if a local delivery was requested */ export async function getWebhookSample( developerPlatformClient: DeveloperPlatformClient, variables: SendSampleWebhookVariables, + organizationId: string, ): Promise { const {sendSampleWebhook: result}: SendSampleWebhookSchema = await developerPlatformClient.sendSampleWebhook( variables, + organizationId, ) return result diff --git a/packages/app/src/cli/services/webhook/request-topics.test.ts b/packages/app/src/cli/services/webhook/request-topics.test.ts index e0267d856fa..83a680fd428 100644 --- a/packages/app/src/cli/services/webhook/request-topics.test.ts +++ b/packages/app/src/cli/services/webhook/request-topics.test.ts @@ -3,11 +3,12 @@ import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {describe, expect, test} from 'vitest' const aVersion = 'SOME_VERSION' +const anOrganizationId = 'organizationId' describe('requestTopics', () => { test('calls partners to request topics data and returns array', async () => { // Given/When - const got = await requestTopics(testDeveloperPlatformClient(), aVersion) + const got = await requestTopics(testDeveloperPlatformClient(), aVersion, anOrganizationId) // Then expect(got).toEqual(['orders/create', 'shop/redact']) diff --git a/packages/app/src/cli/services/webhook/request-topics.ts b/packages/app/src/cli/services/webhook/request-topics.ts index f8b3bdc9f2d..f93a162e3a7 100644 --- a/packages/app/src/cli/services/webhook/request-topics.ts +++ b/packages/app/src/cli/services/webhook/request-topics.ts @@ -19,14 +19,16 @@ export const getTopicsQuery = ` * * @param developerPlatformClient - The client to access the platform API * @param apiVersion - ApiVersion of the topics + * @param organizationId - Organization ID required by the API to verify permissions * @returns - Available webhook topics for the api-version */ export async function requestTopics( developerPlatformClient: DeveloperPlatformClient, apiVersion: string, + organizationId: string, ): Promise { const variables: WebhookTopicsVariables = {api_version: apiVersion} - const {webhookTopics: result}: WebhookTopicsSchema = await developerPlatformClient.topics(variables) + const {webhookTopics: result}: WebhookTopicsSchema = await developerPlatformClient.topics(variables, organizationId) return result } diff --git a/packages/app/src/cli/services/webhook/send-app-uninstalled-webhook.test.ts b/packages/app/src/cli/services/webhook/send-app-uninstalled-webhook.test.ts index da1d3d3b6c5..025c8ac52dd 100644 --- a/packages/app/src/cli/services/webhook/send-app-uninstalled-webhook.test.ts +++ b/packages/app/src/cli/services/webhook/send-app-uninstalled-webhook.test.ts @@ -10,6 +10,7 @@ vi.mock('@shopify/cli-kit/node/system') const address = 'http://localhost:3000/test/path' const storeFqdn = 'test-store.myshopify.io' +const organizationId = 'organizationId' describe('sendUninstallWebhookToAppServer', () => { test('requests sample and API versions, triggers local webhook', async () => { @@ -22,6 +23,7 @@ describe('sendUninstallWebhookToAppServer', () => { sharedSecret: 'sharedSecret', storeFqdn, developerPlatformClient: testDeveloperPlatformClient(), + organizationId, }) expect(result).toBe(true) @@ -45,6 +47,7 @@ describe('sendUninstallWebhookToAppServer', () => { sharedSecret: 'sharedSecret', storeFqdn, developerPlatformClient, + organizationId, }) expect(result).toBe(false) @@ -68,6 +71,7 @@ describe('sendUninstallWebhookToAppServer', () => { sharedSecret: 'sharedSecret', storeFqdn, developerPlatformClient, + organizationId, }) expect(result).toBe(true) diff --git a/packages/app/src/cli/services/webhook/send-app-uninstalled-webhook.ts b/packages/app/src/cli/services/webhook/send-app-uninstalled-webhook.ts index 38cdc1fc0a7..a8581936c01 100644 --- a/packages/app/src/cli/services/webhook/send-app-uninstalled-webhook.ts +++ b/packages/app/src/cli/services/webhook/send-app-uninstalled-webhook.ts @@ -13,12 +13,13 @@ interface SendUninstallWebhookToAppServerOptions { storeFqdn: string address: string sharedSecret: string + organizationId: string } export async function sendUninstallWebhookToAppServer( options: SendUninstallWebhookToAppServerOptions, ): Promise { - const apiVersions = await requestApiVersions(options.developerPlatformClient) + const apiVersions = await requestApiVersions(options.developerPlatformClient, options.organizationId) const variables: SendSampleWebhookVariables = { topic: 'app/uninstalled', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -27,7 +28,7 @@ export async function sendUninstallWebhookToAppServer( delivery_method: DELIVERY_METHOD.LOCALHOST, shared_secret: options.sharedSecret, } - const sample = await getWebhookSample(options.developerPlatformClient, variables) + const sample = await getWebhookSample(options.developerPlatformClient, variables, options.organizationId) options.stdout.write('Sending APP_UNINSTALLED webhook to app server') diff --git a/packages/app/src/cli/services/webhook/trigger-options.test.ts b/packages/app/src/cli/services/webhook/trigger-options.test.ts index 3dcc2c25dcb..ab8a5afb562 100644 --- a/packages/app/src/cli/services/webhook/trigger-options.test.ts +++ b/packages/app/src/cli/services/webhook/trigger-options.test.ts @@ -18,6 +18,7 @@ const API_KEY = 'AN_API_KEY' const APP = testAppLinked() const ORGANIZATION_APP = testOrganizationApp() const developerPlatformClient = testDeveloperPlatformClient() +const organizationId = ORGANIZATION_APP.organizationId afterEach(() => { mockAndCaptureOutput().clear() @@ -30,7 +31,7 @@ describe('collectApiVersion', () => { vi.mocked(apiVersionPrompt) // When - const version = await collectApiVersion(developerPlatformClient, '2023-01') + const version = await collectApiVersion(developerPlatformClient, '2023-01', organizationId) // Then expect(version).toEqual('2023-01') @@ -43,7 +44,7 @@ describe('collectApiVersion', () => { vi.mocked(requestApiVersions).mockResolvedValue(['2023-01', 'unstable']) // When - const version = await collectApiVersion(developerPlatformClient, undefined) + const version = await collectApiVersion(developerPlatformClient, undefined, organizationId) // Then expect(version).toEqual('2023-01') @@ -59,7 +60,12 @@ describe('collectTopic', () => { vi.mocked(requestTopics).mockResolvedValue(['shop/redact', 'orders/create']) // When - const method = await collectTopic(developerPlatformClient, '2023-01', 'shop/redact') + const method = await collectTopic( + developerPlatformClient, + '2023-01', + 'shop/redact', + ORGANIZATION_APP.organizationId, + ) // Then expect(method).toEqual('shop/redact') @@ -72,7 +78,9 @@ describe('collectTopic', () => { vi.mocked(requestTopics).mockResolvedValue(['shop/redact', 'orders/create']) // When then - await expect(collectTopic(developerPlatformClient, '2023-01', 'unknown/topic')).rejects.toThrow(AbortError) + await expect(collectTopic(developerPlatformClient, '2023-01', 'unknown/topic', organizationId)).rejects.toThrow( + AbortError, + ) expect(topicPrompt).toHaveBeenCalledTimes(0) }) @@ -82,7 +90,7 @@ describe('collectTopic', () => { vi.mocked(requestTopics).mockResolvedValue(['shop/redact', 'orders/create']) // When - const topic = await collectTopic(developerPlatformClient, 'unstable', undefined) + const topic = await collectTopic(developerPlatformClient, 'unstable', undefined, organizationId) // Then expect(topic).toEqual('orders/create') diff --git a/packages/app/src/cli/services/webhook/trigger-options.ts b/packages/app/src/cli/services/webhook/trigger-options.ts index b7181bf98dc..26ca2da3838 100644 --- a/packages/app/src/cli/services/webhook/trigger-options.ts +++ b/packages/app/src/cli/services/webhook/trigger-options.ts @@ -59,13 +59,15 @@ export async function collectCredentials( * * @param developerPlatformClient - The client to access the platform API * @param apiVersion - VALID or undefined api-version + * @param organizationId - Organization ID required by the API to verify permissions * @returns api-version */ export async function collectApiVersion( developerPlatformClient: DeveloperPlatformClient, apiVersion: string | undefined, + organizationId: string, ): Promise { - const apiVersions = await requestApiVersions(developerPlatformClient) + const apiVersions = await requestApiVersions(developerPlatformClient, organizationId) if (apiVersion) return parseApiVersionFlag(apiVersion, apiVersions) return apiVersionPrompt(apiVersions) } @@ -76,18 +78,19 @@ export async function collectApiVersion( * @param developerPlatformClient - The client to access the platform API * @param apiVersion - VALID api-version * @param topic - topic or undefined + * @param organizationId - Organization ID required by the API to verify permissions * @returns topic */ export async function collectTopic( developerPlatformClient: DeveloperPlatformClient, apiVersion: string, topic: string | undefined, + organizationId: string, ): Promise { + const topics = await requestTopics(developerPlatformClient, apiVersion, organizationId) if (topic) { - return parseTopicFlag(topic, apiVersion, await requestTopics(developerPlatformClient, apiVersion)) + return parseTopicFlag(topic, apiVersion, topics) } - - const topics = await requestTopics(developerPlatformClient, apiVersion) return topicPrompt(topics) } diff --git a/packages/app/src/cli/services/webhook/trigger.test.ts b/packages/app/src/cli/services/webhook/trigger.test.ts index 26541d4559d..b17e1d22be1 100644 --- a/packages/app/src/cli/services/webhook/trigger.test.ts +++ b/packages/app/src/cli/services/webhook/trigger.test.ts @@ -23,6 +23,7 @@ const aPort = '1234' const aUrlPath = '/a/url/path' const anAddress = 'https://example.org' const anEventBridgeAddress = 'arn:aws:events:us-east-3::event-source/aws.partner/shopify.com/12/source' +const anOrganizationId = 'anOrganizationId' vi.mock('@shopify/cli-kit') vi.mock('@shopify/cli-kit/node/output') @@ -81,7 +82,7 @@ describe('webhookTriggerService', () => { await webhookTriggerService(sampleFlags()) // Then - expectCalls(aVersion) + expectCalls(aVersion, anOrganizationId) expect(consoleError).toHaveBeenCalledWith(`Request errors:\n · Some error\n · Another error`) }) @@ -104,7 +105,7 @@ describe('webhookTriggerService', () => { await webhookTriggerService(sampleFlags()) // Then - expectCalls(aVersion) + expectCalls(aVersion, anOrganizationId) expect(consoleError).toHaveBeenCalledWith(`Request errors:\n${JSON.stringify(response.userErrors)}`) }) @@ -125,8 +126,12 @@ describe('webhookTriggerService', () => { await webhookTriggerService(sampleFlags()) // Then - expectCalls(aVersion) - expect(getWebhookSample).toHaveBeenCalledWith(developerPlatformClient, expectedSampleWebhookVariables) + expectCalls(aVersion, anOrganizationId) + expect(getWebhookSample).toHaveBeenCalledWith( + developerPlatformClient, + expectedSampleWebhookVariables, + anOrganizationId, + ) expect(triggerLocalWebhook).toHaveBeenCalledTimes(0) expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery') }) @@ -174,8 +179,12 @@ describe('webhookTriggerService', () => { await webhookTriggerService(flags) // Then - expectCalls(aVersion) - expect(getWebhookSample).toHaveBeenCalledWith(developerPlatformClient, expectedSampleWebhookVariables) + expectCalls(aVersion, anOrganizationId) + expect(getWebhookSample).toHaveBeenCalledWith( + developerPlatformClient, + expectedSampleWebhookVariables, + anOrganizationId, + ) expect(outputSuccess).toHaveBeenCalledWith('Webhook has been enqueued for delivery') }) @@ -197,8 +206,12 @@ describe('webhookTriggerService', () => { await webhookTriggerService(sampleLocalhostFlags()) // Then - expectCalls(aVersion) - expect(getWebhookSample).toHaveBeenCalledWith(developerPlatformClient, expectedSampleWebhookVariables) + expectCalls(aVersion, anOrganizationId) + expect(getWebhookSample).toHaveBeenCalledWith( + developerPlatformClient, + expectedSampleWebhookVariables, + anOrganizationId, + ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) expect(outputSuccess).toHaveBeenCalledWith('Localhost delivery sucessful') }) @@ -220,8 +233,12 @@ describe('webhookTriggerService', () => { await webhookTriggerService(sampleLocalhostFlags()) // Then - expectCalls(aVersion) - expect(getWebhookSample).toHaveBeenCalledWith(developerPlatformClient, expectedSampleWebhookVariables) + expectCalls(aVersion, anOrganizationId) + expect(getWebhookSample).toHaveBeenCalledWith( + developerPlatformClient, + expectedSampleWebhookVariables, + anOrganizationId, + ) expect(triggerLocalWebhook).toHaveBeenCalledWith(aFullLocalAddress, samplePayload, sampleHeaders) expect(consoleError).toHaveBeenCalledWith('Localhost delivery failed') }) @@ -232,9 +249,9 @@ describe('webhookTriggerService', () => { vi.mocked(requestTopics).mockResolvedValue([topic]) } - function expectCalls(version: string) { - expect(requestApiVersions).toHaveBeenCalledWith(developerPlatformClient) - expect(requestTopics).toHaveBeenCalledWith(developerPlatformClient, version) + function expectCalls(version: string, organizationId: string) { + expect(requestApiVersions).toHaveBeenCalledWith(developerPlatformClient, organizationId) + expect(requestTopics).toHaveBeenCalledWith(developerPlatformClient, version, organizationId) } function sampleFlags(): WebhookTriggerInput { @@ -247,6 +264,7 @@ describe('webhookTriggerService', () => { address: anAddress, developerPlatformClient, path: '.', + organizationId: anOrganizationId, } return flags @@ -262,6 +280,7 @@ describe('webhookTriggerService', () => { address: anEventBridgeAddress, developerPlatformClient, path: '.', + organizationId: anOrganizationId, } return flags @@ -277,6 +296,7 @@ describe('webhookTriggerService', () => { address: aFullLocalAddress, developerPlatformClient, path: '.', + organizationId: anOrganizationId, } return flags diff --git a/packages/app/src/cli/services/webhook/trigger.ts b/packages/app/src/cli/services/webhook/trigger.ts index cac19d272dc..035dbb6d0fd 100644 --- a/packages/app/src/cli/services/webhook/trigger.ts +++ b/packages/app/src/cli/services/webhook/trigger.ts @@ -19,6 +19,7 @@ export interface WebhookTriggerInput { clientSecret?: string path: string config?: string + organizationId: string } interface WebhookTriggerOptions { @@ -29,6 +30,7 @@ interface WebhookTriggerOptions { clientSecret: string apiKey?: string developerPlatformClient: DeveloperPlatformClient + organizationId: string } /** @@ -45,8 +47,8 @@ export async function webhookTriggerService(input: WebhookTriggerInput) { } async function validateAndCollectFlags(input: WebhookTriggerInput): Promise { - const apiVersion = await collectApiVersion(input.developerPlatformClient, input.apiVersion) - const topic = await collectTopic(input.developerPlatformClient, apiVersion, input.topic) + const apiVersion = await collectApiVersion(input.developerPlatformClient, input.apiVersion, input.organizationId) + const topic = await collectTopic(input.developerPlatformClient, apiVersion, input.topic, input.organizationId) const [address, deliveryMethod] = await collectAddressAndMethod(input.deliveryMethod, input.address) const clientCredentials = await collectCredentials(input, deliveryMethod) @@ -58,6 +60,7 @@ async function validateAndCollectFlags(input: WebhookTriggerInput): Promise Promise updateDeveloperPreview: (input: DevelopmentStorePreviewUpdateInput) => Promise appPreviewMode: (input: FindAppPreviewModeVariables) => Promise - sendSampleWebhook: (input: SendSampleWebhookVariables) => Promise - apiVersions: () => Promise - topics: (input: WebhookTopicsVariables) => Promise + sendSampleWebhook: (input: SendSampleWebhookVariables, organizationId: string) => Promise + apiVersions: (organizationId: string) => Promise + topics: (input: WebhookTopicsVariables, organizationId: string) => Promise migrateFlowExtension: (input: MigrateFlowExtensionVariables) => Promise migrateAppModule: (input: MigrateAppModuleVariables) => Promise updateURLs: (input: UpdateURLsVariables) => Promise diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index c49525391d7..84f17cb74b5 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -10,6 +10,10 @@ import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/ import {testUIExtension, testRemoteExtensionTemplates, testOrganizationApp} from '../../models/app/app.test-data.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {ListApps} from '../../api/graphql/app-management/generated/apps.js' +import {PublicApiVersionsQuery} from '../../api/graphql/webhooks/generated/public-api-versions.js' +import {AvailableTopicsQuery} from '../../api/graphql/webhooks/generated/available-topics.js' +import {CliTesting, CliTestingMutation} from '../../api/graphql/webhooks/generated/cli-testing.js' +import {SendSampleWebhookVariables} from '../../services/webhook/request-sample.js' import {describe, expect, test, vi} from 'vitest' import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' import {fetch} from '@shopify/cli-kit/node/http' @@ -17,10 +21,12 @@ import {businessPlatformOrganizationsRequest} from '@shopify/cli-kit/node/api/bu import {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' import {BugError} from '@shopify/cli-kit/node/error' import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {webhooksRequest} from '@shopify/cli-kit/node/api/webhooks' vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/api/business-platform') vi.mock('@shopify/cli-kit/node/api/app-management') +vi.mock('@shopify/cli-kit/node/api/webhooks') const extensionA = await testUIExtension({uid: 'extension-a-uuid'}) const extensionB = await testUIExtension({uid: 'extension-b-uuid'}) @@ -288,3 +294,177 @@ describe('searching for apps', () => { await expect(client.appsForOrg(orgId)).rejects.toThrow(BugError) }) }) + +describe('apiVersions', () => { + test('fetches available public API versions', async () => { + // Given + const orgId = '1' + const mockedResponse: PublicApiVersionsQuery = { + publicApiVersions: [{handle: '2024-07'}, {handle: '2024-10'}, {handle: '2025-01'}, {handle: 'unstable'}], + } + vi.mocked(webhooksRequest).mockResolvedValueOnce(mockedResponse) + + // When + const client = new AppManagementClient() + client.token = () => Promise.resolve('token') + const apiVersions = await client.apiVersions(orgId) + + // Then + expect(apiVersions.publicApiVersions.length).toEqual(mockedResponse.publicApiVersions.length) + expect(apiVersions.publicApiVersions).toEqual(mockedResponse.publicApiVersions.map((version) => version.handle)) + }) +}) + +describe('topics', () => { + test('fetches available topics for a valid API version', async () => { + // Given + const orgId = '1' + const mockedResponse: AvailableTopicsQuery = {availableTopics: ['app/uninstalled', 'products/created']} + vi.mocked(webhooksRequest).mockResolvedValueOnce(mockedResponse) + + // When + const client = new AppManagementClient() + client.token = () => Promise.resolve('token') + const topics = await client.topics({api_version: '2024-07'}, orgId) + + // Then + expect(topics.webhookTopics.length).toEqual(mockedResponse.availableTopics?.length) + expect(topics.webhookTopics).toEqual(mockedResponse.availableTopics) + }) + + test('returns an empty list when failing', async () => { + // Given + const orgId = '1' + const mockedResponse: AvailableTopicsQuery = {availableTopics: null} + vi.mocked(webhooksRequest).mockResolvedValueOnce(mockedResponse) + + // When + const client = new AppManagementClient() + client.token = () => Promise.resolve('token') + const topics = await client.topics({api_version: 'invalid'}, orgId) + + // Then + expect(topics.webhookTopics.length).toEqual(0) + }) +}) + +describe('sendSampleWebhook', () => { + test('succeeds for local delivery', async () => { + // Given + const orgId = '1' + const input: SendSampleWebhookVariables = { + address: 'http://localhost:3000/webhooks', + api_key: 'abc123', + api_version: '2025-01', + delivery_method: 'localhost', + shared_secret: 'secret', + topic: 'app/uninstalled', + } + const mockedResponse: CliTestingMutation = { + cliTesting: { + headers: `{"Content-Type":"application/json"}`, + samplePayload: `{"id": 42,"name":"test"}`, + success: true, + errors: [], + }, + } + const expectedVariables = { + address: input.address, + apiKey: input.api_key, + apiVersion: input.api_version, + deliveryMethod: input.delivery_method, + sharedSecret: input.shared_secret, + topic: input.topic, + } + const token = 'token' + vi.mocked(webhooksRequest).mockResolvedValueOnce(mockedResponse) + + // When + const client = new AppManagementClient() + client.token = () => Promise.resolve(token) + const result = await client.sendSampleWebhook(input, orgId) + + // Then + expect(webhooksRequest).toHaveBeenCalledWith(orgId, CliTesting, token, expectedVariables) + expect(result.sendSampleWebhook.samplePayload).toEqual(mockedResponse.cliTesting?.samplePayload) + expect(result.sendSampleWebhook.headers).toEqual(mockedResponse.cliTesting?.headers) + expect(result.sendSampleWebhook.success).toEqual(true) + expect(result.sendSampleWebhook.userErrors).toEqual([]) + }) + + test('succeeds for remote delivery', async () => { + // Given + const orgId = '1' + const input: SendSampleWebhookVariables = { + address: 'https://webhooks.test', + api_key: 'abc123', + api_version: '2025-01', + delivery_method: 'http', + shared_secret: 'secret', + topic: 'app/uninstalled', + } + const mockedResponse: CliTestingMutation = { + cliTesting: { + headers: '{}', + samplePayload: '{}', + success: true, + errors: [], + }, + } + const expectedVariables = { + address: input.address, + apiKey: input.api_key, + apiVersion: input.api_version, + deliveryMethod: input.delivery_method, + sharedSecret: input.shared_secret, + topic: input.topic, + } + const token = 'token' + vi.mocked(webhooksRequest).mockResolvedValueOnce(mockedResponse) + + // When + const client = new AppManagementClient() + client.token = () => Promise.resolve(token) + const result = await client.sendSampleWebhook(input, orgId) + + // Then + expect(webhooksRequest).toHaveBeenCalledWith(orgId, CliTesting, token, expectedVariables) + expect(result.sendSampleWebhook.samplePayload).toEqual('{}') + expect(result.sendSampleWebhook.headers).toEqual('{}') + expect(result.sendSampleWebhook.success).toEqual(true) + expect(result.sendSampleWebhook.userErrors).toEqual([]) + }) + + test('handles API failures', async () => { + // Given + const orgId = '1' + const input: SendSampleWebhookVariables = { + address: 'https://webhooks.test', + api_key: 'abc123', + api_version: 'invalid', + delivery_method: 'http', + shared_secret: 'secret', + topic: 'app/uninstalled', + } + const mockedResponse: CliTestingMutation = { + cliTesting: { + headers: '{}', + samplePayload: '{}', + success: false, + errors: ['Invalid api_version'], + }, + } + vi.mocked(webhooksRequest).mockResolvedValueOnce(mockedResponse) + + // When + const client = new AppManagementClient() + client.token = () => Promise.resolve('token') + const result = await client.sendSampleWebhook(input, orgId) + + // Then + expect(result.sendSampleWebhook.samplePayload).toEqual('{}') + expect(result.sendSampleWebhook.headers).toEqual('{}') + expect(result.sendSampleWebhook.success).toEqual(false) + expect(result.sendSampleWebhook.userErrors).toEqual([{message: 'Invalid api_version', fields: []}]) + }) +}) diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index ab875512679..cd76748a4c9 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -46,7 +46,11 @@ import { } from '../../api/graphql/development_preview.js' import {AppReleaseSchema} from '../../api/graphql/app_release.js' import {AppVersionsDiffSchema} from '../../api/graphql/app_versions_diff.js' -import {SendSampleWebhookSchema, SendSampleWebhookVariables} from '../../services/webhook/request-sample.js' +import { + SampleWebhook, + SendSampleWebhookSchema, + SendSampleWebhookVariables, +} from '../../services/webhook/request-sample.js' import {PublicApiVersionsSchema} from '../../services/webhook/request-api-versions.js' import {WebhookTopicsSchema, WebhookTopicsVariables} from '../../services/webhook/request-topics.js' import { @@ -100,6 +104,9 @@ import {FetchSpecifications} from '../../api/graphql/app-management/generated/sp import {ListApps} from '../../api/graphql/app-management/generated/apps.js' import {FindOrganizations} from '../../api/graphql/business-platform-destinations/generated/find-organizations.js' import {UserInfo} from '../../api/graphql/business-platform-destinations/generated/user-info.js' +import {AvailableTopics} from '../../api/graphql/webhooks/generated/available-topics.js' +import {CliTesting} from '../../api/graphql/webhooks/generated/cli-testing.js' +import {PublicApiVersions} from '../../api/graphql/webhooks/generated/public-api-versions.js' import {ensureAuthenticatedAppManagement, ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' import {isUnitTest} from '@shopify/cli-kit/node/context/local' import {AbortError, BugError} from '@shopify/cli-kit/node/error' @@ -115,6 +122,7 @@ import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' import {versionSatisfies} from '@shopify/cli-kit/node/node-package-manager' import {outputDebug} from '@shopify/cli-kit/node/output' import {developerDashboardFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {webhooksRequest} from '@shopify/cli-kit/node/api/webhooks' const TEMPLATE_JSON_URL = 'https://cdn.shopify.com/static/cli/extensions/templates.json' @@ -709,25 +717,46 @@ export class AppManagementClient implements DeveloperPlatformClient { throw new BugError('Not implemented: appPreviewMode') } - async sendSampleWebhook(_input: SendSampleWebhookVariables): Promise { - outputDebug('⚠️ sendSampleWebhook is not implemented') - return { - sendSampleWebhook: { - samplePayload: '', - headers: '{}', - success: true, - userErrors: [], - }, + async sendSampleWebhook(input: SendSampleWebhookVariables, organizationId: string): Promise { + const query = CliTesting + const variables = { + address: input.address, + apiKey: input.api_key, + apiVersion: input.api_version, + deliveryMethod: input.delivery_method, + sharedSecret: input.shared_secret, + topic: input.topic, + } + const result = await webhooksRequest(organizationId, query, await this.token(), variables) + let sendSampleWebhook: SampleWebhook = {samplePayload: '{}', headers: '{}', success: false, userErrors: []} + const cliTesting = result.cliTesting + if (cliTesting) { + sendSampleWebhook = { + samplePayload: cliTesting.samplePayload ?? '{}', + headers: cliTesting.headers ?? '{}', + success: cliTesting.success, + userErrors: cliTesting.errors.map((error) => ({message: error, fields: []})), + } } + return {sendSampleWebhook} } - async apiVersions(): Promise { - outputDebug('⚠️ apiVersions is not implemented') - return {publicApiVersions: ['unstable']} + async apiVersions(organizationId: string): Promise { + const result = await webhooksRequest(organizationId, PublicApiVersions, await this.token(), {}) + return {publicApiVersions: result.publicApiVersions.map((version) => version.handle)} } - async topics(_input: WebhookTopicsVariables): Promise { - throw new BugError('Not implemented: topics') + async topics( + {api_version: apiVersion}: WebhookTopicsVariables, + organizationId: string, + ): Promise { + const query = AvailableTopics + const variables = {apiVersion} + const result = await webhooksRequest(organizationId, query, await this.token(), variables) + + return { + webhookTopics: result.availableTopics ?? [], + } } async migrateFlowExtension(_input: MigrateFlowExtensionVariables): Promise { diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index f25f50b35d0..0db97d7b026 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -474,15 +474,18 @@ export class PartnersClient implements DeveloperPlatformClient { return this.request(FindAppPreviewModeQuery, input) } - async sendSampleWebhook(input: SendSampleWebhookVariables): Promise { + async sendSampleWebhook( + input: SendSampleWebhookVariables, + _organizationId: string, + ): Promise { return this.request(sendSampleWebhookMutation, input) } - async apiVersions(): Promise { + async apiVersions(_organizationId: string): Promise { return this.request(GetApiVersionsQuery) } - async topics(input: WebhookTopicsVariables): Promise { + async topics(input: WebhookTopicsVariables, _organizationId: string): Promise { return this.request(getTopicsQuery, input) } diff --git a/packages/cli-kit/src/public/node/api/webhooks.ts b/packages/cli-kit/src/public/node/api/webhooks.ts new file mode 100644 index 00000000000..07f67cf6e19 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/webhooks.ts @@ -0,0 +1,45 @@ +import {graphqlRequestDoc} from './graphql.js' +import {appManagementFqdn} from '../context/fqdn.js' +import Bottleneck from 'bottleneck' +import {Variables} from 'graphql-request' +import {TypedDocumentNode} from '@graphql-typed-document-node/core' + +// API Rate limiter +// Jobs are launched every 150ms +// Only 10 requests can be executed concurrently. +const limiter = new Bottleneck({ + minTime: 150, + maxConcurrent: 10, +}) + +/** + * Executes an org-scoped GraphQL query against the App Management API. + * Uses typed documents. + * + * @param organizationId - Organization ID required to check permissions. + * @param query - GraphQL query to execute. + * @param token - Partners token. + * @param variables - GraphQL variables to pass to the query. + * @returns The response of the query of generic type . + */ +export async function webhooksRequest( + organizationId: string, + query: TypedDocumentNode, + token: string, + variables?: TVariables, +): Promise { + const api = 'Webhooks' + const fqdn = await appManagementFqdn() + const url = `https://${fqdn}/webhooks/unstable/organizations/${organizationId}/graphql.json` + const result = limiter.schedule(() => + graphqlRequestDoc({ + query, + api, + url, + token, + variables, + }), + ) + + return result +}