diff --git a/README.md b/README.md index 56081b3ee97..a249413762a 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,9 @@ In order to run the CLI (`./bin/run`), your current working directory needs to b # generates TypeScript definitions for an integration ./bin/run generate:types +# generate test payloads for a destination's actions +./bin/run generate-test-payload + # start local development server ./bin/run serve ``` diff --git a/codecov.yml b/codecov.yml index 0eeeec81114..be8792f5b71 100644 --- a/codecov.yml +++ b/codecov.yml @@ -12,6 +12,8 @@ codecov: # Setting this configuration as recommended for beginners in the above blog post. # We can introduce more strict settings as we progress. coverage: + ignore: + - 'packages/cli/**/*' # Exclude packages/cli directory from coverage status: project: default: diff --git a/local-development-server.postman_collection.json b/local-development-server.postman_collection.json new file mode 100644 index 00000000000..11843740ca9 --- /dev/null +++ b/local-development-server.postman_collection.json @@ -0,0 +1,327 @@ +{ + "info": { + "name": "Action Destinations API", + "description": "API collection for Segment Action Destinations", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Authenticate", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Test authentication for the destination", + "url": { + "raw": "http://localhost:3000/authenticate", + "protocol": "http", + "host": ["localhost:3000"], + "path": ["authenticate"], + "query": [], + "variable": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"settings\": {\n \"portalId\": \"\",\n \"oauth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Create Audience", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Create an audience in the destination", + "url": { + "raw": "http://localhost:3000/createAudience", + "protocol": "http", + "host": ["localhost:3000"], + "path": ["createAudience"], + "query": [], + "variable": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"settings\": {\n \"region\": \"https://advertising-api.amazon.com\",\n \"oauth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n }\n },\n \"audienceSettings\": {\n \"description\": \"\",\n \"countryCode\": \"\",\n \"externalAudienceId\": \"\",\n \"cpmCents\": 0,\n \"currency\": \"\",\n \"ttl\": 0,\n \"advertiserId\": \"\"\n },\n \"audienceName\": \"Example Audience\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Execute Action", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Execute a specific action", + "url": { + "raw": "http://localhost:3000/:actionSlug", + "protocol": "http", + "host": ["localhost:3000"], + "path": [":actionSlug"], + "query": [], + "variable": [ + { + "key": "actionSlug", + "value": "yourActionSlug" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"settings\": {\n \"region\": \"https://advertising-api.amazon.com\",\n \"oauth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n }\n },\n \"mapping\": {\n \"event_name\": {\n \"@path\": \"$.event\"\n },\n \"externalUserId\": {\n \"@path\": \"$.userId\"\n },\n \"email\": {\n \"@if\": {\n \"exists\": {\n \"@path\": \"$.context.traits.email\"\n },\n \"then\": {\n \"@path\": \"$.context.traits.email\"\n },\n \"else\": {\n \"@path\": \"$.properties.email\"\n }\n }\n },\n \"firstName\": {\n \"@path\": \"$.properties.first_name\"\n },\n \"lastName\": {\n \"@path\": \"$.properties.last_name\"\n },\n \"phone\": {\n \"@path\": \"$.properties.phone\"\n },\n \"postal\": {\n \"@path\": \"$.properties.postal\"\n },\n \"state\": {\n \"@path\": \"$.properties.state\"\n },\n \"city\": {\n \"@path\": \"$.properties.city\"\n },\n \"address\": {\n \"@path\": \"$.properties.address\"\n },\n \"audienceId\": {\n \"@path\": \"$.context.personas.external_audience_id\"\n },\n \"enable_batching\": true,\n \"batch_size\": 10000\n },\n \"payload\": {\n \"userId\": \"f5b644c4-d27b-52a2-8a7a-232b04dd856e\",\n \"anonymousId\": \"f114913c-17e3-57a7-8f20-6ccc4e808cdb\",\n \"event\": \"Audience Exited\",\n \"timestamp\": \"2025-08-22T09:22:03.320Z\",\n \"context\": {\n \"ip\": \"152.210.45.100\",\n \"userAgent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36\",\n \"page\": {\n \"path\": \"/zul\",\n \"url\": \"http://sud.sr/jipoke\",\n \"referrer\": \"http://tazdarus.com/ebiafilo\",\n \"title\": \"Biluz Buwloze\"\n },\n \"locale\": \"am\",\n \"library\": {\n \"name\": \"analytics.js\",\n \"version\": \"5.13.90\"\n },\n \"traits\": {\n \"email\": \"confof@cibva.ro\"\n },\n \"personas\": {\n \"external_audience_id\": \"f5b644c4-d27b-52a2-8a7a-232b04dd856e\",\n \"audience_settings\": {\n \"description\": \"\",\n \"countryCode\": \"\",\n \"externalAudienceId\": \"\",\n \"cpmCents\": 0,\n \"currency\": \"\",\n \"ttl\": 0,\n \"advertiserId\": \"\"\n }\n }\n },\n \"properties\": {\n \"first_name\": \"Oscar\",\n \"last_name\": \"Franco\",\n \"phone\": \"+4607552531\",\n \"postal\": \"36075\",\n \"state\": \"KY\",\n \"city\": \"Vasifgit\",\n \"address\": \"682 Confof Park\"\n },\n \"type\": \"track\"\n },\n \"auth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n },\n \"features\": {\n \"feature1\": true,\n \"feature2\": false\n },\n \"subscriptionMetadata\": {\n \"actionConfigId\": \"YOUR_ACTION_CONFIG_ID\",\n \"destinationConfigId\": \"YOUR_DESTINATION_CONFIG_ID\",\n \"actionId\": \"YOUR_ACTION_ID\",\n \"sourceId\": \"YOUR_SOURCE_ID\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Execute Dynamic Field", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Execute a dynamic field for a specific action", + "url": { + "raw": "http://localhost:3000/:actionSlug/:field", + "protocol": "http", + "host": ["localhost:3000"], + "path": [":actionSlug", ":field"], + "query": [], + "variable": [ + { + "key": "actionSlug", + "value": "yourActionSlug" + }, + { + "key": "field", + "value": "yourDynamicField" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"settings\": {\n \"ad_account_id\": \"\",\n \"send_email\": true,\n \"send_google_advertising_id\": true\n },\n \"payload\": {\n \"userId\": \"f5b644c4-d27b-52a2-8a7a-232b04dd856e\",\n \"anonymousId\": \"f114913c-17e3-57a7-8f20-6ccc4e808cdb\",\n \"event\": \"Example Event\",\n \"timestamp\": \"2025-08-22T09:15:43.262Z\",\n \"context\": {\n \"ip\": \"152.210.45.100\",\n \"userAgent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36\",\n \"page\": {\n \"path\": \"/zul\",\n \"url\": \"http://sud.sr/jipoke\",\n \"referrer\": \"http://tazdarus.com/ebiafilo\",\n \"title\": \"Biluz Buwloze\"\n },\n \"locale\": \"am\",\n \"library\": {\n \"name\": \"analytics.js\",\n \"version\": \"5.13.90\"\n }\n },\n \"type\": \"track\",\n \"traits\": {}\n },\n \"page\": 1,\n \"auth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n },\n \"audienceSettings\": {},\n \"dynamicFieldContext\": {}\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Execute Dynamic Hook Input Field", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Execute a dynamic hook input field for a specific action", + "url": { + "raw": "http://localhost:3000/:actionSlug/hooks/:hookName/dynamic/:fieldKey", + "protocol": "http", + "host": ["localhost:3000"], + "path": [":actionSlug", "hooks", ":hookName", "dynamic", ":fieldKey"], + "query": [], + "variable": [ + { + "key": "actionSlug", + "value": "yourActionSlug" + }, + { + "key": "hookName", + "value": "yourHookName" + }, + { + "key": "fieldKey", + "value": "yourFieldKey" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"settings\": {\n \"ad_account_id\": \"\",\n \"send_email\": true,\n \"send_google_advertising_id\": true,\n \"oauth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n }\n },\n \"payload\": {\n \"userId\": \"f5b644c4-d27b-52a2-8a7a-232b04dd856e\",\n \"anonymousId\": \"f114913c-17e3-57a7-8f20-6ccc4e808cdb\",\n \"event\": \"Example Event\",\n \"timestamp\": \"2025-08-22T09:23:09.236Z\",\n \"context\": {\n \"ip\": \"152.210.45.100\",\n \"userAgent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36\",\n \"page\": {\n \"path\": \"/zul\",\n \"url\": \"http://sud.sr/jipoke\",\n \"referrer\": \"http://tazdarus.com/ebiafilo\",\n \"title\": \"Biluz Buwloze\"\n },\n \"locale\": \"am\",\n \"library\": {\n \"name\": \"analytics.js\",\n \"version\": \"5.13.90\"\n }\n },\n \"type\": \"track\",\n \"traits\": {}\n },\n \"page\": 1,\n \"auth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n },\n \"audienceSettings\": {},\n \"hookInputs\": {},\n \"dynamicFieldContext\": {}\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Execute Hook", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Execute a hook for a specific action", + "url": { + "raw": "http://localhost:3000/:actionSlug/hooks/:hookName", + "protocol": "http", + "host": ["localhost:3000"], + "path": [":actionSlug", "hooks", ":hookName"], + "query": [], + "variable": [ + { + "key": "actionSlug", + "value": "yourActionSlug" + }, + { + "key": "hookName", + "value": "yourHookName" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"settings\": {\n \"ad_account_id\": \"\",\n \"send_email\": true,\n \"send_google_advertising_id\": true,\n \"oauth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n }\n },\n \"payload\": {\n \"userId\": \"f5b644c4-d27b-52a2-8a7a-232b04dd856e\",\n \"anonymousId\": \"f114913c-17e3-57a7-8f20-6ccc4e808cdb\",\n \"event\": \"Example Event\",\n \"timestamp\": \"2025-08-22T09:22:49.782Z\",\n \"context\": {\n \"ip\": \"152.210.45.100\",\n \"userAgent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36\",\n \"page\": {\n \"path\": \"/zul\",\n \"url\": \"http://sud.sr/jipoke\",\n \"referrer\": \"http://tazdarus.com/ebiafilo\",\n \"title\": \"Biluz Buwloze\"\n },\n \"locale\": \"am\",\n \"library\": {\n \"name\": \"analytics.js\",\n \"version\": \"5.13.90\"\n }\n },\n \"type\": \"track\",\n \"traits\": {}\n },\n \"page\": 1,\n \"auth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n },\n \"audienceSettings\": {},\n \"hookInputs\": {},\n \"hookOutputs\": {}\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Get Audience", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Get an audience from the destination", + "url": { + "raw": "http://localhost:3000/getAudience", + "protocol": "http", + "host": ["localhost:3000"], + "path": ["getAudience"], + "query": [], + "variable": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"settings\": {\n \"region\": \"https://advertising-api.amazon.com\",\n \"oauth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n }\n },\n \"audienceSettings\": {\n \"description\": \"\",\n \"countryCode\": \"\",\n \"externalAudienceId\": \"\",\n \"cpmCents\": 0,\n \"currency\": \"\",\n \"ttl\": 0,\n \"advertiserId\": \"\"\n },\n \"externalId\": \"AUDIENCE_ID\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Get Destination Manifest", + "event": [], + "request": { + "method": "GET", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Get the manifest of the destination", + "url": { + "raw": "http://localhost:3000/manifest", + "protocol": "http", + "host": ["localhost:3000"], + "path": ["manifest"], + "query": [], + "variable": [] + } + } + }, + { + "name": "Invoke Delete Handler", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Invokes the delete handler of the destination", + "url": { + "raw": "http://localhost:3000/delete", + "protocol": "http", + "host": ["localhost:3000"], + "path": ["delete"], + "query": [], + "variable": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"payload\": {\n \"userId\": \"f5b644c4-d27b-52a2-8a7a-232b04dd856e\",\n \"anonymousId\": \"f114913c-17e3-57a7-8f20-6ccc4e808cdb\",\n \"event\": \"Example Event\",\n \"timestamp\": \"2025-08-22T09:20:39.495Z\",\n \"context\": {\n \"ip\": \"152.210.45.100\",\n \"userAgent\": \"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36\",\n \"page\": {\n \"path\": \"/zul\",\n \"url\": \"http://sud.sr/jipoke\",\n \"referrer\": \"http://tazdarus.com/ebiafilo\",\n \"title\": \"Biluz Buwloze\"\n },\n \"locale\": \"am\",\n \"library\": {\n \"name\": \"analytics.js\",\n \"version\": \"5.13.90\"\n }\n },\n \"type\": \"delete\",\n \"traits\": {}\n },\n \"settings\": {\n \"projectToken\": \"\",\n \"apiSecret\": \"\",\n \"apiRegion\": \"US πŸ‡ΊπŸ‡Έ\",\n \"sourceName\": \"\",\n \"strictMode\": \"1\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + }, + { + "name": "Refresh Access Token", + "event": [], + "request": { + "method": "POST", + "header": [], + "auth": { + "type": "noauth" + }, + "description": "Refresh access token for the destination", + "url": { + "raw": "http://localhost:3000/refreshAccessToken", + "protocol": "http", + "host": ["localhost:3000"], + "path": ["refreshAccessToken"], + "query": [], + "variable": [] + }, + "body": { + "mode": "raw", + "raw": "{\n \"settings\": {\n \"region\": \"https://advertising-api.amazon.com\",\n \"oauth\": {\n \"accessToken\": \"YOUR_ACCESS_TOKEN\",\n \"refreshToken\": \"YOUR_REFRESH_TOKEN\"\n }\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + } + } + } + ], + "variable": [] +} diff --git a/packages/cli/src/commands/generate-test-payload.ts b/packages/cli/src/commands/generate-test-payload.ts new file mode 100644 index 00000000000..8440c24eca5 --- /dev/null +++ b/packages/cli/src/commands/generate-test-payload.ts @@ -0,0 +1,340 @@ +import { Command, flags } from '@oclif/command' +import chalk from 'chalk' +import globby from 'globby' +import ora from 'ora' +import * as path from 'path' +import { autoPrompt } from '../lib/prompt' +import { loadDestination } from '../lib/destinations' +import { DestinationDefinition } from '../lib/destinations' +import { InputField, BaseActionDefinition } from '@segment/actions-core' +import { generateSamplePayloadFromMapping, addAudienceSettingsToPayload } from '../lib/payload-generator/payload' +import { generateDestinationSettings, generateSampleFromSchema } from '../lib/payload-generator/settings' +import { generateAudienceSettings } from '../lib/payload-generator/audience' +import { + API_ENDPOINTS, + ApiEndpoint, + getApiEndpointByName, + getFormattedPath +} from '../lib/payload-generator/api-definitions' + +export default class GenerateTestPayload extends Command { + private spinner: ora.Ora = ora() + + static description = `Generates sample test payload curl commands for different APIs in a cloud mode destination.` + + static examples = [ + `$ ./bin/run generate-test-payload`, + `$ ./bin/run generate-test-payload --destination=slack`, + `$ ./bin/run generate-test-payload --destination=slack --action=postToChannel`, + `$ ./bin/run generate-test-payload --destination=slack --api="Execute Action"`, + `$ ./bin/run generate-test-payload --destination=slack --api="Create Audience"` + ] + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static flags: flags.Input = { + help: flags.help({ char: 'h' }), + destination: flags.string({ + char: 'd', + description: 'destination to generate test payloads for' + }), + action: flags.string({ + char: 'a', + description: 'specific action to generate test payload for' + }), + api: flags.boolean({ + description: 'prompt for API Selection' + }), + directory: flags.string({ + char: 'p', + description: 'destination actions directory path', + default: './packages/destination-actions/src/destinations' + }), + browser: flags.boolean({ + char: 'r', + description: 'generate payloads for browser destinations' + }) + } + + static args = [] + + async run() { + const { flags } = this.parse(GenerateTestPayload) + let destinationName = flags.destination + const isBrowser = !!flags.browser + let selectedApiEndpoint: ApiEndpoint | undefined = getApiEndpointByName('Execute Action') + + // Get the API endpoint based on the flag if provided + if (flags.api) { + // Prompt for API selection instead of exiting + const { selectedApi } = await autoPrompt<{ selectedApi: string }>(flags, { + type: 'select', + name: 'selectedApi', + message: 'Please select a valid API endpoint:', + choices: API_ENDPOINTS.map((api) => ({ + title: api.name === 'Execute Action' ? 'Execute Action (default)' : api.name, + value: api.name + })) + }) + selectedApiEndpoint = getApiEndpointByName(selectedApi) + } + + if (!selectedApiEndpoint) { + this.warn('No valid API endpoint selected. Exiting.') + this.exit(1) + } + + this.spinner.info(`Generating test payload for API: ${selectedApiEndpoint.name}`) + + if (!destinationName) { + const integrationsGlob = `${flags.directory}/*` + const integrationDirs = await globby(integrationsGlob, { + expandDirectories: false, + onlyDirectories: true, + gitignore: true, + ignore: ['node_modules'] + }) + + const { selectedDestination } = await autoPrompt<{ selectedDestination: { name: string } }>(flags, { + type: 'autocomplete', + name: 'selectedDestination', + message: 'Which destination?', + choices: integrationDirs.map((integrationPath: string) => { + const [name] = integrationPath.split(path.sep).reverse() + return { + title: name, + value: { name } + } + }) + }) + + if (selectedDestination) { + destinationName = selectedDestination.name + } + } + + if (!destinationName) { + this.warn('You must select a destination. Exiting.') + this.exit() + } + + this.spinner.start(`Loading destination: ${destinationName}`) + + const cloudEntry = path.join(process.cwd(), flags.directory, destinationName, 'index.ts') + const browserEntry = path.join(process.cwd(), flags.directory, destinationName, 'src', 'index.ts') + const targetDirectory = isBrowser ? browserEntry : cloudEntry + + const destination = await loadDestination(targetDirectory) + if (!destination) { + this.error(`Failed to load destination: ${destinationName}`) + } + + this.spinner.succeed(`Successfully loaded destination: ${destinationName}`) + + // If Execute Action API is selected, proceed with action selection + if (selectedApiEndpoint.name === 'Execute Action') { + const actions = Object.entries(destination.actions) + + if (actions.length === 0) { + this.warn('No actions found for this destination.') + this.exit() + } + + let actionToGenerate = flags.action + + if (!actionToGenerate && actions.length > 1) { + const { selectedAction } = await autoPrompt<{ selectedAction: string }>(flags, { + type: 'select', + name: 'selectedAction', + message: 'Which action to generate test payload for?', + choices: [ + { title: 'All actions', value: 'all' }, + ...actions.map(([slug, action]) => ({ + title: action.title || slug, + value: slug + })) + ] + }) + + actionToGenerate = selectedAction + } else if (!actionToGenerate) { + actionToGenerate = 'all' + } + + this.log(chalk.bold('\nTest Payload curl commands:')) + + if (actionToGenerate === 'all') { + for (const [slug, action] of actions) { + await this.generatePayloadForAction(destination, slug, action) + } + } else { + const action = actions.find(([slug]) => slug === actionToGenerate) + if (!action) { + this.warn(`Action "${actionToGenerate}" not found. Exiting.`) + this.exit(1) + return + } + + await this.generatePayloadForAction(destination, action[0], action[1]) + } + } else { + // Generate payload for selected API endpoint + await this.generatePayloadForApi(destination, selectedApiEndpoint) + } + + this.log(chalk.green(`\nDone generating test payloads! πŸŽ‰`)) + } + + async generatePayloadForAction(destination: DestinationDefinition, actionSlug: string, action: any) { + this.spinner.start(`Generating test payload for action: ${actionSlug}`) + + try { + // Generate destination settings and auth + const { settings, auth } = generateDestinationSettings(destination) + + // Get audience settings + const audienceSettings = generateAudienceSettings(destination) + + // Generate sample mapping based on action fields + const mapping = {} as Record + const fields = (action.fields || {}) as Record + + for (const [fieldKey, field] of Object.entries(fields)) { + if (field.default) { + mapping[fieldKey] = field.default + } else if (field.choices) { + // if choices is array of string, pick the first one + // if choices is array of {label: string, value: string}, then pick the value of the first one + mapping[fieldKey] = typeof field.choices[0] === 'string' ? field.choices[0] : field.choices[0].value + } + } + + const defaultSubscription = (action as BaseActionDefinition).defaultSubscription + + // Generate sample payload based on the fields. + let payload = generateSamplePayloadFromMapping(mapping, fields, defaultSubscription) + + // Add audience settings to payload if they exist + if (Object.keys(audienceSettings).length > 0) { + payload = addAudienceSettingsToPayload(payload, destination) + } + + // Generate final sample request + const sampleRequest = this.generateSampleRequest(settings, mapping, payload, auth) + + this.spinner.succeed(`Generated test payload for action: ${actionSlug}`) + + // Print the curl command to the terminal + this.log(chalk.cyan(`\n# Test payload for ${chalk.bold(destination.name)} - ${chalk.bold(actionSlug)}`)) + this.log(chalk.yellow(`curl -X POST http://localhost:3000/${actionSlug} \\`)) + this.log(chalk.yellow(` -H "Content-Type: application/json" \\`)) + this.log(chalk.yellow(` -d '${JSON.stringify(sampleRequest).replace(/'/g, "\\'")}'`)) + } catch (error) { + this.spinner.fail(`Failed to generate payload for ${actionSlug}: ${(error as Error).message}`) + } + } + + /** + * Generates a complete test request for a destination action. + */ + generateSampleRequest( + settings: unknown, + mapping: Record, + payload: Record, + auth?: unknown + ): Record { + return { + settings, + mapping, + payload, + auth, + features: { + feature1: true, + feature2: false + }, + subscriptionMetadata: { + actionConfigId: 'YOUR_ACTION_CONFIG_ID', + destinationConfigId: 'YOUR_DESTINATION_CONFIG_ID', + actionId: 'YOUR_ACTION_ID', + sourceId: 'YOUR_SOURCE_ID' + } + } + } + + /** + * Generate payload for a specific API endpoint + */ + async generatePayloadForApi(destination: DestinationDefinition, apiEndpoint: ApiEndpoint) { + this.spinner.start(`Generating test payload for API: ${apiEndpoint.name}`) + + try { + // Generate destination settings and auth + const { settings, auth } = generateDestinationSettings(destination) + + // Get audience settings if needed + const audienceSettings = generateAudienceSettings(destination) + const audienceSettingsValues = + Object.keys(audienceSettings).length > 0 ? generateSampleFromSchema(audienceSettings || {}) : {} + + // Start with the template payload from the API definition + const request = { ...apiEndpoint.requestTemplate } + + // Fill in settings and auth if the payload has those fields + if ('settings' in request && !Object.keys(request.settings).length) { + request.settings = settings + } + + if ('auth' in request && !Object.keys(request.auth || {}).length && auth) { + request.auth = auth + } + + // Fill in audience settings if applicable + if ('audienceSettings' in request && !Object.keys(request.audienceSettings || {}).length) { + request.audienceSettings = audienceSettingsValues + } + + if ('payload' in request) { + const baseEvent = generateSamplePayloadFromMapping({}, {}) + const isDeleteHandler = apiEndpoint.name === 'Invoke Delete Handler' + request.payload = { + ...baseEvent, + type: isDeleteHandler ? 'delete' : 'track', + traits: {} + } + } + + // Handle path parameters if needed + const pathParams: Record = {} + if (apiEndpoint.pathParams) { + // For now we'll use placeholders, but in the future we could prompt for these values + for (const param of apiEndpoint.pathParams) { + pathParams[param.key] = param.placeholder + } + } + + // Format the path with any parameters + const formattedPath = getFormattedPath(apiEndpoint, pathParams) + + this.spinner.succeed(`Generated test payload for API: ${apiEndpoint.name}`) + + // Print the curl command to the terminal + this.log(chalk.cyan(`\n# Test payload for ${chalk.bold(destination.name)} - ${chalk.bold(apiEndpoint.name)}`)) + + if (apiEndpoint.method === 'GET') { + this.log(chalk.yellow(`curl -X GET http://localhost:3000${formattedPath}`)) + } else { + this.log(chalk.yellow(`curl -X ${apiEndpoint.method} http://localhost:3000${formattedPath} \\`)) + this.log(chalk.yellow(` -H "Content-Type: application/json" \\`)) + this.log(chalk.yellow(` -d '${JSON.stringify(request).replace(/'/g, "\\'")}'`)) + } + } catch (error) { + this.spinner.fail(`Failed to generate payload for API ${apiEndpoint.name}: ${(error as Error).message}`) + } + } + + async catch(error: unknown) { + if (this.spinner?.isSpinning) { + this.spinner.fail() + } + throw error + } +} diff --git a/packages/cli/src/lib/event-generator.ts b/packages/cli/src/lib/event-generator.ts new file mode 100644 index 00000000000..d702b0f8992 --- /dev/null +++ b/packages/cli/src/lib/event-generator.ts @@ -0,0 +1,149 @@ +import { Condition, GroupConditionOperator, Operator } from '@segment/destination-subscriptions' +import { set } from 'lodash' + +type SegmentEvent = Record + +function isGroupConditionOperator(op: any): op is GroupConditionOperator { + return ['and', 'or'].includes(op) +} + +function handleOperator(operator: Operator, value: unknown): unknown { + switch (operator) { + case '=': + return value + case '!=': + if (value === null || value === undefined) { + return `not ${value}` + } else if (typeof value === 'string') { + return `not ${value}` + } else if (typeof value === 'number') { + return value + 1 + } else if (typeof value === 'boolean') { + return !value + } else { + return `not ${value}` + } + case '<': + if (typeof value === 'number') { + return value - 1 + } else { + return value + } + case '>': + if (typeof value === 'number') { + return value + 1 + } else { + return value + } + case '<=': + case '>=': + return value + case 'contains': + case 'ends_with': + case 'starts_with': + return value + case 'not_contains': + if (typeof value === 'string') { + return `not contains ...` + } else { + return value + } + case 'not_starts_with': + if (typeof value === 'string') { + return `not starts with ${value}` + } else { + return value + } + case 'not_ends_with': + if (typeof value === 'string') { + return `${value} ...` + } else { + return value + } + case 'exists': + return 'value' + case 'not_exists': + return undefined + case 'is_true': + return true + case 'is_false': + return false + default: + throw new Error(`Unsupported operator: ${operator}`) + } +} + +export function reconstructSegmentEvent(conditions: Condition[], baseEvent: SegmentEvent): SegmentEvent { + const event = { ...baseEvent } + + conditions.forEach((condition) => { + if (isGroupConditionOperator(condition.operator)) { + return + } + + const value = handleOperator(condition.operator, (condition as any).value) + if (value === undefined) return + + switch (condition.type) { + case 'event-type': + event.type = value as string + break + case 'event': + event.event = value as string + event.type = 'track' // event usually comes with track events + break + case 'name': + event.name = value as string + event.type = event.type ?? 'page' // name usually comes with page events + break + case 'userId': + event.userId = value as string + break + case 'event-property': + if (!event.properties) { + event.properties = {} + } + + if (condition.name) { + set(event?.properties, condition.name, value) + } + event.type = event.type ?? 'track' // properties usually come with track events + break + case 'event-trait': + if (!event.traits) { + event.traits = {} + } + + if (condition.name) { + set(event?.traits, condition.name, value) + } + event.type = event.type ?? 'identify' // traits usually come with identify events + break + case 'event-context': + if (condition.name) { + set(event.context, condition.name, value) + } + break + default: + break + } + }) + + const getEventWithoutExtraKey = (): SegmentEvent => { + if (event.type === 'track') { + const { traits, ...eventWithoutTraits } = event + + return eventWithoutTraits + } + + if (event.type === 'identify') { + const { properties, ...eventWithoutProperties } = event + + return eventWithoutProperties + } + + return event + } + + return getEventWithoutExtraKey() +} diff --git a/packages/cli/src/lib/payload-generator/api-definitions.ts b/packages/cli/src/lib/payload-generator/api-definitions.ts new file mode 100644 index 00000000000..e71a21c9ecc --- /dev/null +++ b/packages/cli/src/lib/payload-generator/api-definitions.ts @@ -0,0 +1,205 @@ +/** + * API endpoint definitions for payload generation + */ + +/** + * Type definitions for API endpoints + */ +export interface ApiEndpoint { + name: string + description: string + method: 'GET' | 'POST' + path: string + pathParams?: Array<{ + key: string + description: string + placeholder: string + }> + requestTemplate: Record +} + +/** + * Static list of API endpoints + */ +export const API_ENDPOINTS: ApiEndpoint[] = [ + { + name: 'Invoke Delete Handler', + description: 'Invokes the delete handler of the destination', + method: 'POST', + path: '/delete', + requestTemplate: { + payload: {}, + settings: {} + } + }, + { + name: 'Authenticate', + description: 'Test authentication for the destination', + method: 'POST', + path: '/authenticate', + requestTemplate: { + settings: {} + } + }, + { + name: 'Create Audience', + description: 'Create an audience in the destination', + method: 'POST', + path: '/createAudience', + requestTemplate: { + settings: {}, + audienceSettings: {}, + audienceName: 'Example Audience' + } + }, + { + name: 'Get Audience', + description: 'Get an audience from the destination', + method: 'POST', + path: '/getAudience', + requestTemplate: { + settings: {}, + audienceSettings: {}, + externalId: 'AUDIENCE_ID' + } + }, + { + name: 'Refresh Access Token', + description: 'Refresh access token for the destination', + method: 'POST', + path: '/refreshAccessToken', + requestTemplate: { + settings: {} + } + }, + { + name: 'Execute Action', + description: 'Execute a specific action', + method: 'POST', + path: '/:actionSlug', + pathParams: [ + { + key: 'actionSlug', + description: 'The slug of the action to execute', + placeholder: 'yourActionSlug' + } + ], + requestTemplate: { + payload: {}, + settings: {}, + mapping: {}, + auth: {}, + features: {}, + subscriptionMetadata: {} + } + }, + { + name: 'Execute Dynamic Field', + description: 'Execute a dynamic field for a specific action', + method: 'POST', + path: '/:actionSlug/:field', + pathParams: [ + { + key: 'actionSlug', + description: 'The slug of the action', + placeholder: 'yourActionSlug' + }, + { + key: 'field', + description: 'The dynamic field to execute', + placeholder: 'yourDynamicField' + } + ], + requestTemplate: { + settings: {}, + payload: {}, + page: 1, + auth: {}, + audienceSettings: {}, + dynamicFieldContext: {} + } + }, + { + name: 'Execute Hook', + description: 'Execute a hook for a specific action', + method: 'POST', + path: '/:actionSlug/hooks/:hookName', + pathParams: [ + { + key: 'actionSlug', + description: 'The slug of the action', + placeholder: 'yourActionSlug' + }, + { + key: 'hookName', + description: 'The name of the hook to execute', + placeholder: 'yourHookName' + } + ], + requestTemplate: { + settings: {}, + payload: {}, + page: 1, + auth: {}, + audienceSettings: {}, + hookInputs: {}, + hookOutputs: {} + } + }, + { + name: 'Execute Dynamic Hook Input Field', + description: 'Execute a dynamic hook input field for a specific action', + method: 'POST', + path: '/:actionSlug/hooks/:hookName/dynamic/:fieldKey', + pathParams: [ + { + key: 'actionSlug', + description: 'The slug of the action', + placeholder: 'yourActionSlug' + }, + { + key: 'hookName', + description: 'The name of the hook', + placeholder: 'yourHookName' + }, + { + key: 'fieldKey', + description: 'The key of the dynamic field', + placeholder: 'yourFieldKey' + } + ], + requestTemplate: { + settings: {}, + payload: {}, + page: 1, + auth: {}, + audienceSettings: {}, + hookInputs: {}, + dynamicFieldContext: {} + } + } +] + +/** + * Get an API endpoint by name + */ +export function getApiEndpointByName(name: string): ApiEndpoint | undefined { + return API_ENDPOINTS.find((api) => api.name === name) +} + +/** + * Get path with path parameters applied + */ +export function getFormattedPath(endpoint: ApiEndpoint, pathParams?: Record): string { + if (!endpoint.pathParams || !pathParams) { + return endpoint.path + } + + let formattedPath = endpoint.path + for (const param of endpoint.pathParams) { + const value = pathParams[param.key] || param.placeholder + formattedPath = formattedPath.replace(`:${param.key}`, value) + } + + return formattedPath +} diff --git a/packages/cli/src/lib/payload-generator/audience.ts b/packages/cli/src/lib/payload-generator/audience.ts new file mode 100644 index 00000000000..a2c52d94495 --- /dev/null +++ b/packages/cli/src/lib/payload-generator/audience.ts @@ -0,0 +1,26 @@ +import { AudienceDestinationDefinition } from '@segment/actions-core' +import { set } from 'lodash' +import { generateSampleFromSchema } from './settings' + +/** + * Generates audience settings based on the destination definition. + */ +export function generateAudienceSettings(destination: any): Record { + return { + ...(destination as AudienceDestinationDefinition)?.audienceFields + } +} + +/** + * Adds audience settings to a payload if applicable. + */ +export function addAudienceSettingsToPayload(payload: Record, destination: any): Record { + const audienceSettings = generateAudienceSettings(destination) + + if (Object.keys(audienceSettings).length > 0) { + const audienceSettingsValues = generateSampleFromSchema(audienceSettings || {}) + set(payload, 'context.personas.audience_settings', audienceSettingsValues) + } + + return payload +} diff --git a/packages/cli/src/lib/payload-generator/payload.ts b/packages/cli/src/lib/payload-generator/payload.ts new file mode 100644 index 00000000000..627c6929527 --- /dev/null +++ b/packages/cli/src/lib/payload-generator/payload.ts @@ -0,0 +1,244 @@ +import Chance from 'chance' +import { get, set } from 'lodash' +import { isDirective, InputField } from '@segment/actions-core' +import { getRawKeys } from '@segment/actions-core/mapping-kit/value-keys' +import { ErrorCondition, GroupCondition, parseFql } from '@segment/destination-subscriptions' +import { reconstructSegmentEvent } from '../event-generator' +import { generateSampleFromSchema } from './settings' +import { generateAudienceSettings } from './audience' + +/** + * Adds audience settings to a payload if applicable. + */ +export function addAudienceSettingsToPayload(payload: Record, destination: any): Record { + const audienceSettings = generateAudienceSettings(destination) + + if (Object.keys(audienceSettings).length > 0) { + const audienceSettingsValues = generateSampleFromSchema(audienceSettings || {}) + set(payload, 'context.personas.audience_settings', audienceSettingsValues) + } + + return payload +} + +/** + * Generates a sample payload based on the given mapping, fields, and default subscription. + */ +export function generateSamplePayloadFromMapping( + mapping: Record, + fields: Record, + defaultSubscription?: string +): Record { + const chance = new Chance('payload') + + const payload: Record = { + userId: chance.guid(), + anonymousId: chance.guid(), + event: 'Example Event', + timestamp: new Date().toISOString(), + context: { + ip: chance.ip(), + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36', + page: { + path: `/${chance.word()}`, + url: chance.url(), + referrer: chance.url(), + title: `${chance.capitalize(chance.word())} ${chance.capitalize(chance.word())}` + }, + locale: chance.locale(), + library: { + name: 'analytics.js', + version: `${chance.integer({ min: 1, max: 5 })}.${chance.integer({ min: 0, max: 20 })}.${chance.integer({ + min: 0, + max: 99 + })}` + } + } + } + + // Add properties based on mapping with better values + for (const [key, value] of Object.entries(mapping)) { + if (isDirective(value)) { + const [pathKey] = getRawKeys(value) + const path = pathKey.replace('$.', '') + const fieldDefinition = fields[key] + const existingValue = get(payload, path) + const newValue = setTestData(fieldDefinition, key) + if (typeof existingValue === 'object' && existingValue !== null && !Array.isArray(existingValue)) { + set(payload, path, { ...existingValue, ...newValue }) + } else { + set(payload, path, newValue) + } + } + } + + if (defaultSubscription) { + const parsed = parseFql(defaultSubscription) + if ((parsed as ErrorCondition).error) { + console.error(`Failed to parse FQL: ${(parsed as ErrorCondition).error}`) + } else { + const groupCondition = parsed as GroupCondition + return reconstructSegmentEvent(groupCondition.children, payload) + } + } + + return payload +} + +/** + * Sets test data for a field based on its definition and name. + */ +export function setTestData(fieldDefinition: Omit, fieldName: string) { + const chance = new Chance('payload') + const { type, format, choices, multiple } = fieldDefinition + + if (Array.isArray(choices)) { + if (typeof choices[0] === 'object' && 'value' in choices[0]) { + return choices[0].value + } + + return choices[0] + } + let val: any + switch (type) { + case 'boolean': + val = chance.bool() + break + case 'datetime': + val = '2021-02-01T00:00:00.000Z' + break + case 'integer': + val = chance.integer() + break + case 'number': + val = chance.floating({ fixed: 2 }) + break + case 'text': + val = chance.sentence() + break + case 'object': + if (fieldDefinition.properties) { + val = {} + for (const [key, prop] of Object.entries(fieldDefinition.properties)) { + val[key] = setTestData(prop as Omit, key) + } + } + break + default: + // covers string + switch (format) { + case 'date': { + const d = chance.date() + val = [d.getFullYear(), d.getMonth() + 1, d.getDate()].map((v) => String(v).padStart(2, '0')).join('-') + break + } + case 'date-time': + val = chance.date().toISOString() + break + case 'email': + val = chance.email() + break + case 'hostname': + val = chance.domain() + break + case 'ipv4': + val = chance.ip() + break + case 'ipv6': + val = chance.ipv6() + break + case 'time': { + const d = chance.date() + val = [d.getHours(), d.getMinutes(), d.getSeconds()].map((v) => String(v).padStart(2, '0')).join(':') + break + } + case 'uri': + val = chance.url() + break + case 'uuid': + val = chance.guid() + break + default: + val = generateValueByFieldName(fieldName, chance) + break + } + break + } + + if (multiple) { + val = [val] + } + + return val +} + +/** + * Generates a test value based on field name patterns. + */ +export function generateValueByFieldName(fieldKey: string, chanceInstance?: Chance.Chance): any { + const chance = chanceInstance || new Chance('payload') + const lowerFieldName = fieldKey.toLowerCase() + + // Check for common field name patterns + if (lowerFieldName.includes('email')) { + return chance.email() + } else if (lowerFieldName.includes('phone') || lowerFieldName.includes('mobile')) { + return `+${chance.phone({ formatted: false })}` + } else if (lowerFieldName.includes('name')) { + if (lowerFieldName.includes('first')) { + return chance.first() + } else if (lowerFieldName.includes('last')) { + return chance.last() + } else if (lowerFieldName.includes('full')) { + return chance.name() + } else { + return chance.name() + } + } else if (lowerFieldName.includes('url') || lowerFieldName.includes('link')) { + return chance.url() + } else if (lowerFieldName.includes('date')) { + return chance.date().toISOString() + } else if (lowerFieldName.includes('time')) { + return chance.date().toISOString() + } else if ( + lowerFieldName.includes('price') || + lowerFieldName.includes('amount') || + lowerFieldName.includes('total') + ) { + return chance.floating({ min: 1, max: 1000, fixed: 2 }) + } else if (lowerFieldName.includes('currency')) { + return chance.currency().code + } else if (lowerFieldName.includes('country')) { + return chance.country() + } else if (lowerFieldName.includes('city')) { + return chance.city() + } else if (lowerFieldName.includes('state') || lowerFieldName.includes('province')) { + return chance.state() + } else if (lowerFieldName.includes('zip') || lowerFieldName.includes('postal')) { + return chance.zip() + } else if (lowerFieldName.includes('address')) { + return chance.address() + } else if (lowerFieldName.includes('company') || lowerFieldName.includes('organization')) { + return chance.company() + } else if (lowerFieldName.includes('description')) { + return chance.paragraph() + } else if (lowerFieldName.includes('id')) { + return chance.guid() + } else if (lowerFieldName.includes('quantity') || lowerFieldName.includes('count')) { + return chance.integer({ min: 1, max: 10 }) + } else if (lowerFieldName.includes('age')) { + return chance.age() + } else if (lowerFieldName === 'gender') { + return chance.gender() + } else if ( + lowerFieldName.includes('boolean') || + lowerFieldName.includes('enabled') || + lowerFieldName.includes('active') + ) { + return chance.bool() + } else { + // Default fallback + return chance.word() + } +} diff --git a/packages/cli/src/lib/payload-generator/settings.ts b/packages/cli/src/lib/payload-generator/settings.ts new file mode 100644 index 00000000000..1b1f224ee76 --- /dev/null +++ b/packages/cli/src/lib/payload-generator/settings.ts @@ -0,0 +1,73 @@ +import { GlobalSetting } from '@segment/actions-core' +import { BrowserDestinationDefinition } from '@segment/destinations-manifest' +import { DestinationDefinition as CloudModeDestinationDefinition } from '@segment/actions-core' + +/** + * Generates sample settings based on schema. + */ +export function generateSampleFromSchema(schema: Record): Record { + const result: Record = {} + for (const [propName, setting] of Object.entries(schema)) { + if (setting.default !== undefined) { + result[propName] = setting.default + } else { + result[propName] = generatePlaceholderForSchema(setting) + } + } + + return result +} + +/** + * Generates a placeholder value based on schema type. + */ +export function generatePlaceholderForSchema(schema: GlobalSetting): any { + const type = schema.type + + switch (type) { + case 'string': + if (schema.choices) { + return schema.choices[0] + } + return `<${schema.label || 'VALUE'}>` + case 'number': + return 0 + case 'boolean': + return false + case 'password': + return `<${schema.label || 'PASSWORD'}>` + default: + return null + } +} + +/** + * Generates destination settings based on destination type. + */ +export function generateDestinationSettings(destination: any): { settings: Object; auth: Object } { + let settings: Object = {} + let auth: Object = {} + + if ((destination as BrowserDestinationDefinition).mode === 'device') { + // Generate sample settings based on destination settings schema + const destinationSettings = (destination as BrowserDestinationDefinition).settings + settings = generateSampleFromSchema(destinationSettings || {}) + } else { + const destinationSettings = (destination as CloudModeDestinationDefinition).authentication?.fields + settings = generateSampleFromSchema(destinationSettings || {}) + if ((destination as CloudModeDestinationDefinition).authentication?.scheme === 'oauth2') { + auth = { + accessToken: 'YOUR_ACCESS_TOKEN', + refreshToken: 'YOUR_REFRESH_TOKEN' + } + settings = { + ...settings, + oauth: { + ...auth + } + } + } + } + + return { settings, auth } +}