handler.ts configuration
info
You can deploy a connector with just Raw HTTP operation and test it in the UI. This would confirm if you have set up the custom service properly and your auth works.
Check out the Raw HTTP quickstart to see how it's done.
You can now move on to building other operations.
Introduction to the handler.ts file
File overview
Handler file specifies the logic which makes the operation work.
There are two types of handlers you could build using CDK:
- HTTP - For single HTTP requests only (e.g. REST / SOAP / GraphQL APIs etc.)
-
Composite - For everything else:
- multiple HTTP requests
- utility / helper (vanilla Typescript or SDKs) operations
- DDL operations which can be called by other operations to present dropdowns
Before building an HTTP or Composite operation, validations can be performed and a globalConfig can be applied.
Validations
Requests can be validated before they are processed by the CDK and error messages can be returned accordingly, as shown in the following example:
handler.addInputValidation((validation) =>
validation
.condition((ctx, input) => input.page_size <= 100)
.errorMessage((ctx, input) => "Maximum page size is 100")
);
Global Config
It is standard practice to use the withGlobalConfiguration
function as it enables you to use the authentication and any other configs (e.g. baseUrl) defined in the Global config file.
Error Handling
Handling of errors is done at the handler level once the response is received.
Exactly how errors are handled depends on whether you are building an HTTP or composite operation and what you want to return to the user.
Refer to the HTTP Error Handling and Composite error handling sections below for more details.
Single HTTP operation
In order to set up a single HTTP request, the usingHttp
function is used.
The function takes one argument, http
and the objective is to construct the http request and handle the response from the API.
You will need to specify:
- Request type : The HTTP method for the API call (e.g. get, post, put, patch, delete)
- Request path : The path of the endpoint being called (The baseUrl should be specified in the Global config file )
- handleRequest : The function that will configure the request before it is sent
- handleResponse : The function that you can use to process the response received
The handleRequest function
This function receives three arguements: ctx
, input
, and request
object and the goal is to construct and return the HTTP request.
Parameter | Notes |
---|---|
ctx | Refers to the OperationHandlerContext object. This holds the details of the auth e.g. accessToken , and environment variables (executionId, workflowId etc.) of the workflow where the connector will be used |
input | represents the inputs for the operation that will be defined in the input.ts file |
request | object represents the HTTP request that will be made. You will use the input to contruct the request object. |
request
object has several functions that can be used to form your request:
Function | Notes |
---|---|
addHeader(name: string, value: HttpHeaderValue) | Adds a header, chain the function for each header you want to add e.g. addHeader('Content-Type', 'application/json').addHeader('Accept', 'application/json') |
withBearerToken(token: string) | Adds a Authorization header to the request and the value is set as Bearer <Token> |
addQueryString(name: string, value: string | string[]) | Adds a query parameter, chain the function for each query parameter you want to add e.g. addQueryString('skip', 0).addQueryString('limit', '100') |
addPathParameter(name: string, value: string) | Adds a path parameter, chain the function for each path parameter you want to add e.g. addPathParameter('connector-name', 'slack').addPathParameter('version', '9.0') |
withoutBody() | Use this function, if the API does not need a request body e.g. GET, DELETE methods |
withBodyAsJson(body: JSON) | Use this if the API expects application/json request body |
withBodyAsText(body: string) | Use this if the API expects text/plain request body |
withBodyAsFormUrlEncoded(body: DynamicObject) | Use this if the API expects application/x-www-form-urlencoded request body |
withBodyAsMultipart({ fields: Record<string, string>, files: Record<string, FileReference> }) | Use this if the API expects multipart/form-data request body. Refer to the File Handling page for more details. |
withBodyAsFile(file: FileReference) | Use this if the API expects binary data e.g. application/octet-stream . This will send a Tray file object, refer to the File Handling page for more details. |
info
The request object must finish with one of the body functions: withoutBody
, withBodyAsJson
, withBodyAsText
, withBodyAsFormUrlEncoded
, WithBodyAsMultipart
, withBodyAsFile
If no body is required, use withoutBody() to finish the request.
The handleResponse function
This function receives three arguements: ctx
, input
, and response
object and the goal is to format and return the response.
response
object has several functions that can be used to process the received response:
Function | Notes |
---|---|
getStatusCode() | Returns a status code |
getHeader(name: string) | Gets the value of a particular header |
withErrorHandling(responseParser: HttpOperationResponseParser) | Used in HTTP Error Handling to send customized error responses |
withJsonErrorHandling(responseParser: HttpOperationResponseParser) | Used in HTTP Error Handling to parse and surface 3rd party error responses |
parseWithBodyAsText(responseParser: HttpOperationResponseParser) | Use this if the API sends a response with Content-Type as text/plain |
parseWithBodyAsJson(responseParser: HttpOperationResponseParser) | Use this if the API sends a response with Content-Type as application/json |
parseWithBodyAsFile(responseParser: HttpOperationResponseParser) | Use this if the API sends a file / bianry response e.g. Content-Type as application/octet-stream |
Single HTTP Error Handling
The following strategies can be used to handle errors:
info
Both error handling functions are automatically invoked by a non 200 status coming from the 3rd party.
Returning custom error messages
The withErrorHandling()
function can be used to display custom error messages to the user of your connector. The following example
handler.usingHttp((http) =>
http
.get("/posts")
.handleRequest((ctx, input, request) => request.addPathParameter("postId", input.postId)
.withBearerToken(ctx.auth!.user.access_token)
.withoutBody())
.handleResponse((ctx, input, response) =>
response
.withErrorHandling(() => {
const status = response.getStatusCode();
if (status === 404) {
return OperationHandlerResult.failure(
OperationHandlerError.userInputError("post not found")
);
}
return OperationHandlerResult.failure(
OperationHandlerError.userInputError(`api error: ${status}`)
);
})
.parseWithBodyAsJson()
)
);
Parsing 3rd party error messages
The withJsonErrorHandling
function can be used to parse the response body returned by the 3rd party API. This can be used to surface error messages to the user of your connector.
handler.withGlobalConfiguration(globalConfigHttp).usingHttp((http) =>
http
.get("/user/repos")
.handleRequest((ctx, input, request) => request.withoutBody())
.handleResponse((ctx, input, response) =>
response
.withJsonErrorHandling<{ message: string }>((body) =>
OperationHandlerResult.failure(
OperationHandlerError.apiError(
`API returned an error: ${
body.message
}, status code: ${response.getStatusCode()}`,
{
statusCode: response.getStatusCode(),
}
)
)
)
.parseWithBodyAsJson()
)
);
Composite operation
info
Global config cannot be used with Composite operations.
In order to set up a composite operation, the usingComposite
function is used.
The function takes three parameters:
parameter | notes |
---|---|
ctx | Refers to OperationHandlerContext object. This holds the details of the auth e.g. accessToken , and environment variables (executionId, workflowId etc.) of the workflow where the connector will be used |
input | represents the inputs for the operation that will be defined in the input.ts file |
invoke | Can be used to call another operation within this operation |
info
A Composite handler should always return an instance of OperationHandlerResult
.
The successful result should be returned by passing an obejct to the success
method of OperationHandlerResult
.
Similarly an operation failure can be returned by passing an OperationHandlerError
instance to the failure
method of OperationHandlerResult
.
Refer to the Composite Error handling and Composite Examples sections below for more details.
Invoking other operations
You can use invoke
method to call another handler function and use the result of that operation to:
- Return a dynamic dropdown list (DDLs)
- Do further processing
DDLs allow you to present a dropdown that is dynamic based on auth or the user inputs. A simple demonstration of this can be found in the DDL example below.
Read the detailed DDL docs for guidance on preparing a DDL and using the DDL in another operation.
info
DDLs are not the only purpose of invoking handler. e.g. You may use the result of an HTTP operation and process it further in a Composite operation. This allows you to write modular code instead of packaging everything in one operation.
Composite Error Handling
An operation failure can be indicated by supplying an instance of OperationHandlerError
to OperationHandlerResult.failure()
function.
You will have to return one of the following types of Errors:
Error Type | Retried | Visible in Tray app logs | Notes |
---|---|---|---|
connectorError | Yes | No | Use it for Internal Server Error e.g. when the 3rd party API throws a server side exception or a library you are using errors out |
apiError | No | No | |
userInputError | No | Yes | Use it when the input validation fails |
oauthRefreshError | Yes | Yes | Use it if the 3rd party API throws unauthorized exception (e.g. 401 or 403 ). Tray will attempt a token refresh since it has the service's OAuth2 details and then retry the operation |
timeoutError | No | Yes (a generic message will show up) | You can throw this to indicate a time out. Tray will return this error in any case if the operation takes longer than 2 mins to complete. A generic error message is shown in the logs |
skipTriggerError | No | No | Only applicable on Trigger operations. Use it when trigger invocation should not trigger a workflow execution. This is mainly used for filtering incoming events, allowing you to choose which ones trigger the workflow and which don’t. |
The following example shows connectorError
returned upon failure to convert Markdown to HTML:
import { marked } from "marked";
export const markdownToHtmlHandler = OperationHandlerSetup.configureHandler<
DataFormatHelperAuth,
MarkdownToHtmlInput,
MarkdownToHtmlOutput
>((handler) =>
handler.usingComposite(async (ctx, { markdownString }, invoke) => {
try {
return OperationHandlerResult.success({
htmlString: await marked.parse(markdownString),
});
} catch {
return OperationHandlerResult.failure(
OperationHandlerError.connectorError(
"Could not convert HTML to Markdown" //message that is surfaced on the Tray UI
)
);
}
})
);
More examples of Composite operations with error handling can be found the sectionbelow.
Single HTTP examples
Without query, path params or request body
The example below demonstrates a GET request to an endpoint that either doesn't require any parameters or uses satisfactory default values for them.
Request: GET /posts
handler.withGlobalConfiguration(globalConfigHttp).usingHttp((http) =>
http
.get("/posts")
.handleRequest((_ctx, _input, request) => request.withoutBody())
.handleResponse((_ctx, _input, response) => response.parseWithBodyAsJson())
);
With path params only
Request: GET /posts/{postId}/comments/{commentId}
handler.withGlobalConfiguration(globalConfigHttp).usingHttp((http) =>
http
.get("/posts/:postId/comments/:commentId")
.handleRequest((_ctx, input, request) =>
request
.addPathParameter("postId", input.postId)
.addPathParameter("commentId", input.commentId)
.withoutBody()
)
.handleResponse((_ctx, _input, response) => response.parseWithBodyAsJson())
);
With query params only
Request: GET /posts?topic={topic}&start_date={startDate}&end_date={endDate}
handler.withGlobalConfiguration(globalConfigHttp).usingHttp((http) =>
http
.get("/posts")
.handleRequest((_ctx, input, request) =>
request
.addQueryString("topic", input.topic)
.addQueryString("start_date", input.startDate)
.addQueryString("end_date", input.endDate)
.withoutBody()
)
.handleResponse((_ctx, _input, response) => response.parseWithBodyAsJson())
);
With both query and path params
Request: GET /products/{productId}/reviews?min_rating={minRating}&start_date={startDate}
handler.withGlobalConfiguration(globalConfigHttp).usingHttp((http) =>
http
.get("/products/:productId/reviews")
.handleRequest((_ctx, input, request) =>
request
.addPathParameter("productId", input.productId)
.addQueryString("min_rating", input.minRating)
.addQueryString("start_date", input.startDate)
.withoutBody()
)
.handleResponse((_ctx, _input, response) => response.parseWithBodyAsJson())
);
With path params and JSON request body
Request: PUT /posts/{postId}/comments/{commentId}
handler.withGlobalConfiguration(globalConfigHttp).usingHttp((http) =>
http
.put("/posts/:postId/comments/:commentId")
.handleRequest((_ctx, input, request) =>
request
.addPathParameter("postId", input.postId)
.addPathParameter("commentId", input.commentId)
.withBodyAsJson({ commentText: input.commentText })
)
.handleResponse((_ctx, _input, response) => response.parseWithBodyAsJson())
);
Composite examples
Helper / utility
You can use Javascript pacakges to build a helper / utility connector without using APIs.
[Note: Explain a bit more about what helpers are:]
Here is an operation that converts HTML to markdown using turndown
:
import TurndownService from "turndown";
export const htmlToMarkdownHandler = OperationHandlerSetup.configureHandler<
DataFormatHelperAuth,
HtmlToMarkdownInput,
HtmlToMarkdownOutput
>((handler) =>
handler.usingComposite(async (ctx, { htmlString }, invoke) => {
const turndownService = new TurndownService();
const markdownString = turndownService.turndown(htmlString);
return OperationHandlerResult.success({ markdownString });
})
);
DDL example
Here is an example of using invoke
to call another handler function.
[Link to DDL docs]
import { listChannelsHandler } from "../list_channels/handler";
export const listChannelsDdlHandler = OperationHandlerSetup.configureHandler<
DdlExampleAuth,
ListChannelsDdlInput,
ListChannelsDdlOutput
>((handler) =>
handler.usingComposite(async (_ctx, _input, invoke) => {
try {
const listChannelsResult = await invoke(listChannelsHandler);
if (listChannelsResult.isFailure) {
return listChannelsResult;
}
const channelName = listChannelsResult.value.channels.map((channel) => {
return {
text: channel.name,
value: channel.id,
};
});
return OperationHandlerResult.success({
result: channelName,
});
} catch (error) {
console.error("Could not get channels list from Slack", error);
return OperationHandlerResult.failure(
OperationHandlerError.connectorError(
"Could not get channels list from Slack"
)
);
}
})
);
Multiple API calls
warning
Connectors built with CDK have a timeout of 120 seconds.
If you are performing several tasks in one operation and it takes more than 120 seconds to return the result, connector will time out on the Tray UI.
You can make multiple API calls and return a final response. The following example shows an operation that gets list of all channels, users and installed app details from Slack.
export const GenerateSlackAdminReportHandler =
OperationHandlerSetup.configureHandler<
SlackExampleAuth,
GenerateSlackAdminReportInput,
GenerateSlackAdminReportOutput
>((handler) =>
handler.usingComposite(async (_ctx, _input, invoke) => {
try {
// Setup headers with authorization
const headers = {
Authorization: `Bearer ${ctx.auth?.user.access_token}`,
"Content-Type": "application/json",
};
// Make individual API calls concurrently
const [channelsResponse, usersResponse, integrationsResponse] =
await Promise.all([
axios.get("https://slack.com/api/conversations.list", { headers }),
axios.get("https://slack.com/api/users.list", { headers }),
axios.get("https://slack.com/api/apps.permissions.scopes.list", {
headers,
}),
]);
// Combine the results into one final report
const slackAdminReport = {
channels: channelsResponse.data.channels,
activeUsers: usersResponse.data.members.filter(
(member) => !member.deleted && !member.is_bot
),
installedIntegrations: integrationsResponse.data.scopes,
};
// Return the combined report
return OperationHandlerResult.success({
result: slackAdminReport,
});
} catch (error) {
console.error("Failed to generate Slack admin report", error);
return OperationHandlerResult.failure(
OperationHandlerError.connectorError(
"Failed to generate Slack admin report"
)
);
}
})
);
API call & processing response using a package
Following example shows an API call made to extract users list and then process there emai
import axios from "axios";
import validator from "validator";
export const NormalizeEmailsHandler = OperationHandlerSetup.configureHandler<
NormalizeEmailsAuth,
NormalizeEmailsInput,
NormalizeEmailsOutput
>((handler) =>
handler.usingComposite(async (_ctx, _input, invoke) => {
try {
// Fetch the list of users from the API
const response = await axios.get("https://example.com/users");
// Extract the email addresses and process them
const processedEmails = response.data.map((user) => {
// Validate and normalize the email address
const email = user.email;
const isValid = validator.isEmail(email);
const normalizedEmail = validator.normalizeEmail(email);
return {
name: user.name,
email: email,
isValid: isValid,
normalizedEmail: normalizedEmail,
};
});
// Return the processed email information
return OperationHandlerResult.success({
result: processedEmails,
});
} catch (error) {
console.error("Error fetching or processing emails:", error);
return OperationHandlerResult.failure(
OperationHandlerError.connectorError(
"Failed to fetch or process emails"
)
);
}
})
);