@neatsuite/http
Version:
A TypeScript-first NetSuite API client with built-in OAuth 1.0a authentication, retry logic, and performance monitoring
578 lines (573 loc) • 15.4 kB
JavaScript
import axios from 'axios';
import OAuth from 'oauth-1.0a';
import crypto from 'crypto';
import http from 'http';
import https from 'https';
// src/client.ts
// src/types.ts
var NetSuiteError = class _NetSuiteError extends Error {
constructor(message, status, code, details, response) {
super(message);
this.name = "NetSuiteError";
this.status = status;
this.code = code;
this.details = details;
this.response = response;
Error.captureStackTrace(this, _NetSuiteError);
}
};
// src/client.ts
var pRetry;
import('p-retry').then((module) => {
pRetry = module;
});
var NetSuiteClient = class {
constructor(config, logger) {
this.middlewares = [];
this.config = {
timeout: 15e3,
retries: 3,
enablePerformanceLogging: false,
...config
};
this.logger = logger;
this.oauth = new OAuth({
consumer: {
key: config.oauth.consumerKey,
secret: config.oauth.consumerSecret
},
signature_method: "HMAC-SHA256",
hash_function: (base_string, key) => {
return crypto.createHmac("sha256", key).update(base_string).digest("base64");
},
realm: config.oauth.realm
});
this.axiosInstance = axios.create({
timeout: this.config.timeout,
headers: {
"Content-Type": "application/json",
"Accept-Encoding": "gzip, deflate",
...this.config.headers
},
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true }),
maxRedirects: 5,
validateStatus: (status) => status < 500
});
if (this.config.enablePerformanceLogging || logger) {
this.setupInterceptors();
}
}
/**
* Add middleware to the request pipeline
* @param middleware - Middleware function
*/
use(middleware) {
this.middlewares.push(middleware);
}
/**
* Setup axios interceptors for logging
*/
setupInterceptors() {
this.axiosInstance.interceptors.request.use(
(config) => {
if (this.logger) {
this.logger.debug("NetSuite API Request", {
method: config.method,
url: config.url,
headers: config.headers
});
}
return config;
},
(error) => {
if (this.logger) {
this.logger.error("NetSuite API Request Error", { error });
}
return Promise.reject(error);
}
);
this.axiosInstance.interceptors.response.use(
(response) => {
if (this.logger) {
this.logger.debug("NetSuite API Response", {
status: response.status,
url: response.config.url,
duration: response.config?.metadata?.duration
});
}
return response;
},
(error) => {
if (this.logger) {
this.logger.error("NetSuite API Response Error", {
status: error.response?.status,
url: error.config?.url,
error: error.message
});
}
return Promise.reject(error);
}
);
}
/**
* Generate OAuth authorization header
*/
generateAuthHeader(url, method) {
const request_data = { url, method };
const token = {
key: this.config.oauth.tokenKey,
secret: this.config.oauth.tokenSecret
};
return this.oauth.toHeader(this.oauth.authorize(request_data, token));
}
/**
* Measure performance of an operation
*/
async measurePerformance(operation, fn) {
const startTime = Date.now();
try {
const result = await fn();
const duration = Date.now() - startTime;
if (this.config.enablePerformanceLogging) {
this.logger?.info(`[Performance] ${operation} completed`, { duration });
}
return { result, duration };
} catch (error) {
const duration = Date.now() - startTime;
this.logger?.error(`[Performance] ${operation} failed`, { duration, error });
throw error;
}
}
/**
* Execute middleware chain
*/
async executeMiddlewares(context, index, finalHandler) {
if (index >= this.middlewares.length) {
return finalHandler();
}
const middleware = this.middlewares[index];
return middleware(
context,
() => this.executeMiddlewares(context, index + 1, finalHandler)
);
}
/**
* Make a request to NetSuite API
*
* @example
* ```typescript
* const response = await client.request({
* url: 'https://account.restlets.api.netsuite.com/app/site/hosting/restlet.nl',
* method: 'POST',
* body: { action: 'search', query: 'customer' }
* });
* ```
*/
async request(options) {
const {
url,
method = "GET",
body = null,
headers = {},
retries = this.config.retries,
axiosConfig = {}
} = options;
const authHeader = this.generateAuthHeader(url, method);
const requestConfig = {
method,
url,
headers: {
...authHeader,
...headers
},
...axiosConfig,
metadata: {}
};
if (body && ["POST", "PUT", "PATCH"].includes(method)) {
requestConfig.data = body;
}
const retryOptions = {
retries: retries || 3,
minTimeout: 1e3,
maxTimeout: 3e3,
shouldRetry: (error) => {
if (error.response?.status >= 400 && error.response?.status < 500) {
return false;
}
return true;
},
onFailedAttempt: (error) => {
this.logger?.warn(`NetSuite API retry attempt`, {
attemptNumber: error.attemptNumber,
retriesLeft: error.retriesLeft
});
}
};
const makeRequest = async () => {
const startTime = Date.now();
requestConfig.metadata.startTime = startTime;
const response2 = await this.executeMiddlewares(
{
config: options,
authHeaders: authHeader,
startTime
},
0,
() => this.axiosInstance(requestConfig)
);
requestConfig.metadata.duration = Date.now() - startTime;
if (response2.status !== 200) {
throw new NetSuiteError(
`NetSuite API returned status ${response2.status}`,
response2.status,
"HTTP_ERROR",
response2.data,
response2
);
}
return response2;
};
const { result: response, duration } = await this.measurePerformance(
`NetSuite API ${method} ${url}`,
() => pRetry(makeRequest, retryOptions)
);
return {
data: response.data,
status: response.status,
headers: response.headers,
duration
};
}
/**
* Build NetSuite RESTlet URL
*/
buildRestletUrl(params) {
const baseUrl = `https://${this.config.accountId}.restlets.api.netsuite.com/app/site/hosting/restlet.nl`;
const queryParams = new URLSearchParams({
script: params.script.toString(),
deploy: params.deploy.toString(),
...params.params
});
return `${baseUrl}?${queryParams.toString()}`;
}
/**
* Make a RESTlet call
*
* @example
* ```typescript
* const response = await client.restlet({
* script: '123',
* deploy: '1',
* params: { action: 'getCustomer', id: '456' }
* });
* ```
*/
async restlet(params, options) {
const url = this.buildRestletUrl(params);
return this.request({
url,
...options
});
}
/**
* GET request helper
*/
async get(url, options) {
return this.request({ ...options, url, method: "GET" });
}
/**
* POST request helper
*/
async post(url, body, options) {
return this.request({ ...options, url, method: "POST", body });
}
/**
* PUT request helper
*/
async put(url, body, options) {
return this.request({ ...options, url, method: "PUT", body });
}
/**
* PATCH request helper
*/
async patch(url, body, options) {
return this.request({ ...options, url, method: "PATCH", body });
}
/**
* DELETE request helper
*/
async delete(url, options) {
return this.request({ ...options, url, method: "DELETE" });
}
/**
* Handle API errors
*/
static isNetSuiteError(error) {
return error instanceof NetSuiteError;
}
/**
* Create error from axios error
*/
static createError(error) {
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
return new NetSuiteError(
"Request timeout - NetSuite API is taking too long to respond",
504,
"TIMEOUT"
);
}
if (error.response) {
return new NetSuiteError(
error.response.data?.detail || "Error from NetSuite API",
error.response.status,
error.response.data?.["o:errorCode"],
error.response.data,
error.response
);
}
return new NetSuiteError(
error.message || "Unknown error occurred",
500,
"UNKNOWN_ERROR"
);
}
};
// src/utils.ts
var ResponseCache = class {
constructor() {
this.cache = /* @__PURE__ */ new Map();
}
/**
* Get cached response
*/
get(key) {
const cached = this.cache.get(key);
if (!cached) return null;
if (Date.now() > cached.expiry) {
this.cache.delete(key);
return null;
}
return cached.data;
}
/**
* Set cached response
*/
set(key, data, ttl) {
this.cache.set(key, {
data,
expiry: Date.now() + ttl * 1e3
});
}
/**
* Clear cache
*/
clear() {
this.cache.clear();
}
/**
* Remove specific key
*/
delete(key) {
return this.cache.delete(key);
}
};
var RateLimiter = class {
constructor(maxRequests = 100, windowMs = 6e4) {
this.requests = [];
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
/**
* Check if request can be made
*/
canMakeRequest() {
const now = Date.now();
this.requests = this.requests.filter((time) => now - time < this.windowMs);
return this.requests.length < this.maxRequests;
}
/**
* Record a request
*/
recordRequest() {
this.requests.push(Date.now());
}
/**
* Get remaining requests
*/
getRemainingRequests() {
const now = Date.now();
this.requests = this.requests.filter((time) => now - time < this.windowMs);
return Math.max(0, this.maxRequests - this.requests.length);
}
/**
* Get time until next request can be made
*/
getTimeUntilNextRequest() {
if (this.canMakeRequest()) return 0;
const oldestRequest = Math.min(...this.requests);
return Math.max(0, this.windowMs - (Date.now() - oldestRequest));
}
};
var RequestBatcher = class {
constructor(batchProcessor, batchSize = 10, batchDelay = 50) {
this.batch = [];
this.batchProcessor = batchProcessor;
this.batchSize = batchSize;
this.batchDelay = batchDelay;
}
/**
* Add request to batch
*/
add(key) {
return new Promise((resolve, reject) => {
this.batch.push({ key, resolve, reject });
if (this.batch.length >= this.batchSize) {
this.processBatch();
} else if (!this.batchTimeout) {
this.batchTimeout = setTimeout(() => this.processBatch(), this.batchDelay);
}
});
}
/**
* Process the current batch
*/
async processBatch() {
if (this.batchTimeout) {
clearTimeout(this.batchTimeout);
this.batchTimeout = void 0;
}
const currentBatch = this.batch.splice(0, this.batchSize);
if (currentBatch.length === 0) return;
const keys = currentBatch.map((item) => item.key);
try {
const results = await this.batchProcessor(keys);
currentBatch.forEach(({ key, resolve, reject }) => {
const result = results.get(key);
if (result !== void 0) {
resolve(result);
} else {
reject(new Error(`No result for key: ${key}`));
}
});
} catch (error) {
currentBatch.forEach(({ reject }) => reject(error));
}
}
};
async function retryWithBackoff(fn, options = {}) {
const {
maxRetries = 3,
initialDelay = 1e3,
maxDelay = 3e4,
factor = 2,
onRetry
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxRetries) {
throw error;
}
if (onRetry) {
onRetry(attempt + 1, error);
}
const delay = Math.min(
initialDelay * Math.pow(factor, attempt),
maxDelay
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
function createCacheKey(url, method, params) {
const sortedParams = params ? Object.keys(params).sort().reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {}) : {};
return `${method}:${url}:${JSON.stringify(sortedParams)}`;
}
function parseNetSuiteError(error) {
if (NetSuiteError.prototype.isPrototypeOf(error)) {
return {
message: error.message,
code: error.code,
details: error.details
};
}
if (error.response?.data) {
const data = error.response.data;
return {
message: data.detail || data.message || "NetSuite API error",
code: data["o:errorCode"] || data.code,
details: data["o:errorDetails"] || data
};
}
return {
message: error.message || "Unknown error",
code: error.code,
details: error
};
}
function formatNetSuiteDate(date) {
return date.toISOString().split("T")[0];
}
function parseNetSuiteDate(dateString) {
return /* @__PURE__ */ new Date(dateString + "T00:00:00Z");
}
function buildSearchQuery(filters) {
return Object.entries(filters).filter(([_, value]) => value !== null && value !== void 0).map(([key, value]) => {
if (Array.isArray(value)) {
return `${key} IN (${value.map((v) => `'${v}'`).join(",")})`;
}
if (typeof value === "string") {
return `${key} = '${value}'`;
}
return `${key} = ${value}`;
}).join(" AND ");
}
function sanitizeFieldValue(value) {
if (value === null || value === void 0) {
return "";
}
if (typeof value === "string") {
return value.replace(/[\x00-\x1F\x7F]/g, "").trim();
}
if (Array.isArray(value)) {
return value.map(sanitizeFieldValue);
}
if (typeof value === "object") {
const sanitized = {};
for (const [key, val] of Object.entries(value)) {
sanitized[key] = sanitizeFieldValue(val);
}
return sanitized;
}
return value;
}
function toInternalId(id) {
return String(id);
}
function validateConfig(config) {
const errors = [];
if (!config.oauth) {
errors.push("OAuth configuration is required");
} else {
if (!config.oauth.consumerKey) errors.push("OAuth consumer key is required");
if (!config.oauth.consumerSecret) errors.push("OAuth consumer secret is required");
if (!config.oauth.tokenKey) errors.push("OAuth token key is required");
if (!config.oauth.tokenSecret) errors.push("OAuth token secret is required");
if (!config.oauth.realm) errors.push("OAuth realm is required");
}
if (!config.accountId) {
errors.push("Account ID is required");
}
return errors;
}
export { NetSuiteClient, NetSuiteError, RateLimiter, RequestBatcher, ResponseCache, buildSearchQuery, createCacheKey, formatNetSuiteDate, parseNetSuiteDate, parseNetSuiteError, retryWithBackoff, sanitizeFieldValue, toInternalId, validateConfig };
//# sourceMappingURL=index.mjs.map
//# sourceMappingURL=index.mjs.map