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 can authenticate with OAuth 2.0 or by using 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";
export default component({  key: "acmeerp",  public: false,  display: {    label: "Acme ERP",    description: "Manage inventory in Acme ERP",    iconPath: "icon.png",  },  actions,  triggers,});

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 both OAuth 2.0 and API Key credentials. To handle those things, let's create a function that takes an endpoint and credential, 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 declare the type of

src/auth.ts
import axios from "axios";import { Credential, AuthorizationDefinition } from "@prismatic-io/spectral";
export const authorization: AuthorizationDefinition = {  required: true, // Credentials are required for all actions  methods: ["api_key", "oauth2"], // Accept both API keys and OAuth 2.0 credentials};
export function getAcmeErpClient(endpointUrl: string, credential: Credential) {  // Save the api_key or OAuth 2.0's access_token into a variable, token  let token;  switch (credential.authorizationMethod) {    case "api_key":      token = credential.fields.api_key;      break;    case "oauth2":      token = credential.token.access_token;      break;    default:      throw new Error(        `Unsupported credential type: ${credential.authorizationMethod}.`      );  }
  // Return an HTTP client that has been configured to point  // towards endpointUrl, and passes an access token as a header  return axios.create({    baseURL: endpointUrl,    headers: {      Accept: "application/json", // Our API returns JSON      Authorization: `Bearer ${token}`,    },    maxContentLength: Infinity,    maxBodyLength: Infinity,  });}

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

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 - an API endpoint.

Let's keep our inputs organized, so we'll create a new file, src/inputs.ts, with that single input for now. At the top of the file, we'll import the input type function from our custom component SDK library, and we'll use it to define an input for the variable API URL that each customer will have:

src/inputs.ts - Endpoint URL Input
import { input } from "@prismatic-io/spectral";
export const endpointUrlInput = input({  label: "Endpoint URL",  default: "https://my-json-server.typicode.com/prismatic-io/placeholder-data",  required: true,  type: "string",});

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, util } from "@prismatic-io/spectral";import { authorization, getAcmeErpClient } from "./auth";import { endpointUrlInput } from "./inputs";
const listAllItems = action({  display: {    label: "List All Items",    description: "List all items in our inventory",  },  inputs: {    // Declare some inputs for this action    endpointUrl: endpointUrlInput,  },  authorization, // Require authorization for this action  perform: async ({ credential }, { endpointUrl }) => {    const acmeErpClient = getAcmeErpClient(      util.types.toString(endpointUrl), // Convert our input to string, if it's not already      credential    );    // Make a synchronous GET call to "{ endpointUrl }/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 the input that we specified, as well as the required credential:

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

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 endpointUrlInput 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 { endpointUrlInput, itemIdInput } from "./inputs";
// ...
const getItem = action({  display: {    label: "Get Item",    description: "Get an Item by ID",  },  authorization,  inputs: {    endpointUrl: endpointUrlInput,    itemId: itemIdInput,  },  perform: async ({ credential }, { endpointUrl, itemId }) => {    const acmeErpClient = getAcmeErpClient(      util.types.toString(endpointUrl),      credential    );    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",  },  authorization,  inputs: {    endpointUrl: endpointUrlInput,    itemId: itemIdInput,  },  perform: async ({ credential }, { endpointUrl, itemId }) => {    const acmeErpClient = getAcmeErpClient(      util.types.toString(endpointUrl),      credential    );    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",  },  authorization,  // We can define some inputs inline if they're not reused:  inputs: {    endpointUrl: endpointUrlInput,    name: input({ label: "Item Name", type: "string" }),    quantity: input({ label: "Item Quantity", type: "string" }),  },  perform: async ({ credential }, { endpointUrl, name, quantity }) => {    const acmeErpClient = getAcmeErpClient(      util.types.toString(endpointUrl),      credential    );    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, credentials } from "@prismatic-io/spectral/dist/testing";import actions from "./actions";
const credential = credentials.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 endpointUrl =      "https://my-json-server.typicode.com/prismatic-io/placeholder-data";    const name = "widgets";    const quantity = 123;    const { result } = await invoke(      actions.addItem, // Invoke the "addItem" action      { endpointUrl, name, quantity }, // Pass in some inputs that we declared      { credential } // Pass in a valid credential to use for testing    );    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  });});