Skip to main content

Writing Custom Components with Spectral 1.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.

Writing Actions#

A component is comprised of one or many actions. For example, the HTTP component contains actions to GET (httpGet), POST (httpPost), etc.

An action takes four arguments:

  1. A unique key name of the action, preferably in camelCase.
  2. Information on how the web app display the action
  3. A function to perform
  4. A series of input fields
action({  key: "myAction",  display: {    label: "Brief Description",    description: "Longer description to display in web app UI",  },  perform: functionToPerform(),  inputs: [inputFieldOne, inputFieldTwo],});

Adding Inputs#

Components can take inputs. Each input is comprised of a required key, label, and type and optional placeholder, default, comments, required and model.

Consider this example input:

const middleNameInputField = input({  key: "middleName",  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.

Input key names

The key specified above (in this case, "middleName") is accessible as an input for the perform function.

Component Input Types#

An 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 Inputs#

Rather 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 environment = input({  key: "acmeEnvironment",  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.

Writing perform functions#

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

export const properFormatNameAction = action({  key: "properFormatName",  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: [firstNameInputField, middleNameInputField, lastNameInputField],});

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, ... }) => {},

The context Parameter#

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 -- see Authorization Methods. 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] },};

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.

The params Parameter#

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 key of each input

const middleNameInputField = input({  key: "middleName",

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]}.`,    };  }},

Component Outputs#

In 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 Data#

Some 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 Codes#

If 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 Component#

Component code contains a default export of component type. A component contains a key name, whether or not it's public, a version, some information about how the web app should display it, and an array of actions that the component contains. For the "proper and improper" names example component, the export can look like this:

export default component({  key: "formatName",  public: true,  version,  display: {    label: "Format Name",    description: "Format a person's name given a first, middle, and last name",    icon: "icon.png",  },  actions: {    ...improperFormatNameAction,    ...properFormatNameAction,  },});

Testing a Component#

It'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 Conventions#

To 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 Actions#

As described above a component action's perform function takes two arguments:

  • context is an object that contains a logger and credential.
  • 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 properFormatNameAction = action({  key: "properFormatName",  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: [firstNameInputField, middleNameInputField, lastNameInputField],});

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 { properFormatNameAction } from ".";import { PerformDataReturn } from "@prismatic-io/spectral";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<PerformDataReturn>(properFormatNameAction, {      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<PerformDataReturn>(properFormatNameAction, {      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 Tests#

You 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 Test#

Many actions require credentials to interact with third-party services. You can create a credential object using @prismatic-io/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 { saveFileAction } 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<PerformDataReturn>(    saveFileAction,    {      filePath: "/path/to/file.txt",      fileContents: "Hello world!",    },    {      credential: myAwsTestCredential,    }  );});

Publishing a Component#

Package a component with webpack by running npm run build or yarn build:

$ yarn buildyarn run v1.22.10$ webpack[webpack-cli] Compilation finishedasset index.js 37 KiB [compared for emit] (name: main)asset icon.png 17.8 KiB [compared for emit] [from: assets/icon.png] [copied]runtime modules 1.03 KiB 5 modulesmodules by path ./node_modules/ 22.7 KiB  modules by path ./node_modules/date-fns/esm/ 14.6 KiB    modules by path ./node_modules/date-fns/esm/_lib/ 517 bytes      ./node_modules/date-fns/esm/_lib/requiredArgs/index.js 221 bytes [built] [code generated]      ./node_modules/date-fns/esm/_lib/toInteger/index.js 296 bytes [built] [code generated]    4 modules  modules by path ./node_modules/@prismatic-io/spectral/dist/*.js 4.05 KiB    ./node_modules/@prismatic-io/spectral/dist/index.js 975 bytes [built] [code generated]    ./node_modules/@prismatic-io/spectral/dist/util.js 3.02 KiB [built] [code generated]    ./node_modules/@prismatic-io/spectral/dist/types.js 77 bytes [built] [code generated]  ./node_modules/valid-url/index.js 3.99 KiB [built] [code generated]./src/index.ts 1.5 KiB [built] [code generated]./package.json 345 bytes [built] [code generated]webpack 5.11.0 compiled successfully in 1344 ms✨  Done in 2.60s.

This will create a dist/ directory containing your compiled JavaScript and icon image. Now you can publish your custom component:

prism components:publish
Format NameFormat a person's name given a first, middle, and last nameWould you like to publish Format Name? (y/N): ySuccessfully submitted Format Name (v5)! The publish should finish processing shortly.