Writing Custom Components with Spectral 3.x
Outdated Spectral Version
This page covers how to write and maintain a custom component using an outdated version of Spectral. If you are about to start a new custom component, we strongly recommend you reference the most recent guide on writing custom components. You can update your component by following the Upgrade Guides in the sidebar to the left.
#
OverviewPrismatic is extensible and allows for developer users to develop their own custom components. Components that Prismatic users develop are proprietary to their organization, and are private.
Sample component code is referenced throughout this page.
#
Custom Component LibraryPrismatic provides a NodeJS package, @prismatic-io/spectral, which provides TypeScript typing and some utility functions. Source code for Spectral is available on github.
For information on Spectral's utility functions and types, see our custom component library docs.
#
Initializing a New ComponentTo initialize a new project, run prism components:init {{ COMPONENT NAME }}
.
If you do not have Prismatic's CLI tool, prism
, installed, please take a moment to look through the Prism overview page.
prism components:init format-name
Your component name must be comprised of alphanumeric characters, hyphens, and underscores, and start and end with alphanumeric characters. This will create a directory structure that looks like this:
format-name├── assets│ └── icon.png├── jest.config.js├── package.json├── src│ ├── index.test.ts│ └── index.ts├── tsconfig.json└── webpack.config.js
assets/icon.png
is the icon that will be displayed next to your component Square transparent PNGs at least 128 x 128 pixels in size look best, and will be scaled by the web app appropriately.jest.config.js
contains configuration for the Jest testing frameworkpackage.json
is a standard node package definition filesrc/index.ts
contains the code for your component. This file can be broken into multiple files as neededsrc/index.test.ts
contains tests for component actions defined inindex.ts
. See Unit Testing Custom Componentstsconfig.json
contains configuration for TypeScriptwebpack.config.js
contains configuration for Webpack
After these files are created run cd {{ COMPONENT_NAME }}
to enter the directory of your component, and then npm install
or yarn install
to install dependencies.
#
Custom Components from WSDLs or OpenAPI SpecsThird-party apps and services often provide APIs with hundreds of RESTful endpoints. It would be tedious to manually write actions for each individual endpoint. Luckily, many companies also provide an API specification - commonly a Web Service Definition Language (WSDL) file, or an OpenAPI (Swagger) specification.
You can generate a custom component from a WSDL file with prism
by passing the --wsdl-path
flag to the components:init
subcommand:
prism components:init myThirdPartyComponent --wsdl-path ./thirdPartySpec.wsdl
You can generate a custom component from an OpenAPI definition (you can use a YAML or JSON file - both work fine) with prism
by passing the --open-api-path
flag to the components:init
subcommand:
prism components:init myThirdPartyComponent --open-api-path ./third-party-openapi-spec.json
# or
prism components:init myThirdPartyComponent --open-api-path ./third-party-openapi-spec.yaml
A directory will be created containing your new custom component, complete with input
, action
, and component
definitions.
#
Writing ActionsA component is comprised of one or many actions. For example, the HTTP component contains actions to GET (httpGet
), POST (httpPost
), etc.
An action
has three required properties and one optional one:
- Information on how the web app
display
the action - A function to
perform
- A series of
input
fields - (optional) An
authorization
block that describes what type(s) of credentials the action accepts.
const myAction = action({ display: { label: "Brief Description", description: "Longer description to display in web app UI", }, perform: async (context, params) => {}, inputs: { inputFieldOne, inputFieldTwo }, // optional: authorization: { required: true, methods: ["basic", "oauth", "api_key"], },});
#
Adding InputsComponents can take inputs
. Each input
is comprised of a required label
, and type
and optional placeholder
, default
, comments
, required
and model
.
Consider this example input:
const middleName = input({ label: "Middle Name", placeholder: "Middle name of a person", type: "string", required: false, default: "", comments: "Leave blank if the user has no middle name",});
This contributes to an input prompt that looks like this:

Note where the label
and placeholder
text appeared in the web app, and note that First Name and Last Name are required - indicated with a *
, but Middle Name is not.
#
Component Input TypesAn input can take a number of types, which affects how the input renders in the Prismatic web app:
- string will allow users to input or reference a string of characters.
- text is similar to string, but allows for multi-line input.
- boolean allows users to enter one of two values: true or false.
- code opens a code editor so users can enter XML, HTML, JSON, etc.
- conditional allows users to enter a series of logical conditionals.
This is most notably used in the branch component.
#
Creating Dropdown Menu InputsRather than allowing integration builders to enter values for an input, you might want to have users choose a value from a list of possible values. You can do that by making your input into a dropdown menu.

To create an input with a dropdown menu, add a model
property to your input:
export const acmeEnvironment = input({ label: "Acme Inc Environment to Use", placeholder: "ACME Environment", type: "string", required: true, model: [ { label: "Production", value: "https://api.acme.com/", }, { label: "Staging", value: "https://staging.acme.com/api", }, { label: "Sandbox", value: "https://sandbox.acme.com/api", }, ],});
The model
property should be an array of objects, with each object containing a label
and a value
.
The label
is shown in the dropdown menu.
The value
is passed in as the input's value to the custom component.
perform
functions#
Writing Each action contains one perform
function, which is an async function with two parameters that may or may not have a return value.
In this example firstName
, middleName
, and lastName
, are input parameters for this perform
function
const properFormatName = action({ display: { label: "Properly Format Name", description: "Properly format a person's name (Last, First M.)", }, perform: async (context, { firstName, middleName, lastName }) => { if (middleName) { return { data: `${lastName}, ${firstName} ${middleName[0]}.`, }; } else { return { data: `${lastName}, ${firstName}` }; } }, inputs: { firstName, middleName, lastName },});
perform
Function Parameters#
The perform
function takes two parameters, context
and params
, that can be destructured into their respective properties:
perform: async (context, params) => {},// orperform: async ( { logger, credential }, { paramName1, paramName2, ... }) => {},
context
Parameter#
The The context
parameter is an object that contains four attributes: logger
, credential
, instanceState
, and stepId
.
context.logger
#
context.logger
is a logging object and can be helpful to debug components.
perform: async ({ logger }, params) => { logger.info("Things are going great"); logger.warn("Now less great...");};
context.credential
#
context.credential
is an object that stores information about any credentials tied to this action in the form
{ authorizationMethod: "api_key_secret" | "basic" | "private_key" | "api_key" | "oauth2" | "oauth2_client_credentials", fields: { FIELD_NAME: String, FIELD_NAME: String, },}
The fields
presented are dependent on what type of credential is passed in.
If, for example, you use the basic
authorization type, the credential
payload might read
{ authorizationMethod: "basic", fields: { username: "MyUser", password: "MyP@$$word", },}
The credential parameter can be used in your custom component like this:
perform: async ({ logger, credential }, params) => { logger.log(`Your basic auth username is "${credential.fields.username}"`);};
context.instanceState
#
context.instanceState
is a key/value store that may be used to store small amounts of data which is persisted between instance executions.
It is most notably used by the Persist Data and Process Data components, but you can use it in your custom components, too.
If, for example, a previous instance run saved a state key of sampleKey
, you can reference context.instanceState['sampleKey']
to access that key's value.
To do the reverse, and save data to instance state for subsequent runs, add a state
property to your perform function's return value:
return { data: "Some Data", state: { exampleKey: "example value", anotherKey: [1, 2, 3] },};
Persisted data is scoped per-instance
Persisted data is scoped per-instance.
So, the same integration deployed to two different customers would have two distinct instanceState
stores.
One instance is not able to read from the other instance's instanceState
.
context.stepId
#
context.stepId
contains the unique identifier (UUID) of the step.
It is used by the Process Data - DeDuplicate action to track what items in a array have or have not been previously seen.
You can use it similarly in a custom component to persist step-specific data.
params
Parameter#
The The params
parameter is an object that has attributes for each input field the action supports.
For example, for the perform action defined above, params
has params.firstName
, params.middleName
, and params.lastName
.
firstName
, middleName
, and lastName
are based off of the input objects that are provided to the action as inputs
.
Shorthand property names
Note that we are using shorthand property names for inputs in our example.
If your input object variables have different names - say you have a const myFirstNameInput = input ({...})
, you can structure your action's input property like this:
inputs: { firstName: myFirstNameInput, middleName: myMiddleNameInput, lastName: myLastNameInput,}
and the params
object passed into perform
will have keys firstName
, middleName
, and lastName
.
Using non-shorthand property names is preferable to some developers to avoid variable shadowing.
The function is written with a destructured params
parameter.
It could be rewritten without being destructured.
perform: async (context, params) => { if (params.middleName == "") { return { data: `${params.lastName}, ${params.firstName}` }; } else { return { data: `${params.lastName}, ${params.firstName} ${params.middleName[0]}.`, }; }},
#
Coercing Input TypesTypeScript-based Node libraries often have strict rules about the type of variables that are passed into their functions, but inputs to perform
functions are of type unknown
since it's not known ahead of time what types of values users of components are going to pass in.
For example, you might expect one of your inputs to be a number
, but a user might pass in a string
instead.
That's obviously a problem since "2" + 3
is "23"
, while 2 + 3
is 5
in JavaScript.
The Spectral package includes several utility functions for coercing input to be the type of variable that you need.
Looking at the number/string example, suppose you have some input - quantity
- that you need turned into a number (even if someone passes in "123.45"
as a string), and you have another input - itemName
- that you'd like to be a string.
You can use util.types.toNumber()
and util.types.toString()
to ensure that the input has been converted to a number and string respectively:
import { action, util } from "@prismatic-io/spectral";import { someThirdPartyApiCall } from "some-example-third-party-library";
action({ /*...*/ perform: async (context, { quantity, itemName }) => { const response = await someThirdPartyApiCall({ orderQuantity: util.types.toNumber(quantity), // Guaranteed to be a number orderItemName: util.types.toString(itemName), // Guaranteed to be a string }); return { data: response }; },});
If an input cannot be coerced into the type you've chosen - for example, suppose you pass "Hello World"
into util.toNumber()
- an error will be thrown indicating that "Hello World"
cannot be coerced into a number.
#
Writing Your Own Type Checking FunctionsPrismatic provides a variety of type check and type coercion functions for common types (number, integer, string, boolean, etc). If you require a uniquely shaped object, you can create your own type check and coercion functions to ensure that inputs your custom component receives have the proper shape that the libraries you rely on expect.
You can import an interface
or type
(or write one yourself) and write a function that converts inputs into an expected shape.
For example, the SendGrid SDK expects an object that has this form:
{ "to": [string], "from": string, "subject": string, "text": string, "html": string}
We can pull in that defined type, MailDataRequired
, from the SendGrid SDK, and write a function that takes inputs and converts them to an object containing a series of strings:
import { MailDataRequired } from "@sendgrid/mail";import { util } from "@prismatic-io/spectral";
export const createEmailPayload = ({ to, from, subject, text, html,}): MailDataRequired => ({ to: util.types .toString(to) .split(",") .map((recipient: string) => recipient.trim()), from: util.types.toString(from), subject: util.types.toString(from), text: util.types.toString(text), html: util.types.toString(html),});
#
Component OutputsIn the example above, the function returns a string of the form Last, First M.
.
This return value of a custom component is accessible to subsequent steps by referencing this step's results
.
Component outputs can take many forms.
To return a simple string, number, boolean, array, or object your return block can read:
// return a string:return { data: "some string",};// return a number:return { data: 123.45,};// return a boolean:return { data: true,};// return an array:return { data: [1, 2, 3, 4, "a", "b"],};// return an object:return { data: { key1: "value1", key2: ["value2", 123], },};
Those values can be used as inputs in subsequent steps by referencing this step's results
.
#
Outputting Binary DataSome custom components will not output a number, string, boolean, array, or object, but will instead output an entire file (like an image, PDF, video, etc).
For those custom components, the return value will contain a file Buffer
as the data
return, and a contentType
key to indicate kind of file is being returned.
See Mozilla's documentation for a list of common file MIME types.
For example, if your custom component returns a rendered PDF file, and the PDF contents are saved in a Buffer
variable named pdfContents
, the return block might look like this:
return { data: pdfContents, contentType: "application/pdf",};
#
Setting Synchronous HTTP Status CodesIf you invoke your instances synchronously and would like to return an HTTP status code other than 200 - OK
, you can configure the final step of your integration to be a custom component that returns any HTTP status code you want.
To return an HTTP status code other than 200, return a statusCode
attribute in the object you return from your custom component instead of a data
attribute:
return { statusCode: 415,};
If this custom component is the last step of an integration, then the integration will return an HTTP status code of 415 if invoked synchronously.
Note: When an integration is invoked synchronously, the integration redirects the caller to a URL containing the output results of the final step of the integration.
If the final step of the integration is a Stop Execution action, or any custom component action that returns a statusCode
, the redirect does not occur and the caller receives a null
response body instead.
You should not return both data
and statusCode
attributes in a custom component.
#
Exporting a ComponentComponent code contains a default export of component
type.
A component
contains a key
that uniquely identifies it, whether or not it's public
, some information about how the web app should display
it, and an object containing the actions
that the component is comprised of.
For the "proper and improper" names example component, the export can look like this:
export default component({ key: "format-name", public: false, display: { label: "Format Name", description: "Format a person's name given a first, middle, and last name", iconPath: "icon.png", }, actions: { improperFormatName, properFormatName, },});
#
Testing a ComponentIt's important to have good unit tests for software - custom components are no exception. You want to catch errors or breaking changes before they wreak havoc on your customers' integrations. Prismatic's Spectral library provides some utility functions to make writing unit tests easier.
In the examples below, we assume that you use the Jest testing framework which is installed by default when you run prism components:init
.
You can swap Jest out for another testing framework if you like.
#
Test File Naming ConventionsTo create a unit test file, create a new file alongside your code that has the extension test.ts
(rather than .ts
).
For example, if your code lives in index.ts
, create a file named index.test.ts
.
If you separate out your component actions into actions.ts
, create a corresponding actions.test.ts
.
#
Testing Component ActionsAs described above a component action's perform
function takes two arguments:
context
is an object that contains alogger
andcredential
.params
is an object that contains input parameters as key-value pairs.
Test logger
and credential
parameters are described below.
Let's ignore them for now and look at the params
object.
Consider the example "Format Proper Name" action described previously:
export const properFormatName = action({ display: { label: "Properly Format Name", description: "Properly format a person's name (Last, First M.)", }, perform: async (context, { firstName, middleName, lastName }) => { if (middleName) { return { data: `${lastName}, ${firstName} ${middleName[0]}.`, }; } else { return { data: `${lastName}, ${firstName}` }; } }, inputs: { firstName, middleName, lastName },});
You can use the @prismatic-io/spectral/dist/testing/invoke
function to test an invocation of that action.
The invoke
function takes two required and one optional parameters:
- The action's definition (i.e.
properFormatNameAction
) - An object containing input parameters
- An object containing a logger or credentials (optional)
A Jest test file, then, could look like this:
import { properFormatName } from ".";import { invoke } from "@prismatic-io/spectral/dist/testing";
describe("Test the Proper Name formatter", () => { test("Verify first, middle, and last name", async () => { const { result } = await invoke(properFormatName, { firstName: "John", middleName: "James", lastName: "Doe", }); expect(result.data).toStrictEqual("Doe, John J."); }); test("Verify first and last name without middle", async () => { const { result } = await invoke(properFormatName, { firstName: "John", middleName: null, lastName: "Doe", }); expect(result.data).toStrictEqual("Doe, John"); });});
You can then run yarn run jest
, and Jest will run each test, returning an error code if a test failed.
#
Verifying Correct Logging in Action TestsYou may want to verify that your action generates some logs of particular severities in certain situations.
The invoke
function mentioned above also returns an object, loggerMock
, with information on what was logged during the action invocation.
You can use Jest to verify that certain lines were logged like this:
test("Ensure that an error is logged", async () => { const level = "error"; const message = "Error code 42 occurred."; const { loggerMock } = await invoke(myExampleAction, { exampleInput1, exampleInput2, }); expect(loggerMock[level]).toHaveBeenCalledWith(message);});
In the above example, the test would pass if an error
log line of Error code 42 occurred.
were generated, and would fail otherwise.
#
Providing Test Credentials to an Action TestMany actions require credentials to interact with third-party services.
You can create a credential object using @prismatic/spectral/dist/testing/credentials/*
functions:
import { credentials } from "@prismatic-io/spectral/dist/testing";
const myBasicAuthTestCredential = credentials.basic("myUsername", "myPassword");console.log(myBasicAuthTestCredential);/* { authorizationMethod: 'basic', fields: { username: 'myUsername', password: 'myPassword' } }*/
It's not good practice to hard-code credentials. Please use best practices, like setting environment variables to store credentials in your CI/CD environment:
import { credentials } from "@prismatic-io/spectral/dist/testing";
const myAwsTestCredential = credentials.apiKeySecret( process.env.AWS_ACCESS_KEY_ID, process.env.AWS_SECRET_ACCESS_KEY);console.log(myAwsTestCredential);/* { authorizationMethod: 'api_key_secret', fields: { api_key: 'AKIAIOSFODNN7EXAMPLE', api_secret: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' } }*/
A full list of credientials.*
functions is available in the Spectral typedocs.
Credentials can then be passed in as part of the context
parameter to the invoke
function, using the credential
key:
import { saveFile } from "./actions";import { credentials, invoke } from "@prismatic-io/spectral/dist/testing";
const myAwsTestCredential = credentials.apiKeySecret( process.env.AWS_ACCESS_KEY_ID, process.env.AWS_SECRET_ACCESS_KEY);
test("Test saving a file", async () => { const { result } = await invoke( saveFile, { filePath: "/path/to/file.txt", fileContents: "Hello world!", }, { credential: myAwsTestCredential, } );});
#
Publishing a ComponentPackage a component with webpack
by running npm run build
or yarn build
:
$ yarn buildyarn run v1.22.10$ webpackasset icon.png 94.2 KiB [compared for emit] [from: assets/icon.png] [copied]asset index.js 92.2 KiB [emitted] (name: main)runtime modules 1.04 KiB 5 modulesmodules by path ./node_modules/@prismatic-io/spectral/ 49.6 KiB modules by path ./node_modules/@prismatic-io/spectral/dist/types/*.js 3.92 KiB 12 modules modules by path ./node_modules/@prismatic-io/spectral/dist/*.js 21.4 KiB ./node_modules/@prismatic-io/spectral/dist/index.js 4.21 KiB [built] [code generated] ./node_modules/@prismatic-io/spectral/dist/util.js 11.9 KiB [built] [code generated] ./node_modules/@prismatic-io/spectral/dist/testing.js 5.29 KiB [built] [code generated] ./node_modules/@prismatic-io/spectral/node_modules/jest-mock/build/index.js 24.2 KiB [built] [code generated]modules by path ./node_modules/date-fns/ 16 KiB modules by path ./node_modules/date-fns/_lib/ 780 bytes ./node_modules/date-fns/_lib/toInteger/index.js 426 bytes [built] [code generated] ./node_modules/date-fns/_lib/requiredArgs/index.js 354 bytes [built] [code generated] 4 modules./src/index.ts 2.46 KiB [built] [code generated]./node_modules/valid-url/index.js 3.99 KiB [built] [code generated]webpack 5.41.1 compiled successfully in 1698 ms✨ Done in 2.86s.
This will create a dist/
directory containing your compiled JavaScript and icon image.
Now use prism
to publish your component.
If you do not have Prismatic's CLI tool, prism
, installed, please take a moment to look through the Prism overview page.
$ prism components:publish
Format Name - Format a person's name given a first, middle, and last nameWould you like to publish Format Name? (y/N): ySuccessfully submitted Format Name (v6)! The publish should finish processing shortly.
#
Upgrading Spectral VersionsWe release minor, non-breaking changes to Spectral often, and major changes periodically. Major changes come with major version bumps (1.x.x, 2.x.x, 3.x.x, etc).
To upgrade a component from an older major version of spectral to a new one, see our upgrade guides:
If you are building a new component, we strongly encourage you to start with the latest version of Spectral.