Code-Native Integrations
When you build an integration, you can build it in the low-code designer, or as a TypeScript project in your favorite IDE. We call an integration built with code a "code-native integration" (or CNI). This article covers how to build an integration purely in code.
Please see GitHub for a few example code-native integrations.
Regardless of how you build your integrations, the rest of the platform is the same. Both code-native and low-code integrations are deployed to the same runner environment. Both can include OAuth 2.0 connections, data sources and other advanced configuration wizard steps. Integrations built using either tool can include multiple flows, and can be added to your integration marketplace. The same logging, monitoring, and alerting tools are available for both types of integrations.
The key difference between the two is how you build the integration - you either assemble as set of low-code steps in the designer, or you write blocks of TypeScript code to accomplish the same task.
Setting up your development environment
To build a code-native integration, you'll need to set up your development environment. The requirements are the same as the requirements for building custom components. Check out this article for a detailed guide on setting up your development environment.
Initializing a new code-native integration
To initialize a new code-native integration, you can use the Prismatic CLI. Run the following command to create a new code-native integration:
prism integrations:init my-new-integration
You will be prompted to give your integration a description and select a type of connection (OAuth 2.0 or basic auth).
Then, a boilerplate TypeScript project will be created in the my-new-integration
directory.
Your project's directory will look like this:
├── .npmrc # Instructs NPM to look for component manifests in Prismatic's NPM repository
├── .spectral
│ └── index.ts # A helper file that will enable type hinting for component references
├── assets
│ └── icon.png # The icon for your integration
├── jest.config.js # Configuration for the Jest unit testing suite
├── package.json # Includes a dependency on @prismatic-io/spectral
├── src
│ ├── client.ts # Code for connecting to a third-party API
│ ├── componentRegistry.ts # Where you list components that you reference
│ ├── configPages.ts # The config wizard experience
│ ├── flows.ts # Your integration's flows
│ ├── index.test.ts # Unit testing code
│ └── index.ts # Metadata about the integration
├── tsconfig.json
└── webpack.config.js
Configuring code-native integration metadata
Your integration's name, description, and other metadata are defined in the src/index.ts
file.
The name
, description
and category
properties are customer-facing and will be visible when customers deploy your integration from the embedded marketplace.
export default integration({
name: "Example Slack Integration with CNI",
description: "My code-native Slack integration!",
category: "Communication",
labels: ["chat", "beta", "paid"],
iconPath: "icon.png",
flows,
configPages,
componentRegistry,
});
Semantic versioning in code-native integrations
Each time you publish your integration, the version number will be incremented. Versions are not synced between stacks. That means that if you've published your Salesforce integration ten times in the US stack, and twice on the EU stack, instances on v10 in the US stack will be running the same code as instances on v2 in the EU stack.
You can specify your own semantic versioning in the integration()
definition in src/index.ts
:
export default integration({
// ...
version: "1.2.3",
});
This version is accessible in the Prismatic API as the integration's externalVersion
property.
For example, this query will return these results:
query getIntegrationExternalVersions ($myIntegrationId: ID!) {
integration(
id: $myIntegrationId
) {
versionSequence {
nodes {
versionNumber
externalVersion
}
}
}
}
{
"myIntegrationId": "SW50ZWdyYXRpb246ZDRjZjlmMWYtYWI5Mi00OTJiLWI1YzAtNThjNDkwOTUzM2Mw"
}
{
"data": {
"integration": {
"versionSequence": {
"nodes": [
{
"versionNumber": 2,
"externalVersion": "1.2.3"
},
{
"versionNumber": 1,
"externalVersion": "1.2.1"
}
]
}
}
}
}
Supplying an external version in this way is optional, but a great way to determine which version of your integration is running in a given stack.
Building and importing your code-native integration
To build your project, run
npm run build
Webpack will compile your TypeScript code into a single JavaScript file, and the dist
directory will be created.
Once your integration is built, you can import your code-native integration using the prism
CLI tool:
prism integrations:import --open
The --open
flag is optional and will open the integration in the Prismatic designer, where you can configure a test instance and test datasources and flows.
By default, the integrations:import
command will use the name of the integration defined in code to determine which existing integration to replace (or, if no integration with that name exists, it will create a new integration).
If you update your integration's name in code, you will need to use a --integrationId
flag to specify the ID of the integration you want to update.
Code-native integration flows
Just like low-code integrations, code-native integrations can include multiple flows.
Flows consist of a name
and description
, and a stableKey
that uniquely identifies the flow.
It also contains four functions:
onTrigger
is called when a flow is invoked. If a flow is invoked asynchronously, you can return a custom HTTP response to the caller from the trigger.onExecution
is run immediately after theonTrigger
function and is where the bulk of the flow's work is done.onInstanceDeploy
is called when a new instance of the integration is deployed. These functions are optional and can be used to set up resources or perform other tasks when an instance is deployed.onInstanceDelete
is called when an instance of the integration is deleted,
Code-native flow triggers
The onTrigger
function of a flow is called when the flow is invoked.
A simple no-op trigger that is called asynchronously can simply return the payload it was called with:
flow({
// ...
onTrigger: async (context, payload) => {
return Promise.resolve({ payload });
},
});
If you omit the onTrigger
function, the Prismatic platform will automatically use the generic Webhook trigger.
This will yield the payload to the next StereoPannerNode, the onExecution
function.
If you want to return a custom HTTP response to the caller or would like to complete additional work in the trigger, you can additionally return an HttpResponse
object from the trigger.
In this example, suppose the trigger is invoked via an XML webhook payload that looks like this:
<notification>
<type>new_account</type>
<challenge>067DEAB4-B89C-4211-9767-84C96A39CF8C</challenge>
<account>
<first>Nelson</first>
<last>Bighetti</last>
<company>
<name>Hooli</name>
<city>Palo Alto</city>
<state>CA</state>
</company>
</account>
</notification>
The app calling the trigger requires that you parse the XML payload and return the challenge
property in the HTTP response body with an HTTP 200 Response.
You could write a trigger that parses the XML payload and returns the challenge
property:
import { HttpResponse, flow, util } from "@prismatic-io/spectral";
import { XMLParser } from "fast-xml-parser";
flow({
// ...
onTrigger: async (context, payload) => {
// Parse the raw XML from the webhook request
const parser = new XMLParser();
const parsedBody = parser.parse(util.types.toString(payload.rawBody.data));
// Respond to the request with a plaintext response that includes the challenge key
const response: HttpResponse = {
statusCode: 200,
contentType: "text/plain",
body: parsedBody.notification.challenge,
};
// Ensure that the payload is updated with the parsed body
return Promise.resolve({
payload: { ...payload, body: { data: parsedBody } },
response,
});
},
});
Running code-native flows on a schedule
Code-native flows support running on a schedule.
The schedule can either be a static schedule that you define in your code, or you can create a config variable of "schedule"
and let your customer define the schedule.
In the example below, the first flow defines a static schedule of "Run at 10:20 every day on US Central time". For tips on creating cron strings, check out crontab.guru.
The second flow uses a config variable to define the schedule.
import { configPage, flow, integration } from "@prismatic-io/spectral";
const scheduleWithCronExpression = flow({
name: "Schedule Trigger with cron expression",
description: "This flow is triggered by schedule following a cron expression",
stableKey: "schedule-trigger-cron-expression",
onExecution: async (context) => {
const now = new Date();
context.logger.info(`Flow executed at ${now}`);
return Promise.resolve({ data: null });
},
schedule: { cronExpression: "20 10 * * *", timezone: "America/Chicago" }, // Run at 10:20 AM CST
});
const scheduleWithConfigVar = flow({
name: "Schedule with config var",
description: "This flow is triggered by a schedule following a config var",
stableKey: "schedule-trigger-config-var",
onExecution: async (context) => {
const now = new Date();
context.logger.info(`Flow executed at ${now}`);
return Promise.resolve({ data: null });
},
schedule: { configVarKey: "My Schedule" }, // Run on a user-defined schedule
});
export default integration({
name: "schedule-trigger-test",
description: "Schedule Trigger Test",
iconPath: "icon.png",
flows: [scheduleWithCronExpression, scheduleWithConfigVar],
configPages: {
"Page One": configPage({
elements: {
"My Schedule": {
dataType: "schedule",
stableKey: "my-schedule",
},
},
}),
},
});
Code-native flow onInstanceDeploy and onInstanceDelete
Code-native flows support onInstanceDeploy
and onInstanceDelete
callback functions.
These functions run when an instance of the integration is deployed and deleted respectively.
These functions are handy for setting up resources or performing other tasks when an instance is deployed or deleted, and are often used to set up webhooks in third-party apps.
The functions work the same as custom trigger functions, which are documented in the Writing Custom Components article.
Code-native flow onExecution
The onExecution
function runs immediately after the onTrigger
function, and is where the bulk of the flow's work is done.
The onExecution
function takes two parameters:
context
- in addition to the attributes that a normal custom component receives (like a logger, persisted data, metadata about the integration, customer and instance), a CNI flow'scontext
object also contains aconfigVars
object which has the values of all config variables that your integration includes.params
- theparams
object contains the payload that was returned from theonTrigger
function.
This example onExecution
function performs the same logic that the low-code Build Your First Integration integration did, but in TypeScript:
import { flow } from "@prismatic-io/spectral";
import axios from "axios";
import { createSlackClient } from "../slackClient";
interface TodoItem {
id: number;
completed: boolean;
task: string;
}
export const todoAlertsFlow = flow({
// ...
onExecution: async (context) => {
// Config variables are accessed using the context object
const { logger, configVars } = context;
// Make an HTTP request to the Acme API using the config variable
const { data: todoItems } = await axios.get<TodoItem[]>(
configVars["Acme API Endpoint"],
);
// Create an HTTP Slack client using the Slack OAuth connection
const slackClient = createSlackClient(configVars["Slack OAuth Connection"]);
// Loop over the todo items
for (const item of todoItems) {
if (item.completed) {
logger.info(`Skipping completed item ${item.id}`);
} else {
// Send a message to the Slack channel for each incomplete item
logger.info(`Sending message for item ${item.id}`);
try {
await slackClient.post("chat.postMessage", {
channel: configVars["Select Slack Channel"],
text: `Incomplete task: ${item.task}`,
});
} catch (e) {
throw new Error(`Failed to send message for item ${item.id}: ${e}`);
}
}
}
// Asynchronously-invoked flows should simply return null
return { data: null };
},
});
Referencing the trigger payload in the onExecution function
The trigger will generally return the payload it received, but you can also return a modified payload from the trigger.
The onExecution
function will receive the payload that was returned from the trigger.
The trigger may receive a payload of any format, so annotating a TypeScript interface
is helpful for type hinting and code completion:
import { createSlackClient } from "../slackClient";
interface AccountNotification {
notification: {
type: string;
challenge: string;
account: {
first: string;
last: string;
company: {
name: string;
city: string;
state: string;
};
};
};
}
const sendMessagesFlow = flow({
// ...
onExecution: async (context, params) => {
const { configVars } = context;
const slackClient = createSlackClient(configVars["Slack OAuth Connection"]);
// The parsed XML payload is available in the params object
const data = params.onTrigger.results.body.data as AccountNotification;
// Construct a message to send to Slack
const message =
`New account received:\n` +
`Name: ${data.notification.account.first} ${data.notification.account.last}\n` +
`Company: ${data.notification.account.company.name}\n` +
`Location: ${data.notification.account.company.city}, ${data.notification.account.company.state}\n`;
await slackClient.post("chat.postMessage", {
channel: configVars["Select Slack Channel"],
text: message,
});
return { data: null };
},
});
Code-native integration config wizard
Just like low-code integrations, code-native integrations include a config wizard. The config wizard can include things like OAuth 2.0 connections, API key connections, dynamically-sourced UI elements (data sources), and other advanced configuration wizard steps.
A config wizard consists of multiple pages.
Each page has a title, which is derived from the key
of the configPage object, and a tagline
as well as a set of elements
(individual config variables).
For example, a config wizard might contain a page for a Slack OAuth 2.0 connection, a page where the user selects a channel from a dynamically-populated dropdown menu, and a page where a user enters two static string inputs:
import { configPage, configVar } from "@prismatic-io/spectral";
import { slackConnectionConfigVar } from "./connections";
import { slackSelectChannelDataSource } from "./dataSources";
export const configPages = {
Connections: configPage({
tagline: "Authenticate with Slack",
elements: {
"Slack OAuth Connection": slackConnectionConfigVar,
},
}),
"Slack Config": configPage({
tagline: "Select a Slack channel from a dropdown menu",
elements: {
"Select Slack Channel": slackSelectChannelDataSource,
},
}),
"Other Config": configPage({
elements: {
"Acme API Endpoint": configVar({
stableKey: "acme-api-endpoint",
dataType: "string",
description: "The endpoint to fetch TODO items from Acme",
defaultValue:
"https://my-json-server.typicode.com/prismatic-io/placeholder-data/todo",
}),
"Webhook Config Endpoint": configVar({
stableKey: "webhook-config-endpoint",
dataType: "string",
description:
"The endpoint to call when deploying or deleting an instance",
}),
},
}),
};
Config variable visibility in code-native
Each config variable can have a permissionAndVisibilityType
property with one of three values:
customer
is the default value. Customer users can view and edit the config variable, and it will always appear in the config wizard.embedded
makes it so the config variable does not show up in the config wizard, but your app is able to set it programmatically through the embedded SDK. This is helpful if you want to set an API key for a user during the configuration process, but not allow the user to see or edit the value that is set.organization
makes it so the config variable is not visible to your customer, and is not able to be set programmatically by your app. Config variables marked organization must have a default value, or else your team members will need to set the value on behalf of your customer.
Additionally, visibleToOrgDeployer
determines if an organization user will see this config variable in the config wizard UI.
While organization team members always have programmatic access to instances' config variables and their values, this helps to visually conceal some config variable values like generated metadata from data sources, etc.
Defaults to true
.
configVar({
stableKey: "debug",
dataType: "boolean",
description: "Enable debug logging",
defaultValue: "false",
permissionAndVisibilityType: "customer",
visibleToOrgDeployer: true,
});
Connections in code-native integrations
Connection definitions in CNI are very similar to custom component connection definitions, but you use the connectionConfigVar
function instead of the connection
function.
For example, a Slack OAuth 2.0 connection might look like this:
export const slackConnectionConfigVar = connectionConfigVar({
stableKey: "slack-oauth-connection",
dataType: "connection",
oauth2Type: OAuth2Type.AuthorizationCode,
inputs: {
authorizeUrl: {
label: "Authorize URL",
placeholder: "Authorize URL",
type: "string",
default: "https://slack.com/oauth/v2/authorize",
required: true,
shown: false,
comments: "The OAuth 2.0 Authorization URL for the API",
},
tokenUrl: {
label: "Token URL",
placeholder: "Token URL",
type: "string",
default: "https://slack.com/api/oauth.v2.access",
required: true,
shown: false,
comments: "The OAuth 2.0 Token URL for the API",
},
revokeUrl: {
label: "Revoke URL",
placeholder: "Revoke URL",
type: "string",
required: true,
shown: false,
comments: "The OAuth 2.0 Revocation URL for Slack",
default: "https://slack.com/api/auth.revoke",
},
scopes: {
label: "Scopes",
placeholder: "Scopes",
type: "string",
required: false,
shown: false,
comments: "Space separated OAuth 2.0 permission scopes for the API",
default: "chat:write chat:write.public channels:read",
},
clientId: {
label: "Client ID",
placeholder: "Client ID",
type: "string",
required: true,
shown: false,
comments: "Client Identifier of your app for the API",
default: SLACK_CLIENT_ID,
},
clientSecret: {
label: "Client Secret",
placeholder: "Client Secret",
type: "password",
required: true,
shown: false,
comments: "Client Secret of your app for the API",
default: SLACK_CLIENT_SECRET,
},
signingSecret: {
label: "Signing Secret",
type: "password",
required: true,
shown: false,
default: SLACK_SIGNING_SECRET,
},
},
});
The above OAuth 2.0 connection would be rendered in a config wizard as a card with a connect button that a user can click to authenticate with Slack.
Connection inputs yield objects.
To access one of the fields in the connection definition (like the signingSecret
input) in a trigger, or onExecution
function, you can access it using the configVars
object in the context
parameter.
OAuth
// Access a field defined in the connection definition
context.configVars["Slack OAuth Connection"].fields.signingSecret;
// Access the access token from the OAuth 2.0 flow
context.configVars["Slack OAuth Connection"].token?.access_token;
Data sources is code-native integrations
Data sources are defined in CNI similar to how they are defined in custom components, but you use the dataSourceConfigVar
function rather than the dataSource
function.
For example, if you would like to add a picklist dropdown menu to your config wizard that displays a list of Slack channels, you could define a data source like this:
import {
Connection,
Element,
dataSourceConfigVar,
} from "@prismatic-io/spectral";
import { createSlackClient } from "./slackClient";
import { AxiosResponse } from "axios";
interface Channel {
id: string;
name: string;
}
interface ListChannelsResponse {
ok: boolean;
channels: Channel[];
response_metadata?: {
next_cursor: string;
};
}
export const slackSelectChannelDataSource = dataSourceConfigVar({
stableKey: "slack-channel-selection",
dataSourceType: "picklist",
perform: async (context) => {
const client = createSlackClient(
context.configVars["Slack OAuth Connection"] as Connection,
);
let channels: Channel[] = [];
let cursor = null;
let counter = 1;
// Loop over pages of conversations, fetching up to 10,000 channels
// If we loop more than 10 times, we risk hitting Slack API limits,
// and returning over 10,000 channels can cause the UI to hang
do {
const response: AxiosResponse<ListChannelsResponse> = await client.get(
"conversations.list",
{
params: {
exclude_archived: true,
types: "public_channel",
cursor,
limit: 1000,
},
},
);
if (!response.data.ok) {
throw new Error(
`Error when fetching data from Slack: ${response.data}`,
);
}
channels = [...channels, ...response.data.channels];
cursor = response.data.response_metadata?.next_cursor;
counter += 1;
} while (cursor && counter < 10);
// Map conversations to key/label objects, sorted by name
const objects = channels
.sort((a, b) => (a.name < b.name ? -1 : 1))
.map<Element>((channel) => ({
key: channel.id,
label: channel.name,
}));
return { result: objects };
},
});
The above data source would be rendered in a config wizard as a picklist dropdown menu that displays a list of Slack channels.
You can build advanced UI elements, like field mappers, into your config wizard using JSON Forms data sources.
Other config variable types in code-native integrations
Other types of config variables that Prismatic supports (picklists, text inputs, schedules, etc) can be added to CNI integrations, as well.
These are generally defined inline alongside other elements
in a configPage
:
export const configPages = {
// ...
"Other Config": configPage({
"Acme API Endpoint": configVar({
stableKey: "1F886045-27E7-452B-9B44-776863F6A862",
dataType: "string",
description: "The endpoint to fetch TODO items from Acme",
defaultValue:
"https://my-json-server.typicode.com/prismatic-io/placeholder-data/todo",
}),
}),
};
Additional config wizard helper text in code-native integrations
In addition to config variables, you can add helpful text and images to guide your customers as they work through your config wizard.
To add HTML to the config wizard (which can include links, images, etc), include a string element
to a configPage
definition:
export const configPages = {
Connections: configPage({
elements: {
helpertext1: "<h2>Asana Instructions</h2>",
helpertext2:
"To generate an Asana API Key, visit the " +
'<a href="https://app.asana.com/0/my-apps" target="_blank">developer portal</a> ' +
'and select "Create new token".',
"Asana API Key": connectionConfigVar({
stableKey: "f0eab60f-545b-4b46-bebf-04d3aca6b63c",
dataType: "connection",
inputs: {
// ...
},
}),
},
}),
};
User-level config wizards in code-native integrations
If your integration relies on user-level config, you can add a user-level config wizard similar to how you create the integration's config wizard.
Within configPages.ts
create a userLevelConfigPages
object that has the same shape as configPages
:
export const userLevelConfigPages = {
Options: configPage({
elements: {
"My ULC Config Variables": configVar({
dataType: "string",
stableKey: "my-ulc-config-var",
description: "Enter a widget value",
}),
},
}),
};
Then, in index.ts
import the userLevelConfigPages
object.
Provide the object as an export of your project (so TypeScript can infer types via .spectral/index.ts
), and include it in your integration()
definition:
import { integration } from "@prismatic-io/spectral";
import flows from "./flows";
import { configPages, userLevelConfigPages } from "./configPages";
import { componentRegistry } from "./componentRegistry";
export { configPages, userLevelConfigPages } from "./configPages";
export { componentRegistry } from "./componentRegistry";
export default integration({
name: "ulc-example",
description: "My user-level config example integration",
iconPath: "icon.png",
flows,
configPages,
userLevelConfigPages,
componentRegistry,
});
Code-native flow and config variable stable keys
Flows and config variables each have a user-supplied stableKey
property.
These keys are used to uniquely identify the flow or config variable in the Prismatic API, and help guard against inadvertent changes to the name of a flow or config variable.
Without a stable key, a flow or config variable's name can be changed, and the Prismatic API will treat it as a new flow or config variable, and flows would receive new webhook URLs and config variables on instances would need to be reconfigured.
With a stable key, the Prismatic API will treat the flow or config variable as the same flow or config variable, even if its name is changed.
Stable keys can be any user-supplied string. You can choose a random UUID, or a string that describes the flow or config variable.
Endpoint configuration in code-native integrations
By default, instances of integrations that you deploy will be assigned unique webhook URLs - one URL for each flow. We call this Instance and Flow Specific endpoint configuration. Alternatively, you can choose Instance Specific endpoint configuration (each instance gets its own webhook URL and all flows share the single URL) or Shared endpoint configuration, where all flows of all instances share one URL.
To specify endpoint type, add an endpointType
property to the integration()
definition in src/index.ts
.
It can have values "instance_specific"
, "flow_specific"
or "shared_instance"
, and defaults to "flow_specific"
:
import { integration } from "@prismatic-io/spectral";
import flows from "./flows";
import { configPages } from "./configPages";
export default integration({
name: "shared-endpoint-example",
description: "Shared Endpoint Example",
iconPath: "icon.png",
flows,
configPages,
componentRegistry,
endpointType: "instance_specific",
});
When Instance Specific or Shared endpoint configuration is selected, you need some logic to determine which flow (and which customer's instance in the case of Shared) should be run. This can be done with or without a preprocess flow, and both methods are described below.
Full documentation on endpoint configuration is available in the Endpoint Configuration article.
Endpoint configuration in code-native without preprocess flow
If the flow that you want to run is specified in the webhook request's body or in a header, you can configure shared endpoint without a preprocess flow.
If, for example, the flow you want to run is specified using a header named x-acme-flow
, note that header's name in your integration definition using the triggerPreprocessFlowConfig
property:
export default integration({
name: "shared-endpoint-example",
description: "Shared Endpoint Example",
iconPath: "icon.png",
flows,
configPages,
componentRegistry,
endpointType: "instance_specific",
triggerPreprocessFlowConfig: {
flowNameField: "headers.x-acme-flow",
},
});
To invoke an instance of an execution that has been deployed, this curl
command would invoke the flow named "Create Opportunity":
curl https://hooks.prismatic.io/trigger/SW5ExampleInstanceSpecificEndpoint \
-X POST \
--header "content-type: application/json" \
--header "x-acme-flow: Create Opportunity" \
--data '{
"opportunity": {
"name": "Foo",
"amount": 10000
}
}'
If all of your instances share an endpoint, you can similarly specify a customer external ID from the request body or headers:
export default integration({
name: "shared-endpoint-example",
description: "Shared Endpoint Example",
iconPath: "icon.png",
flows,
configPages,
componentRegistry,
endpointType: "shared_instance",
triggerPreprocessFlowConfig: {
flowNameField: "headers.x-acme-flow",
externalCustomerIdField: "body.data.acmeAccountId",
},
});
curl https://hooks.prismatic.io/trigger/SW5ExampleSharedEndpoint \
-X POST \
--header "content-type: application/json" \
--header "x-acme-flow: Create Opportunity" \
--data '{
"acmeAccountId": "abc-123",
"opportunity": {
"name": "Foo",
"amount": 10000
}
}'
Endpoint configuration in code-native with preprocess flow
A preprocess flow allows you to run custom logic to determine which flow should be run (and in the case of Shared endpoint config, which customer should be run). One of your flows can look at the request body or headers, make API calls, etc., and then return the name of the flow (and customer) to run.
If you use a preprocess flow, one (and exactly one) of your flows must be marked as the preprocess flow.
You cannot specify both a preprocess flow and a triggerPreprocessFlowConfig
property.
This example preprocess flow has an onExecution
function (like any other flow).
This flow returns two properties: myFlowName
and myCustomerId
- you can name those properties whatever you like.
These property preprocessFlowConfig
specifies which properties to look for in the response from the preprocess flow:
import axios from "axios";
import { flow } from "@prismatic-io/spectral";
const flowMapper = {
create_opportunity: "Create Opportunity",
update_opportunity: "Update Opportunity",
};
interface CreateOpportunityPayload {
event: "create_opportunity";
acctId: string;
opportunity: {
name: string;
amount: number;
};
}
interface UpdateOpportunityPayload {
event: "update_opportunity";
acctId: string;
opportunity: {
id: string;
name: string;
amount: number;
};
}
type Payload = CreateOpportunityPayload | UpdateOpportunityPayload;
export const myPreprocessFlow = flow({
name: "My Preprocess Flow",
stableKey: "my-preprocess-flow",
preprocessFlowConfig: {
flowNameField: "myFlowName",
externalCustomerIdField: "myCustomerId",
},
description: "This determines which sibling flow should be invoked",
onExecution: async (context, params) => {
const { event, acctId } = params.onTrigger.results.body.data as Payload;
const customerIdResponse = await axios.post(
"https://api.example.com/get-customer-id",
{
acmeAcctId: acctId,
},
);
return Promise.resolve({
data: {
myFlowName: flowMapper[event],
myCustomerId: customerIdResponse.data.customerId,
},
});
},
});
The above preprocess flow will look at a property named event
in the request body and map an event of create_opportunity
to the string Create Opportunity
, returning Create Opportunity
as the name of the flow to run.
It will also extract an acctId
from the request body and make an HTTP request to https://api.example.com/get-customer-id
to get an external customer ID, returning that customer ID as well.
curl https://hooks.prismatic.io/trigger/SW5ExampleSharedEndpoint \
-X POST \
--header "content-type: application/json" \
--data '{
"event": "create_opportunity",
"acctId": "abc-123",
"opportunity": {
"name": "Foo",
"amount": 10000
}
}'
To create a preprocess flow for Instance Specific endpoint configuration, omit the externalCustomerIdField
property from the preprocessFlowConfig
object.
Using existing components in code-native integrations
Prismatic provides a number of existing components that you can use in your code-native integrations. By leveraging an existing component, you can save time and effort by reusing existing functionality. Both public and your private components can be used in your code-native integrations.
An example integration that uses the existing Slack OAuth 2.0 connection, Slack "Select Channel" data source, and Slack "Post Message" action is available in GitHub.
Importing types from existing components
To use an existing trigger, connection, data source or action you need to import TypeScript types from the existing component.
Importing types from public components
Manifests for Prismatic-provided public components are available through Prismatic's component manifests repository.
When you initialized your code-native integration, an .npmrc
file was created which read:
@component-manifests:registry=https://app.prismatic.io/packages/npm
That instructs your package manager to look for packages that begin with @component-manifests
in the Prismatic repository.
NPM and Yarn "Classic" (1.x) both respect a .npmrc
file.
If you are using a more recent version of yarn
, please replace the .npmrc
file with a .yarnrc.yml
file that reads:
npmScopes:
"component-manifests":
npmRegistryServer: "https://app.prismatic.io/packages/npm"
To add a component's manifest package to your code-native project, take note of the component's key and run:
npm install @component-manifests/slack
Importing types from private components
If you've developed your own custom component(s), you can do one of two things to share the component logic with the code-native integration:
-
You can abstract the logic from the custom component into a distinct package, and reference that logic in both the custom component and code-native integration. In this case, your code-native integration won't depend on a custom component - it'll run utility and helper functions from your abstracted library.
-
You can generate a component manifest, like those created for Prismatic-provided public components. To create your own component manifest, build and publish your component and then run:
Generate a component manifest from a private componentnpx @prismatic-io/spectral@latest component-manifest
This will generate a manifest project alongside your component project. You can save this project alongside your code-native project, or publish it to a package repository that you control.
Adding components to a code-native component manifest
Once the component manifest is installed, add it to componentRegistry.ts
import { componentManifests } from "@prismatic-io/spectral";
import slack from "@component-manifests/slack";
export const componentRegistry = componentManifests({
slack,
});
Behind the scenes, .spectral/index.ts
will inspect your code-native project's exported componentRegistry
object, and will provide type hinting to your TypeScript based on which components are included in your component registry.
Using existing connections in code-native
After adding an existing component's manifest to your code-native project, you can use a component's connection in your code-native integration's configPages
definition.
At the top of your config pages file, import the connectionConfigVar
function from @prismatic-io/spectral
.
Within a config page, include a connectionConfigVar()
that includes a connection
property:
import { configPage, connectionConfigVar } from "@prismatic-io/spectral";
export const configPages = {
Connections: configPage({
tagline: "Authenticate with Slack",
elements: {
"Slack OAuth Connection": connectionConfigVar({
stableKey: "my-slack-connection",
dataType: "connection",
connection: {
component: "slack",
key: "oauth2",
values: {
clientId: {
value: "REPLACE_ME_WITH_YOUR_CLIENT_ID",
permissionAndVisibilityType: "organization",
visibleToOrgDeployer: false,
},
clientSecret: {
value: "REPLACE_ME_WITH_YOUR_CLIENT_SECRET",
permissionAndVisibilityType: "organization",
visibleToOrgDeployer: false,
},
signingSecret: {
value: "REPLACE_ME_WITH_YOUR_SIGNING_SECRET",
permissionAndVisibilityType: "organization",
visibleToOrgDeployer: false,
},
scopes: {
value: "chat:write chat:write.public channels:read",
permissionAndVisibilityType: "organization",
visibleToOrgDeployer: false,
},
},
},
}),
},
}),
};
You can reference an connection template in a referenced connection if you provide a template
name value to your connection definition.
Using existing data sources in code-native
After adding an existing component's manifest to your code-native project, you can use a component's data source in your code-native integration's configPages
definition.
At the top of your config pages file, import the dataSourceConfigVar
function from @prismatic-io/spectral
.
Within a config page, include a dataSourceConfigVar()
that includes a dataSource
property:
import { configPage, dataSourceConfigVar } from "@prismatic-io/spectral";
export const configPages = {
// ...
"Slack Config": configPage({
tagline: "Select a Slack channel from a dropdown menu",
elements: {
"Select Slack Channel": dataSourceConfigVar({
stableKey: "my-slack-channel-picklist",
dataSource: {
component: "slack",
key: "selectChannels",
values: {
connection: { configVar: "Slack OAuth Connection" },
includePublicChannels: { value: "true" },
},
},
}),
},
}),
};
If the data source requires a connection, you can pass a connection to the data source using the appropriate input value by specifying a configVar
by name (connection: { configVar: "Slack OAuth Connection" },
in the example above).
Using existing triggers in code-native
After adding an existing component's manifest to your code-native project, you can use a component's trigger in your code-native integration's flow
definition.
export const existingComponentTriggerFlow = flow({
name: "Existing Component Trigger Flow",
stableKey: "6f58c32c-b29a-4f55-97e6-b86bf9e24551",
description: "This flow uses an existing component trigger",
onTrigger: {
component: "hash",
key: "hmacWebhookTrigger",
values: {
hmacHeaderName: { value: "X-Signature-256" },
secretKey: { configVar: "My Secret Key Config Var" },
hashFunction: { value: "sha256" },
},
},
onExecution: async (context, params) => {
const { logger } = context;
logger.info(`Action context: ${JSON.stringify(context)}`);
logger.info(`Action params: ${JSON.stringify(params)}`);
return Promise.resolve({ data: null });
},
});
If the trigger takes inputs, those inputs are passed to the trigger as values
, and values can be either static string value
, or can reference config variables with configVar: "Config Variable Name"
.
Using existing actions in code-native
After importing an existing component's manifest and adding it to your componentManifests
export, you can call one of the component's actions from within your flow
.
Components are accessible through the context.components
object.
For example,
export const existingComponentTriggerFlow = flow({
name: "Send a Slack Message",
stableKey: "send-a-slack-message",
description: "Send 'Hello World' to a Slack channel",
onExecution: async (context, params) => {
await context.components.slack.postMessage({
channelName: util.types.toString(configVars["Select Slack Channel"]),
connection: configVars["Slack OAuth Connection"],
message: `Incomplete item: ${item.task}`,
});
return { data: null };
},
});
Testing a code-native integration
There are two types of testing that you can do with a code-native integration: you can run unit tests of your code locally in your IDE, and you can import the integration and test it in the Prismatic runner.
- Unit tests in your IDE are great for testing the logic of your integration and testing modular portions of your code
- Testing in the Prismatic runner is great for trying out the configuration wizard experience you've built and testing the integration's behavior in a real-world environment.
You will probably want to incorporate both types of testing into your development process.
Testing a code-native integration in Prismatic
After building with npm run build
and importing your code-native integration with prism integrations:import --open
you can test your integration in the Prismatic runner similar to how you test a low-code integration.
To test your config wizard experience, click the Test Configuration button. To run a test of a flow, select the flow from the dropdown menu on the top right of the page and then click the green Run button. When you're satisfied with your integration, you can click Publish to publish a new version of your integration, and manage instances of your integration from the Management tab.
A code-native integration has no steps - just a trigger and onExecution
function, so there's no step results to inspect.
To debug your integration, use the context.logger
object in your onExecution
.
You can even conditionally log lines based on a config variable you create.
You can make the debug config variable invisible to customer users.
configVar({
stableKey: "debug",
dataType: "boolean",
description: "Enable debug logging",
defaultValue: "false",
orgOnly: true,
visibleToCustomerDeployer: false,
});
Unit tests for code-native integrations
You can also write unit tests for your code-native integration, similar to unit tests for custom components.
The invokeFlow
function from the custom component SDK is used to invoke a test of a flow in a code-native integration.
You can specify a sample payload to "send" to your flow's onTrigger
function, and the invokeFlow
function will run both onTrigger
and onExecution
and return the result of the flow's onExecution
function.
Below is a simple flow that takes a payload and sends the payload to an API, returning the results of the API call. The corresponding unit test code invokes the flow, "sending" a sample payload and verifying that the results received are as expected.
- CNI Code
- Unit test code
import {
configPage,
configVar,
flow,
integration,
} from "@prismatic-io/spectral";
import axios from "axios";
const configPages = {
"Acme Config": configPage({
elements: {
"Acme API Endpoint": configVar({
stableKey: "1F886045-27E7-452B-9B44-776863F6A862",
dataType: "string",
description: "The endpoint to fetch TODO items from Acme",
defaultValue:
"https://my-json-server.typicode.com/prismatic-io/placeholder-data/todo",
}),
"Acme API Key": configVar({
stableKey: "webhook-config-endpoint",
dataType: "string",
description:
"The endpoint to call when deploying or deleting an instance",
}),
},
}),
};
export const myFlow = flow({
name: "Create Acme Opportunity",
stableKey: "create-acme-opportunity",
description: "Create an opportunity in Acme",
onExecution: async (context, params) => {
const { id, name, value } = params.onTrigger.results.body.data as {
id: string;
name: string;
value: number;
};
if (value < 0) {
throw new Error("Invalid value - values cannot be negative");
}
const acmeEndpoint = context.configVars["Acme API Endpoint"];
const response = await axios.post(
`${acmeEndpoint}/opportunity`,
{ id, name, value },
{
headers: {
Authorization: `Bearer ${context.configVars["Acme API Key"]}`,
},
},
);
return { data: response.data };
},
});
export default integration({
name: "acme-cni",
description: "Acme CNI",
iconPath: "icon.png",
flows: [myFlow],
configPages,
componentRegistry,
});
import { myFlow } from ".";
import {
invokeFlow,
defaultTriggerPayload,
} from "@prismatic-io/spectral/dist/testing";
interface MyFlowResponse {
externalId: string;
id: string;
name: string;
value: number;
}
describe("test myFlow", () => {
test("Verify that the API returns an external ID that matches the specified ID", async () => {
const { result } = await invokeFlow(
myFlow,
{
"Acme API Endpoint": "https://staging.api.example.com",
"Acme API Key": "my-api-key",
},
{},
{
...defaultTriggerPayload(),
body: {
data: { id: "123", name: "my-opportunity", value: 1000 },
contentType: "application/json",
},
},
);
expect((result?.data as MyFlowResponse).externalId).toBe("123");
});
test("Verify that errors are thrown when provided negative values", async () => {
await expect(
invokeFlow(
myFlow,
{
"Acme API Endpoint": "https://staging.api.example.com",
"Acme API Key": "my-api-key",
},
{},
{
...defaultTriggerPayload(),
body: {
data: { id: "123", name: "my-opportunity", value: -1000 },
contentType: "application/json",
},
},
),
).rejects.toThrow("Invalid value - values cannot be negative");
});
});
You can run a unit test with
npm run test
Note that if your code-native integration depends on existing components' actions, your local environment does not have the necessary component code and you must test your integration within Prismatic.
Unit testing a code-native integration with an OAuth 2.0 connection
If your integration includes an OAuth 2.0 connection, you can use the same strategy outlined in the Unit Testing Custom Components guide.
Both custom components and code-native integrations can take advantage of the prism components:dev:run
command to fetch an established connection from an existing test instance.