Custom Connections
Overview
A connection is a special type of input for an action that contains information on how to connect to an external app or service. A connection can consist of one or many inputs that can represent things like API endpoints, keys, passwords, OAuth 2.0 fields, etc. The inputs contained within a connection use the same structure as other inputs, described here.
For example, suppose you're writing a component for an API that can take a username and password combination or an API key. You would write two connections - one for username/password authentication, and one for api key authentication.
You also want your customers to be able to point to a sandbox or production environment - each connection should also include an input to represent the endpoint. Your connections could look like this:
import { connection } from "@prismatic-io/spectral";
// Declare this once, so we don't repeat ourselves for the two connections
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: "Sandbox",
value: "https://sandbox.acme.com/api",
},
],
});
const basicAuth = connection({
key: "basicAuth",
display: {
label: "Acme username and password",
description: "Acme basic auth",
},
inputs: {
username: {
label: "Acme Username",
placeholder: "Username",
type: "string",
required: true,
},
password: {
label: "Acme Password",
placeholder: "Password",
type: "string",
required: true,
},
acmeEnvironment,
},
});
const apiKey = connection({
key: "apiKey",
display: {
label: "Acme API Key",
description: "Acme API key auth",
},
inputs: {
username: {
label: "Acme API Key",
placeholder: "API Key",
type: "string",
required: true,
},
acmeEnvironment,
},
});
Once connections have been defined, be sure to include them in your component
definition.
That will allow users to fill in connection information once, and that information can be fed into actions that require that connection.
This also makes connections available to all inputs of type "connection" in your component:
import { component } from "@prismatic-io/spectral";
// ...
export default component({
key: "acme",
public: false,
display: {
label: "Acme Inc",
description: "Interact with Acme Inc's API",
iconPath: "icon.png",
},
actions: { myAction1, myAction2 },
triggers: { myTrigger1 },
connections: [basicAuth, apiKey],
});
The first connection listed in the connections:
array will be the default connection.
In the above example, basicAuth
would be the default connection for this component.
The default connection is the one that is recommended to users when they add an action from your component to their integration, but the other connection types can be selected as well.
Referencing connections as inputs in actions
Actions can reference connections like they do any other input.
To give users the ability to assign a connection to an action, create an input
of type connection
and add it as an input to your action:
import { action, input } from "@prismatic-io/spectral";
const connectionInput = input({ label: "Connection", type: "connection" });
export const getAcmeData = action({
display: {
label: "Get Item",
description: "Get an Item from Acme",
},
inputs: { itemId: itemIdInput, myConnection: connectionInput },
perform: async (context, { itemId, myConnection }) => {
const response = axios({
method: "get",
url: `${myConnection.fields.acmeEnvironment}/item/${itemId}`,
headers: {
Authorization: `Bearer ${myConnection.fields.apiKey}`,
},
});
return { data: response.data };
},
});
Throwing connection errors
It's valuable to know if a connection is valid or not, and to track errors if and when a connection fails to connect.
Within your custom component you can throw a ConnectionError
in order to signal to Prismatic that there is something wrong with the connection (unable to connect to endpoint, invalid credentials, etc).
For example, if you know the API you integrate with returns a 401 "Unauthorized" when credentials are invalid, you could throw a ConnectionError
if your HTTP client returns a status code 401:
import { action, ConnectionError, util } from "@prismatic-io/spectral";
const getItem = action({
display: {
label: "Get Item",
description: "Get an item from Acme",
},
perform: async (context, { myConnection, itemId }) => {
const apiKey = util.types.toString(myConnection.fields.apiKey);
const response = await axios.get(`https://api.acme.com/items/${itemId}`, {
headers: { Authorization: apiKey },
});
if (response.status === 401) {
throw new ConnectionError(
myConnection,
"Invalid Acme credentials have been configured.",
);
}
return {
data: response.data,
};
},
inputs: {
myConnection: input({ label: "Connection", type: "connection" }),
itemId: itemIdInput,
},
});
The thrown error, then, will be indicated by a red mark to the right of customers' connections on an instance and messages will appear in logs.
Writing OAuth 2.0 connections
An OAuth 2.0 authorization code connection follows the OAuth 2.0 protocol and consists of five required inputs:
authorizeUrl
- The URL a user visits to authorize an OAuth 2.0 connection.tokenUrl
- The URL where an authorization code can be exchanged for an API key and optional refresh key, and where a refresh key can be used to refresh an API key.scopes
- A space-delimited list of scopes (permissions) that your application needs.clientId
- Your OAuth 2.0 application's client ID.clientSecret
- Your OAuth 2.0 application's client secret.
The first three fields can generally be found in the documentation of the API that you're integrating with. Client ID and secret are created when you create an application in the third-party application.
You can elect to give integration builders the ability to edit any of these fields.
Or, you can mark the fields as shown: false
, in which case the default value will always be used and integration developers will never see the value.
For example, if you're writing a OAuth 2.0 connection to google drive, the authorizeUrl
and tokenUrl
never change.
So, those can be given default values and can be marked as shown: false
.
Integration developers will want to be able to adjust scopes, client ID and client secret (though, you may already know what scopes you need), so we can write a connection like this:
import { oauth2Connection, OAuth2Type } from "@prismatic-io/spectral";
export const oauth2 = oauth2Connection({
key: "googleDriveOauth",
display: {
label: "OAuth2",
description: "OAuth2 Connection",
icons: {
oauth2ConnectionIconPath: "oauth-icon.png",
},
},
required: true,
oauth2Type: OAuth2Type.AuthorizationCode,
inputs: {
authorizeUrl: {
label: "Authorize URL",
placeholder: "Authorization URL",
type: "string",
required: true,
shown: false,
comments: "The Authorization URL for Google Drive.",
default: "https://accounts.google.com/o/oauth2/v2/auth",
},
tokenUrl: {
label: "Token URL",
placeholder: "Token URL",
type: "string",
required: true,
shown: false,
comments: "The Token URL for Google Drive.",
default: "https://oauth2.googleapis.com/token",
},
scopes: {
label: "Scopes",
placeholder: "Scopes",
type: "string",
required: true,
comments:
"Space delimited listing of scopes. https://developers.google.com/identity/protocols/oauth2/scopes#drive",
default: "https://www.googleapis.com/auth/drive",
},
clientId: {
label: "Client ID",
placeholder: "Client Identifier",
type: "password",
required: true,
comments: "The Google Drive app's Client Identifier.",
},
clientSecret: {
label: "Client Secret",
placeholder: "Client Secret",
type: "password",
required: true,
comments: "The Google Drive app's Client Secret.",
},
},
});
oauth2Connection
for OAuth ConnectionsNote that we used oauth2Connection()
rather than connection()
to define this OAuth connection.
That's because the oauth2Connection
helper function gives us additional TypeScript hinting about what fields are required.
An oauth2Connection
can be assigned to a component and referenced as an input just like a connection
.
The input that is received by a perform
function will have the form:
{
"token": {
"access_token": "EXAMPLE-TOKEN",
"token_type": "bearer",
"expires_in": 14400,
"refresh_token": "EXAMPLE-REFRESH-TOKEN",
"scope": "account_info.read account_info.write file_requests.read file_requests.write files.content.read files.content.write files.metadata.read files.metadata.write",
"uid": "123456789",
"account_id": "dbid:EXAMPLEIRNhsZ3wECJZ3aXK3Gm47Di74",
"expires_at": "2021-12-07T01:54:38.096Z"
},
"context": {
"code": "EXAMPLEqMEAAAAAAAAON5iBXhk_yOxjkfDeWy_vSE0",
"state": "EXAMPLE2VDb25maWdWYXJpYWJsZTpmMDZlMDVkNy1kMjY0LTQ0YTgtYWI0Ni01MDhiOTNmZjU5ZjI="
},
"instanceConfigVarId": "EXAMPLE2VDb25maWdWYXJpYWJsZTpmMDZlMDVkNy1kMjY0LTQ0YTgtYWI0Ni01MDhiOTNmZjU5ZjI=",
"key": "oauth",
"fields": {
"scopes": "",
"clientId": "example-client-id",
"tokenUrl": "https://api.dropboxapi.com/oauth2/token",
"authorizeUrl": "https://www.dropbox.com/oauth2/authorize?token_access_type=offline",
"clientSecret": "example-client-secret"
}
}
You will likely want to reference myConnection.token.access_token
.
You can specify what the OAuth 2.0 button looks like in the instance configuration page by specifying an optional display.icons.oauth2ConnectionIconPath
(see the above example).
An icon must be a PNG file, and we recommend that it be wider than it is tall with text indicating what it does:
Without an oauth2ConnectionIconPath
, a simple button that says CONNECT will be placed in the configuration page.
Supporting PKCE with OAuth 2.0
If the application that you are integrating with supports Proof Key for Code Exchange (PKCE), you can add PKCE to your OAuth 2.0 connection by adding a oauth2PkceMethod
property.
You can specify either the plain
or S256
method, or omit the property to specify "no PKCE".
export const oauth = oauth2Connection({
oauth2Type: OAuth2Type.AuthorizationCode,
oauth2PkceMethod: OAuth2PkceMethod.S256,
key: "oauth",
display: {
label: "Airtable OAuth 2.0",
description: "Airtable OAuth 2.0 Auth Code",
},
inputs: {
// ...
},
});
Overriding OAuth 2.0 token refresh URL
The OAuth 2.0 standard specifies that the refresh endpoint URL is the same as the token endpoint URL.
It is generally something like https://example.com/oauth2/token
.
However, some OAuth 2.0 providers use a different URL for refreshing tokens.
For example, they may use /oauth2/token
for the initial auth code exchange, but /oauth2/refresh
for refreshing tokens.
To override the refresh endpoint URL, add a refreshUrl
property to your OAuth 2.0 connection:
export const oauth = oauth2Connection({
oauth2Type: OAuth2Type.AuthorizationCode,
key: "oauth",
display: {
label: "Acme OAuth 2.0",
description: "Acme OAuth 2.0 Auth Code",
},
inputs: {
// ...
refreshUrl: {
label: "Refresh URL",
placeholder: "Refresh URL",
type: "string",
required: true,
shown: false,
comments: "The Refresh URL for Acme Inc.",
default: "https://example.com/oauth2/refresh",
},
},
});
Using connections with HTTP clients
While the majority of APIs you'll interact with are HTTP based, and most present a RESTful interface, not all are the same. Some APIs (like Prismatic's!) use GraphQL. Others use remote procedure calls (RPCs), like gRPC, XML RPC, or SOAP.
Luckily, there is an NPM package for almost any protocol.
- If you are working with an HTTP-based REST API, we recommend using Spectral's built-in
createClient
function, which creates an Axios HTTP client behind the scenes with some useful settings pre-configured (see example below). If your team is more comfortable with vanilla Axios or node-fetch, you can certainly use those, too. - For GraphQL APIs, we recommend using graphql-request.
You can use a generic HTTP client, but
graphql-request
provides a handygql
string literal tag. - For XML RPC APIs, you can import xmlrpc into your component project, or you can reach for soap if it's a SOAP API.
- It's far less common for HTTP API integrations, but @grpc/grpc-js can be used for gRPC APIs.
Regardless of which client you use, you will likely need to set some HTTP headers for authentication, content type, etc.
Using the built-in createClient HTTP client
Spectral comes with a built-in HTTP client for integrating with REST APIs.
Behind the scenes, createClient
creates an Axios-based HTTP client with some timeout, retry, and debug logic built on top of it.
You can see the source code for createClient
in Spectral's GitHub repo.
To create an HTTP client, feed the client a base URL for your API along with the header information you need for authentication. You can fetch authentication values from a connection. It may look something like this:
import { createClient } from "@prismatic-io/spectral/dist/clients/http";
action({
perform: async (context, params) => {
// Create the authenticated HTTP client
const myClient = createClient({
baseUrl: "https://example.com/api",
debug: false,
headers: {
"X-API-Key": params.connection.fields.apiKey,
Accept: "application/json",
},
responseType: "json",
});
// Use the HTTP client to POST data to the API
const response = await myClient.post("/items", {
sku: "12345",
quantity: 3,
price: 20.25,
});
// Return the response as the action's result
return { data: response.data };
},
});
If you would like to see the full contents of the HTTP request and response, set debug: true
.
You will see all endpoints, headers, response codes, etc. in the integration logs.
Just remember to turn off debugging for production!
Using existing component connections in data sources
You may want to extend an existing component to populate a config variable. For example, you may want to fetch and filter specific information from a CRM or ERP and present the data to your user as a picklist menu. Your data source can reference any existing connection config variable - including those from built-in components.
To use an existing component's connection, reference its connection's key names.
The AWS Glue component , for example, has an accessKeyId
and secretAccessKey
.
Your data source can reference those with:
{
perform: async (context, params) => {
const { accessKeyId, secretAccessKey } = params.myConnection.fields;
};
}
The field that you likely need to use for OAuth 2.0 connections is the connection's access_token
, which is nested under token
like this:
{
perform: async (context, params) => {
const myAccessToken = params.myConnection.token.access_token;
};
}
An example of reusing existing connections is available in the Building a Field Mapper Data Source tutorial which covers pulling down custom fields from Salesforce.