Skip to main content

Wrapping an API in a Component

Component authors are frequently asked to develop a component that implements a series of API calls for some third-party service (or possibly your own company's API). If the API you're creating a component for has an OpenAPI or WSDL spec, you can have prism generate a component based off of your YAML, JSON, or XML spec file.

If, however, your API does not have an OpenAPI or WSDL spec, or if you're just looking to implement actions for a small handful of endpoints, we have some "best practices" you can follow for developing a custom component that implements those endpoints. The code for our custom component is available on GitHub. Let's dive into the code here.

Our Component's Spec#

For today's tutorial, suppose we're writing a component for "Acme ERP" - a made-up ERP system, and we would like to implement actions for a subset of endpoints that the Acme ERP API offers. We're handed a few specs for our component:

  • Customers each have distinct instances of Acme ERP, so the API endpoint is different for each customer.
  • Customers authenticate by entering an API key that they generate.
  • For our first integration, there are four endpoints that we want actions for:
    • List All Items: This is a GET call to the API's /items/ endpoint which returns an array of items in our inventory.
    • Get Item: This is a GET call to /items/{ ITEM_ID } and fetches a single item from our inventory by ID.
    • Add Item: This is a POST call to /items/ to add an item to our inventory.
    • Delete Item: This is a DELETE call to /items/{ ITEM_ID } to delete an item from inventory.

Initializing the Component#

Like any new custom component, we can create our new component using a subcommand of prism:

Initialize our component
$ prism components:init acmeerpCreating component directory for "acmeerp"...
"acmeerp" is ready for development.To install dependencies, run either "npm install" or "yarn install"To test the component, run "npm run test" or "yarn test"To build the component, run "npm run build" or "yarn build"To publish the component, run "prism components:publish"
For documentation on writing custom components, visit https://prismatic.io/docs/custom-components/writing-custom-components/

Then, I'll install my package's dependencies using yarn (though, you can use npm, too):

Install package dependencies
$ yarnyarn install v1.22.10[1/4] ๐Ÿ”  Resolving packages...[2/4] ๐Ÿšš  Fetching packages...[3/4] ๐Ÿ”—  Linking dependencies...[4/4] ๐Ÿ”จ  Building fresh packages...success Saved lockfile.โœจ  Done in 7.48s.

I'm not going to implement any custom triggers with this component (if you're interested in writing triggers, please see this quickstart), so I'm going to remove src/triggers.ts and in src/index.ts I'll remove the lines related to triggers. While I'm at it, I'll give my component a proper label and description:

Remove trigger boilerplate
import { component } from "@prismatic-io/spectral";import actions from "./actions";import triggers from "./triggers";import connections from "./connections";
export default component({  key: "acmeerp",  public: false,  display: {    label: "Acme ERP",    description: "Manage inventory in Acme ERP",    iconPath: "icon.png",  },  actions,  triggers,  connections,});

I'll also remove src/index.test.ts, as I'll implement unit testing later.

Creating a Shared HTTP Client#

Our component needs to reach out to a variable endpoint, and needs to accept an API Key. So, our connections will need to contain both endpoint and credential information To handle those things, let's create a function that takes a connection, and returns an HTTP client pointed at that endpoint. At Prismatic we really like Axios, but you can use another HTTP client library if you like one better.

Add Axios to our project
$ yarn add axiosyarn add v1.22.10[1/4] ๐Ÿ”  Resolving packages...[2/4] ๐Ÿšš  Fetching packages...[3/4] ๐Ÿ”—  Linking dependencies...[4/4] ๐Ÿ”จ  Building fresh packages...
success Saved lockfile.success Saved 2 new dependencies.info Direct dependenciesinfo All dependenciesโ”œโ”€ axios@0.24.0โ””โ”€ follow-redirects@1.14.5โœจ  Done in 6.47s.

Let's first create a connection, which will contain the information needed to connect to Acme ERP: the customer's unique endpoint URL and the customer's API key.

src/connections.ts
import { connection, Connection } from "@prismatic-io/spectral";
// Create a connection that contains an API endpoint URL// and an API key.export const apiKeyConnection = connection({  key: "apiKey",  label: "Acme Connection",  comments: "Acme Connection",  inputs: {    endpoint: {      label: "Acme Endpoint URL",      placeholder: "Acme Endpoint URL",      type: "string",      required: true,      comments: "Acme API Endpoint URL",      default:        "https://my-json-server.typicode.com/prismatic-io/placeholder-data",      example: "https://my-company.api.acme.com/",    },    apiKey: {      label: "Acme API Key",      placeholder: "Acme API Key",      type: "password",      required: true,      comments: "Generate at https://app.acme.com/settings/api-keys",    },  },});
export default [apiKeyConnection];

Then, we'll create a helper function, getAcmeErpClient, which will take a connection input and create an HTTP client pointed at the connection's endpoint URL and authenticated with the connection's API key. Let's place this code in a new file called auth.ts:

src/auth.ts
import axios from "axios";import { Connection, util } from "@prismatic-io/spectral";
export function getAcmeErpClient(acmeConnection: Connection) {  const { apiKey, endpoint } = acmeConnection.fields;
  // Return an HTTP client that has been configured to point  // towards endpoint URL, and passes an API key as a header  return axios.create({    baseURL: util.types.toString(endpoint),    headers: {      Accept: "application/json", // Our API returns JSON      Authorization: `Bearer ${apiKey}`,    },    maxContentLength: Infinity,    maxBodyLength: Infinity,  });}

Now, each of our actions simply need to pass in a connection to the getAcmeErpClient function, and they'll be able to make HTTP calls to the Acme ERP API.

If we build and publish this component now, we can create a new connection config variable for our integration and fill in our API key and endpoint URL:

Set API key and endpoint for Acme ERP Inventory in Prismatic integration designer

Write Some Actions#

Now it's time to implement our actions. Remember, we want to create four actions that list all items in our inventory, list a specific item, delete a specific item, and add an item to our inventory.

List All Items Action#

Let's start with the "list all items" action. This action will take a single input - the connection we just defined.

Let's keep our inputs organized, so we'll create a new file, src/inputs.ts, with that single input for now that represents our connection (customer's API endpoint and api key). At the top of the file, we'll import the input type function from our custom component SDK library:

src/inputs.ts - Endpoint URL Input
import { input } from "@prismatic-io/spectral";
export const connectionInput = input({  label: "Acme ERP",  required: true,  type: "connection",});

Next, let's replace the contents of src/actions.ts. We'll import action and helper util functions from our custom component SDK, our input from inputs.ts, and auth and HTTP client info from auth.ts. Then, we'll add a definition for our "list all items" action:

src/actions.ts - List All Items Action
import { action } from "@prismatic-io/spectral";import { getAcmeErpClient } from "./auth";import { connectionInput } from "./inputs";
const listAllItems = action({  display: {    label: "List All Items",    description: "List all items in our inventory",  },  inputs: {    // Declare an input for this action    acmeConnection: connectionInput,  },  perform: async (context, { acmeConnection }) => {    // Initialize our HTTP client    const acmeErpClient = getAcmeErpClient(acmeConnection);
    // Make a synchronous GET call to "{ endpoint }/items":    const response = await acmeErpClient.get("/items/");
    // Return the data that we got back    return { data: response.data };  },  // Show an example payload in the Prismatic UI:  examplePayload: {    data: [      {        id: 1,        name: "Widgets",        quantity: 20,      },      {        id: 2,        name: "Whatsits",        quantity: 100,      },    ],  },});

At this point we can run yarn build and prism components:publish to publish our single-action component to Prismatic. If we add our action to an integration, we can see that the step takes the connection input we specified:

Acme ERP - List all Items in Prismatic integration designer

We can also reference the example payload that our action declares in a subsequent step:

Acme ERP - Write Log Message in Prismatic integration designer

Get Item Action#

Next, let's build another action that fetches a specific item from our API's inventory. This action will reuse the same connectionInput input that we declared previously, and will also require an itemIdInput input, to specify the ID of the item we want to fetch:

src/inputs.ts - Item ID Input
export const itemIdInput = input({  label: "Item ID",  required: true,  type: "string",});

The "get item" action will be very similar to the previous action - it'll initialize an HTTP client and return some data. The display, inputs, perform logic, and examplePayload will differ slightly, but the bulk of the action will be the same as before:

src/actions.ts - Get Item Action
import { connectionInput, itemIdInput } from "./inputs";
// ...
const getItem = action({  display: {    label: "Get Item",    description: "Get an Item by ID",  },  inputs: {    acmeConnection: connectionInput,    itemId: itemIdInput,  },  perform: async (context, { acmeConnection, itemId }) => {    const acmeErpClient = getAcmeErpClient(acmeConnection);    const response = await acmeErpClient.get(`/items/${itemId}`);    return { data: response.data };  },  examplePayload: {    data: {      id: 1,      name: "Widgets",      quantity: 20,    },  },});
export default { getItem, listAllItems };

Delete Item Action#

At this point, we "rinse and repeat" - the "delete an item" action will get an HTTP client, send a DELETE HTTP request to an endpoint, and this time return nothing (since a DELETE of an item on our API returns nothing). Our "delete an item" action will take an endpoint URL and item ID, like the "get an item" action:

src/actions.ts - Delete an Item Action
const deleteItem = action({  display: {    label: "Delete Item",    description: "Delete an Item by ID",  },  inputs: {    acmeConnection: connectionInput,    itemId: itemIdInput,  },  perform: async (context, { acmeConnection, itemId }) => {    const acmeErpClient = getAcmeErpClient(acmeConnection);    const response = await acmeErpClient.delete(`/items/${itemId}`);    return { data: null };  },});
export default { deleteItem, getItem, listAllItems };

Add an Item Action#

The "add an item" action has a couple subtle differences from the other three actions:

  • It takes additional inputs (name and quanity). These inputs are not shared by other actions, so we can define them in-line.
  • The HTTP call we use is a POST call, and we pass our name and quantity inputs to the API as POST parameters.

The reset of the action is very similar to the previous actions:

src/actions.ts - Add Item Action
const addItem = action({  display: {    label: "Add Item",    description: "Add an Item to Inventory",  },  // We can define some inputs inline if they're not reused:  inputs: {    acmeConnection: connectionInput,    name: input({ label: "Item Name", type: "string" }),    quantity: input({ label: "Item Quantity", type: "string" }),  },  perform: async (context, { acmeConnection, name, quantity }) => {    const acmeErpClient = getAcmeErpClient(acmeConnection);    const response = await acmeErpClient.post("/items/", {      name,      quantity,    });    return { data: response.data };  },  // This API call returns the item object that was created:  examplePayload: {    data: {      id: 1,      name: "Widgets",      quantity: 20,    },  },});
export default { addItem, deleteItem, getItem, listAllItems };

Unit Testing Our Component#

Now that we've implemented our four actions for our component, let's add some unit tests to verify that our actions return what we'd expect them to return. To do that, we'll create a new file, src/actions.test.ts.

We can import a helper function from Prismatic's SDK - invoke. Then, we can import the actions that we want to test (we'll test "add an item" here) and we can invoke the action and verify that the result we receive is what we expect:

src/actions.test.ts - Unit Test the Add Item Action
import { invoke, createConnection } from "@prismatic-io/spectral/dist/testing";import { apiKeyConnection } from "./connections";import actions from "./actions";
const acmeConnection = createConnection(apiKeyConnection, {  endpoint: "https://my-json-server.typicode.com/prismatic-io/placeholder-data",  apiKey: process.env.ACME_ERP_API_KEY, // Get API key from an environment variable});
describe("test the add item action", () => {  test("test that we get back what we sent", async () => {    const name = "widgets";    const quantity = 123;    const { result } = await invoke(      actions.addItem, // Invoke the "addItem" action      { acmeConnection, name, quantity } // Pass in some inputs that we declared    );    expect(result.data.name).toEqual(name); // Verify that the response had the same item name    expect(result.data.quantity).toEqual(quantity); // Verify that the response had the same item quantity  });});