nylas
Version:
A NodeJS wrapper for the Nylas REST API for email, contacts, and calendar.
225 lines (224 loc) • 9.53 kB
JavaScript
import { NylasApiError, NylasOAuthError, NylasSdkTimeoutError, } from './models/error.js';
import { objKeysToCamelCase, objKeysToSnakeCase } from './utils.js';
import { SDK_VERSION } from './version.js';
import { FormDataEncoder } from 'form-data-encoder';
import { Readable } from 'stream';
import { snakeCase } from 'change-case';
/**
* The header key for the debugging flow ID
*/
export const FLOW_ID_HEADER = 'x-fastly-id';
/**
* The header key for the request ID
*/
export const REQUEST_ID_HEADER = 'x-request-id';
/**
* The API client for communicating with the Nylas API
* @ignore Not for public use
*/
export default class APIClient {
constructor({ apiKey, apiUri, timeout, headers }) {
this.apiKey = apiKey;
this.serverUrl = apiUri;
this.timeout = timeout * 1000; // fetch timeout uses milliseconds
this.headers = headers;
}
setRequestUrl({ overrides, path, queryParams, }) {
const url = new URL(`${overrides?.apiUri || this.serverUrl}${path}`);
return this.setQueryStrings(url, queryParams);
}
setQueryStrings(url, queryParams) {
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
const snakeCaseKey = snakeCase(key);
if (key == 'metadataPair') {
// The API understands a metadata_pair filter in the form of:
// <key>:<value>
const metadataPair = [];
for (const item in value) {
metadataPair.push(`${item}:${value[item]}`);
}
url.searchParams.set('metadata_pair', metadataPair.join(','));
}
else if (Array.isArray(value)) {
for (const item of value) {
url.searchParams.append(snakeCaseKey, item);
}
}
else if (typeof value === 'object') {
for (const item in value) {
url.searchParams.append(snakeCaseKey, `${item}:${value[item]}`);
}
}
else {
url.searchParams.set(snakeCaseKey, value);
}
}
}
return url;
}
setRequestHeaders({ headers, overrides, }) {
const mergedHeaders = {
...headers,
...this.headers,
...overrides?.headers,
};
return {
Accept: 'application/json',
'User-Agent': `Nylas Node SDK v${SDK_VERSION}`,
Authorization: `Bearer ${overrides?.apiKey || this.apiKey}`,
...mergedHeaders,
};
}
async sendRequest(options) {
const req = await this.newRequest(options);
const controller = new AbortController();
// Handle timeout
let timeoutDuration;
if (options.overrides?.timeout) {
// Determine if the override timeout is likely in milliseconds (≥ 1000)
if (options.overrides.timeout >= 1000) {
timeoutDuration = options.overrides.timeout; // Keep as milliseconds for backward compatibility
}
else {
// Treat as seconds and convert to milliseconds
timeoutDuration = options.overrides.timeout * 1000;
}
}
else {
timeoutDuration = this.timeout; // Already in milliseconds from constructor
}
const timeout = setTimeout(() => {
controller.abort();
}, timeoutDuration);
try {
const response = await fetch(req, {
signal: controller.signal,
});
clearTimeout(timeout);
if (typeof response === 'undefined') {
throw new Error('Failed to fetch response');
}
const headers = response?.headers?.entries
? Object.fromEntries(response.headers.entries())
: {};
const flowId = headers[FLOW_ID_HEADER];
const requestId = headers[REQUEST_ID_HEADER];
if (response.status > 299) {
const text = await response.text();
let error;
try {
const parsedError = JSON.parse(text);
const camelCaseError = objKeysToCamelCase(parsedError);
// Check if the request is an authentication request
const isAuthRequest = options.path.includes('connect/token') ||
options.path.includes('connect/revoke');
if (isAuthRequest) {
error = new NylasOAuthError(camelCaseError, response.status, requestId, flowId, headers);
}
else {
error = new NylasApiError(camelCaseError, response.status, requestId, flowId, headers);
}
}
catch (e) {
throw new Error(`Received an error but could not parse response from the server${flowId ? ` with flow ID ${flowId}` : ''}: ${text}`);
}
throw error;
}
return response;
}
catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// Calculate the timeout in seconds for the error message
// If we determined it was milliseconds (≥ 1000), convert to seconds for the error
const timeoutInSeconds = options.overrides?.timeout
? options.overrides.timeout >= 1000
? options.overrides.timeout / 1000 // Convert ms to s for error message
: options.overrides.timeout // Already in seconds
: this.timeout / 1000; // Convert ms to s
throw new NylasSdkTimeoutError(req.url, timeoutInSeconds);
}
clearTimeout(timeout);
throw error;
}
}
requestOptions(optionParams) {
const requestOptions = {};
requestOptions.url = this.setRequestUrl(optionParams);
requestOptions.headers = this.setRequestHeaders(optionParams);
requestOptions.method = optionParams.method;
if (optionParams.body) {
requestOptions.body = JSON.stringify(objKeysToSnakeCase(optionParams.body, ['metadata']) // metadata should remain as is
);
requestOptions.headers['Content-Type'] = 'application/json';
}
if (optionParams.form) {
// Use FormDataEncoder to properly encode the form data with the correct
// Content-Type header including the multipart boundary.
// This is required for Node.js environments where formdata-node's FormData
// doesn't automatically set the Content-Type header on fetch requests.
const encoder = new FormDataEncoder(optionParams.form);
// Set the Content-Type header with the boundary from the encoder
requestOptions.headers['Content-Type'] = encoder.contentType;
// Convert the encoded form data to a readable stream for the request body
requestOptions.body = Readable.from(encoder);
// Node.js native fetch requires duplex: 'half' when sending a streaming body
requestOptions.duplex = 'half';
}
return requestOptions;
}
async newRequest(options) {
const newOptions = this.requestOptions(options);
const requestInit = {
method: newOptions.method,
headers: newOptions.headers,
body: newOptions.body,
};
// Add duplex option for streaming bodies (required by Node.js native fetch)
if (newOptions.duplex) {
requestInit.duplex = newOptions.duplex;
}
return new Request(newOptions.url, requestInit);
}
async requestWithResponse(response) {
const headers = response?.headers?.entries
? Object.fromEntries(response.headers.entries())
: {};
const flowId = headers[FLOW_ID_HEADER];
const text = await response.text();
try {
const parsed = JSON.parse(text);
const payload = objKeysToCamelCase({
...parsed,
flowId,
// deprecated: headers will be removed in a future release. This is for backwards compatibility.
headers,
}, ['metadata']);
// Attach rawHeaders as a non-enumerable property to avoid breaking deep equality
Object.defineProperty(payload, 'rawHeaders', {
value: headers,
enumerable: false,
});
return payload;
}
catch (e) {
throw new Error(`Could not parse response from the server: ${text}`);
}
}
async request(options) {
const response = await this.sendRequest(options);
return this.requestWithResponse(response);
}
async requestRaw(options) {
const response = await this.sendRequest(options);
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
async requestStream(options) {
const response = await this.sendRequest(options);
if (!response.body) {
throw new Error('No response body');
}
return response.body;
}
}