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:

Copy
Copied
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:

  1. Request type : The HTTP method for the API call (e.g. get, post, put, patch, delete)
  2. Request path : The path of the endpoint being called (The baseUrl should be specified in the Global config file )
  3. handleRequest : The function that will configure the request before it is sent
  4. 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

Copy
Copied
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.

Copy
Copied
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:

  1. Return a dynamic dropdown list (DDLs)
  2. 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:

Copy
Copied
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

Copy
Copied
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}

Copy
Copied
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}

Copy
Copied
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}

Copy
Copied
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}

Copy
Copied
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:

Copy
Copied
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]

Copy
Copied
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.

Copy
Copied
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

Copy
Copied
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"
        )
      );
    }
  })
);