Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ npm-debug.log
.nyc_output
.vscode/
dist/
.env
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [5.0.0-pre1] - 2025-05-23
### Added
- New WIP TS based client
### Removed
- Existing JS client

## [4.2.3] - 2025-05-5
### Added
- Fix issue with PUT and POST methods where the body wasn't passed on retries.
Expand Down
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export default tseslint.config(
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
Expand Down
132 changes: 3 additions & 129 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,130 +1,4 @@
import type { CreateClient, CreateOptions } from './lib/types';
import { apiUrls } from './lib/utils/apis';
import { createEvents } from './lib/events';
import { createSearch } from './lib/search';

const _ = require('underscore');
const winston = require('winston');

// Possible TODO: Namespace parameters for different subcomponents
// E.g. clientOptions.requestor.instance OR
// clientOptions.requestor.settings
// w/ sub-paths maxRetryDurationSeconds and calcRetryBackoff

function buildRequestor(clientOptions) {
if (clientOptions.requestor) return clientOptions.requestor;

const requestorConfig = _.pick(clientOptions, 'maxRetryDurationSeconds', 'calcRetryBackoff');

if (requestorConfig.maxRetryDurationSeconds)
requestorConfig.maxRetryDurationMillis = requestorConfig.maxRetryDurationSeconds * 1000;

requestorConfig.logger = buildLogger(clientOptions);

return require('./lib/utils/httpRequestor.js').create(requestorConfig);
}

function buildLogger(clientOptions) {
if (hasMultipleLogOptions(clientOptions)) {
throw new Error(
'Smartsheet client options may specify at most one of ' + "'logger', 'loggerContainer', and 'logLevel'."
);
}

if (clientOptions.logger) return clientOptions.logger;

if (clientOptions.logLevel) return buildLoggerFromLevel(clientOptions.logLevel);

if (clientOptions.loggerContainer) return buildLoggerFromContainer(clientOptions.loggerContainer);

return null;
}

function hasMultipleLogOptions(clientOptions) {
return (
(clientOptions.logger && clientOptions.loggerContainer) ||
(clientOptions.logger && clientOptions.logLevel) ||
(clientOptions.loggerContainer && clientOptions.logLevel)
);
}

function buildLoggerFromLevel(logLevel) {
if (winston.levels[logLevel] == null) {
throw new Error(
'Smartsheet client received configuration with invalid log level ' +
`'${logLevel}'. Use one of the standard Winston log levels.`
);
}

return new winston.Logger({
transports: [
new winston.transports.Console({
level: logLevel,
showLevel: false,
label: 'Smartsheet',
}),
],
});
}

function buildLoggerFromContainer(container) {
if (container.has('smartsheet')) return container.get('smartsheet');
else
throw new Error(
'Smartsheet client received a logger container, but could not find a logger named ' + "'smartsheet' inside."
);
}

export const createClient: CreateClient = function (clientOptions) {
const requestor = buildRequestor(clientOptions);

const options: CreateOptions = {
apiUrls: apiUrls,
requestor: requestor,
clientOptions: {
accessToken: clientOptions.accessToken || process.env.SMARTSHEET_ACCESS_TOKEN,
userAgent: clientOptions.userAgent,
baseUrl: clientOptions.baseUrl,
},
};

return {
constants: require('./lib/utils/constants.js'),
contacts: require('./lib/contacts/').create(options),
events: createEvents(options),
favorites: require('./lib/favorites/').create(options),
folders: require('./lib/folders/').create(options),
groups: require('./lib/groups/').create(options),
/**
* @deprecated
* The home module is deprecated. The endpoints powering this module
* are being shut off as part of the sheets folder deprecation.
* The endpoints will be available until June.
*
* See this changelog entry for more information
* https://developers.smartsheet.com/api/smartsheet/changelog#2025-03-25
*/
home: require('./lib/home/').create(options),
images: require('./lib/images/').create(options),
reports: require('./lib/reports/').create(options),
request: require('./lib/request/').create(options),
search: createSearch(options),
server: require('./lib/server/').create(options),
sheets: require('./lib/sheets/').create(options),
sights: require('./lib/sights/').create(options),
templates: require('./lib/templates/').create(options),
tokens: require('./lib/tokens/').create(options),
users: require('./lib/users/').create(options),
webhooks: require('./lib/webhooks/').create(options),
workspaces: require('./lib/workspaces/').create(options),
};
};

export const smartSheetURIs = {
defaultBaseURI: 'https://api.smartsheet.com/2.0/',
govBaseURI: 'https://api.smartsheetgov.com/2.0/',
euBaseURI: 'https://api.smartsheet.eu/2.0/',
};

export { CreateClient, CreateClientOptions, SmartsheetClient } from './lib/types';
export { createApiClient } from './lib/client/createApiClient';
export * from './lib/client/types/clientConfiguration';
export * from './lib/client/types/smartsheetClient';
export * from './lib/events/types';
77 changes: 77 additions & 0 deletions lib/client/buildClientConfiguration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import winston, { format } from 'winston';
import {
type ApiHost,
type CreateClientOptions,
DEFAULT_LOG_LEVEL,
DEFAULT_RETRY_CONFIG,
type FullClientConfig,
type LoggingConfig,
type RetryConfig,
SUPPORTED_LOG_LEVELS,
type SmartsheetClientConfig,
isSupportedLogLevel,
} from './types/clientConfiguration';

// Derive a full set of client configuration from user provided input, environment variables, and defaults
export const buildFullCreateOptions = (options?: CreateClientOptions): FullClientConfig => {
const smartsheetClientConfig = buildSmarClientConfig(options?.smartsheetClientConfig);
const retryConfig = buildRetryConfig(options?.retryConfig);
const loggingConfig = buildLoggingConfig(options?.loggingConfig);

return {
smartsheetClientConfig,
retryConfig,
loggingConfig,
axiosConfig: options?.axiosConfig,
};
};

const buildLoggingConfig = (loggingConfig?: LoggingConfig): Required<LoggingConfig> => {
// Fetch or build new logging instance
const loggerInstance =
loggingConfig?.loggerInstance ??
winston.createLogger({
level: loggingConfig?.logLevel || DEFAULT_LOG_LEVEL,
format: format.combine(format.timestamp(), format.json()),
transports: [new winston.transports.Console()],
});

// extract the log level and validate it is supported
const logLevel = loggerInstance.level;

if (!isSupportedLogLevel(logLevel)) {
return throwRequiredConfigMissingError(
'loggerInstance.logLevel',
`Log level '${logLevel}' is not supported, please set logging level to one of [${SUPPORTED_LOG_LEVELS.join(', ')}]`
);
}

return {
loggerInstance,
logLevel: logLevel,
};
};

const buildRetryConfig = (retryConfig?: RetryConfig): Required<RetryConfig> => {
return {
maxRetries: retryConfig?.maxRetries || DEFAULT_RETRY_CONFIG.maxRetries,
};
};

const buildSmarClientConfig = (smarConfig?: SmartsheetClientConfig): Required<SmartsheetClientConfig> => {
const apiHost = smarConfig?.apiHost || (process.env.SMARTSHEET_API_HOST as ApiHost) || ApiHost.DEFAULT;

Check failure on line 62 in lib/client/buildClientConfiguration.ts

View workflow job for this annotation

GitHub Actions / coverage (16.x)

'ApiHost' cannot be used as a value because it was imported using 'import type'.

Check failure on line 62 in lib/client/buildClientConfiguration.ts

View workflow job for this annotation

GitHub Actions / coverage (18.x)

'ApiHost' cannot be used as a value because it was imported using 'import type'.

Check failure on line 62 in lib/client/buildClientConfiguration.ts

View workflow job for this annotation

GitHub Actions / coverage (14.x)

'ApiHost' cannot be used as a value because it was imported using 'import type'.
const accessToken = smarConfig?.accessToken || process.env.SMARTSHEET_ACCESS_TOKEN;

if (!accessToken) {
return throwRequiredConfigMissingError(
'accessToken',
'Please provide a value within smartsheetClientConfig or set SMARTSHEET_ACCESS_TOKEN env variable'
);
}

return { apiHost, accessToken };
};

const throwRequiredConfigMissingError = (field: string, message: string) => {
throw new Error(`Required config missing ${field}. ` + message);
};
15 changes: 15 additions & 0 deletions lib/client/createApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createEvents } from '../events';
import { buildFullCreateOptions } from './buildClientConfiguration';
import { buildHttpClient } from './httpClient/buildHttpClient';
import type { CreateClientOptions } from './types/clientConfiguration';
import type { SmartsheetClient } from './types/smartsheetClient';

// TODO un-mark Return value as partial once all endpoints are available
export const createApiClient = (options: CreateClientOptions): Partial<SmartsheetClient> => {
const fullConfiguration = buildFullCreateOptions(options);
const httpClient = buildHttpClient(fullConfiguration);

return {
events: createEvents(httpClient, fullConfiguration.loggingConfig.loggerInstance),
};
};
89 changes: 89 additions & 0 deletions lib/client/httpClient/buildHttpClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { create, type AxiosError, type AxiosInstance } from 'axios';
import type { FullClientConfig } from '../types/clientConfiguration';
import axiosRetry from 'axios-retry';
import { type SmartsheetErrorResponseData, errorCodes } from '../types/ServerResponses';
import { version } from '../../../package.json';
import { createRequestInterceptor, createResponseInterceptors, createRetryLogger } from './logging/buildCallbacks';
import { createInternalRequestLogger, type RequestLogger } from './logging/buildInternalRequestLogger';

export const buildHttpClient = (fullConfiguration: FullClientConfig): AxiosInstance => {
const axiosClient = create({
baseURL: fullConfiguration.smartsheetClientConfig.apiHost,
...(fullConfiguration.axiosConfig || {}),
headers: buildHeaders(fullConfiguration),
});

const loggingCallbacks = getInternalLogCallbacks(
createInternalRequestLogger(fullConfiguration.loggingConfig.loggerInstance)
);

configureLoggingInterceptors(axiosClient, loggingCallbacks);
configureRetry(axiosClient, fullConfiguration, loggingCallbacks);

return axiosClient;
};

const buildHeaders = (fullConfiguration: FullClientConfig) => {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
...(fullConfiguration.axiosConfig?.headers || {}),
// non-overridable values for userAgent and authorization
'User-Agent': `smartsheet-javascript-sdk/${version}`,
Authorization: `Bearer ${fullConfiguration.smartsheetClientConfig.accessToken}`,
};
};

const configureRetry = (
axiosClient: AxiosInstance,
fullConfiguration: FullClientConfig,
logCallbacks: LoggingCallbacks
) => {
axiosRetry(axiosClient, {
retries: fullConfiguration.retryConfig.maxRetries,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: shouldRetry,
onRetry: logCallbacks.retry.onRetry,
onMaxRetryTimesExceeded: logCallbacks.retry.onMaxRetries,
});
};

const configureLoggingInterceptors = (axiosClient: AxiosInstance, loggingCallbacks: LoggingCallbacks) => {
axiosClient.interceptors.request.use(loggingCallbacks.requestInterceptors);
axiosClient.interceptors.response.use(
loggingCallbacks.responseInterceptors.onFulfilled,
loggingCallbacks.responseInterceptors.onRejected
);
};

type LoggingCallbacks = ReturnType<typeof getInternalLogCallbacks>;
const getInternalLogCallbacks = (internalLogWrapper: RequestLogger) => {
return {
requestInterceptors: createRequestInterceptor(internalLogWrapper),
responseInterceptors: createResponseInterceptors(internalLogWrapper),
retry: createRetryLogger(internalLogWrapper),
};
};

/**
* Determines if a request should be retried based on the error
* @param error - The error that occurred
* @returns True if the request should be retried, false otherwise
*/
export const shouldRetry = (error: AxiosError<SmartsheetErrorResponseData>): boolean => {
// If we have a response with an error code, check if it's retryable
const responseData = error.response?.data;

if (responseData?.errorCode) {
const errorCode = responseData.errorCode;
return (
errorCode === errorCodes.RATE_LIMIT ||
errorCode === errorCodes.GATEWAY_TIMEOUT ||
errorCode === errorCodes.INTERNAL_SERVER_ERROR ||
errorCode === errorCodes.SERVICE_UNAVAILABLE
);
}

// Default to axios-retry's default retry condition
return axiosRetry.isNetworkOrIdempotentRequestError(error);
};
38 changes: 38 additions & 0 deletions lib/client/httpClient/logging/buildCallbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { AxiosRequestConfig, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
import type { RequestLogger } from './buildInternalRequestLogger';

// Create request interceptor
export const createRequestInterceptor = (logger: RequestLogger) => {
return (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
logger.logRequest(config);
return config;
};
};

// Create response interceptor
export const createResponseInterceptors = (logger: RequestLogger) => {
return {
onFulfilled: (response: AxiosResponse): AxiosResponse => {
logger.logSuccessfulResponse(response);
return response;
},
onRejected: (error: AxiosError): Promise<never> => {
if (error.config) {
logger.logErrorResponse(error.config, error);
}
return Promise.reject(error);
},
};
};

// Create retry logger
export const createRetryLogger = (logger: RequestLogger) => {
return {
onRetry: (retryCount: number, error: AxiosError, requestConfig: AxiosRequestConfig) => {
logger.logRetryAttempt(requestConfig, error, retryCount);
},
onMaxRetries: (_error: unknown, retryCount: number) => {
logger.logRetryFailure(retryCount);
},
};
};
Loading
Loading