woocommerce-rest-ts-api
Version:
WooCommerce REST API - Type Script Library
574 lines (566 loc) • 19 kB
JavaScript
// src/index.ts
import crypto from "node:crypto";
import http from "node:http";
import https from "node:https";
import OAuth from "oauth-1.0a";
import Url from "url-parse";
// src/types/errors/index.ts
var WooCommerceApiError = class _WooCommerceApiError extends Error {
constructor(message, statusCode, response, endpoint) {
super(message);
this.name = "WooCommerceApiError";
this.statusCode = statusCode;
this.response = response;
this.endpoint = endpoint;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, _WooCommerceApiError);
}
}
};
var AuthenticationError = class extends WooCommerceApiError {
constructor(message = "Authentication failed") {
super(message, 401);
this.name = "AuthenticationError";
}
};
var OptionsException = class _OptionsException extends Error {
constructor(message) {
super(message);
this.name = "OptionsException";
if (Error.captureStackTrace) {
Error.captureStackTrace(this, _OptionsException);
}
}
};
// src/utils/sanitize.ts
var SAFE_SEGMENT = /^[a-zA-Z0-9._-]+$/;
function sanitizePathSegment(segment, name) {
if (typeof segment !== "string" || segment.length === 0) {
throw new OptionsException(`${name} must be a non-empty string`);
}
if (segment.includes("..")) {
throw new OptionsException(`Invalid ${name}: contains path traversal or illegal characters`);
}
const cleaned = segment.replace(/\.+/g, ".").replace(/\/+/g, "/").replace(/^\/+|\/+$/g, "");
if (cleaned.includes("..") || cleaned.includes("/") || !SAFE_SEGMENT.test(cleaned)) {
throw new OptionsException(`Invalid ${name}: contains path traversal or illegal characters`);
}
return cleaned;
}
function sanitizeEndpoint(endpoint) {
if (typeof endpoint !== "string" || endpoint.length === 0) {
throw new OptionsException("endpoint must be a non-empty string");
}
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(endpoint) || endpoint.includes("://") || endpoint.startsWith("/") || endpoint.includes("..") || /[?#]/.test(endpoint)) {
throw new OptionsException("Invalid endpoint: must be a relative path segment without traversal or protocol");
}
const parts = endpoint.split("/").filter(Boolean);
const safeParts = parts.map((p, i) => sanitizePathSegment(p, `endpoint part[${i}]`));
return safeParts.join("/");
}
function sanitizeApiVersion(v) {
const cleaned = String(v || "").trim().replace(/^\/+|\/+$/g, "");
if (cleaned.includes("..") || cleaned.split("/").length > 2 || !/^[a-zA-Z0-9/._-]+$/.test(cleaned)) {
throw new OptionsException("Invalid version: contains path traversal or illegal characters");
}
return cleaned;
}
function validateBaseUrl(urlStr) {
let u;
try {
u = new URL(urlStr);
} catch {
throw new OptionsException("url must be a valid absolute URL (http/https)");
}
if (u.protocol !== "http:" && u.protocol !== "https:") {
throw new OptionsException("url must use http or https protocol");
}
return u;
}
// src/http/RetryStrategy.ts
import axios from "axios";
var ExponentialBackoffRetryStrategy = class {
constructor(cfg = {}) {
this.config = cfg;
}
async executeWithRetry(options) {
const maxRetries = this.config.retries ?? 0;
const baseDelay = this.config.retryDelay ?? 1e3;
const retryableStatuses = this.config.retryOn ?? [408, 429, 500, 502, 503, 504];
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await axios(options);
} catch (error) {
lastError = error;
const err = error;
const status = err.response?.status;
const isNetworkError = !err.response || err.code === "ECONNRESET" || err.code === "ETIMEDOUT" || err.code === "ECONNABORTED";
const isRetryableStatus = !status || retryableStatuses.includes(status);
const shouldRetry = attempt < maxRetries && (isNetworkError || isRetryableStatus);
if (!shouldRetry) {
throw error;
}
let delay = baseDelay * Math.pow(2, attempt) * (0.5 + Math.random() * 0.5);
if (status === 429 && err.response?.headers) {
const retryAfterHeader = err.response.headers["retry-after"];
if (retryAfterHeader != null) {
const asSeconds = parseInt(String(retryAfterHeader), 10);
if (!Number.isNaN(asSeconds) && asSeconds > 0) {
delay = Math.max(delay, asSeconds * 1e3);
} else {
const asDate = new Date(String(retryAfterHeader));
if (!Number.isNaN(asDate.getTime())) {
const delta = asDate.getTime() - Date.now();
if (delta > 0) delay = Math.max(delay, delta);
}
}
}
}
delay = Math.min(delay, 3e4);
await new Promise((resolve) => setTimeout(resolve, Math.floor(delay)));
}
}
throw lastError;
}
};
function createDefaultRetryStrategy(retryConfig) {
return new ExponentialBackoffRetryStrategy(retryConfig);
}
// src/http/Throttler.ts
var ConcurrencyThrottler = class {
constructor(maxConcurrent) {
this.current = 0;
this.queue = [];
this.maxConcurrent = maxConcurrent;
}
async acquire() {
if (this.maxConcurrent <= 0) {
return;
}
if (this.current < this.maxConcurrent) {
this.current++;
return;
}
return new Promise((resolve) => {
this.queue.push(() => {
this.current++;
resolve();
});
});
}
release() {
if (this.maxConcurrent <= 0) {
return;
}
this.current = Math.max(0, this.current - 1);
const next = this.queue.shift();
if (next) {
next();
}
}
};
function createThrottler(maxConcurrentRequests) {
return new ConcurrencyThrottler(maxConcurrentRequests ?? 0);
}
// src/http/ErrorNormalizer.ts
function normalizeAxiosError(error, context) {
const err = error;
if (err.response) {
return new WooCommerceApiError(
err.response.data?.message || err.message || "API request failed",
err.response.status,
err.response.data,
context.endpoint
);
} else if (err.request) {
return new WooCommerceApiError(
"Network error: No response received from server",
0,
null,
context.endpoint
);
} else {
return new WooCommerceApiError(
`Request setup error: ${err.message || "unknown"}`,
0,
null,
context.endpoint
);
}
}
// src/utils/PaginationHelper.ts
function parsePaginationHeaders(response) {
const h = response.headers || {};
const total = Number(h["x-wp-total"] ?? h["X-WP-Total"] ?? 0) || 0;
const totalPages = Number(h["x-wp-totalpages"] ?? h["X-WP-TotalPages"] ?? 1) || 1;
return { total, totalPages };
}
async function collectAllPages(fetchPage, options) {
const perPage = options?.perPage ?? 10;
const maxPages = options?.maxPages ?? Infinity;
const results = [];
let page = 1;
while (true) {
if (page > maxPages) break;
const res = await fetchPage(page, perPage);
const items = Array.isArray(res.data) ? res.data : [];
results.push(...items);
const info = parsePaginationHeaders(res);
if (items.length < perPage || page >= info.totalPages) {
break;
}
page += 1;
}
return results;
}
// src/index.ts
var DEFAULT_HTTP_AGENT = new http.Agent({ keepAlive: true, maxSockets: 32 });
var DEFAULT_HTTPS_AGENT = new https.Agent({ keepAlive: true, maxSockets: 32 });
var WooCommerceRestApi = class _WooCommerceRestApi {
/**
* Class constructor.
*
* @param {Object} opt
*/
constructor(opt) {
this._opt = opt;
if (!(this instanceof _WooCommerceRestApi)) {
return new _WooCommerceRestApi(opt);
}
if (!this._opt.url || this._opt.url === "") {
throw new OptionsException("url is required");
}
validateBaseUrl(this._opt.url);
if (!this._opt.consumerKey || this._opt.consumerKey === "") {
throw new OptionsException("consumerKey is required");
}
if (!this._opt.consumerSecret || this._opt.consumerSecret === "") {
throw new OptionsException("consumerSecret is required");
}
this._setDefaultsOptions(this._opt);
this._throttler = createThrottler(this._opt.maxConcurrentRequests);
this._retryStrategy = createDefaultRetryStrategy(this._opt.retryConfig);
}
/**
* Set default options
*
* @param {Object} opt
*/
_setDefaultsOptions(opt) {
const rawPrefix = opt.wpAPIPrefix || "wp-json";
const rawVersion = opt.version || "wc/v3";
this._opt.wpAPIPrefix = sanitizePathSegment(rawPrefix, "wpAPIPrefix");
this._opt.version = sanitizeApiVersion(rawVersion);
this._opt.isHttps = /^https/i.test(this._opt.url);
this._opt.encoding = opt.encoding || "utf-8";
this._opt.queryStringAuth = opt.queryStringAuth || false;
this._opt.classVersion = opt.classVersion || "8.0.0";
}
/**
* Protected factory hooks for subclass DI / custom strategies (advanced extensibility).
* Default implementations are created in ctor from options.
*/
createThrottler(max) {
return createThrottler(max);
}
createRetryStrategy(cfg) {
return createDefaultRetryStrategy(cfg);
}
// Backward-compat thin delegations (tests and power users call these _ methods directly)
async _acquireSlot() {
return this._throttler.acquire();
}
_releaseSlot() {
this._throttler.release();
}
/**
* Core axios execution delegated to the (pluggable) RetryStrategy.
* The strategy encapsulates exp backoff + 429 awareness.
*/
async _executeWithRetry(options) {
return this._retryStrategy.executeWithRetry(options);
}
/**
* Normalize query string for oAuth 1.0a.
* Nested param flattening lives in callers / URL builders; the legacy
* commented-out `_parseParamsObject` was dead code and has been removed.
*
* @param {String} url
* @param {Object} params
*
* @return {String}
*/
_normalizeQueryString(url, params) {
if (url.indexOf("?") === -1 && Object.keys(params).length === 0) {
return url;
}
const query = new Url(url, true).query;
const values = [];
let queryString = "";
for (const key in query) {
values.push(key);
}
values.sort();
for (const i in values) {
if (queryString.length) queryString += "&";
queryString += encodeURIComponent(values[i]) + "=" + encodeURIComponent(query[values[i]]);
}
queryString = queryString.replace(/%5B/g, "[").replace(/%5D/g, "]");
const urlObject = url.split("?")[0] + "?" + queryString;
return urlObject;
}
/**
* Get URL
*
* SECURITY: Always sanitizes the endpoint. Uses URL where practical + guards.
*/
_getUrl(endpoint, params) {
const safeEndpoint = sanitizeEndpoint(endpoint);
const base = this._opt.url.endsWith("/") ? this._opt.url : this._opt.url + "/";
let url = `${base}${this._opt.wpAPIPrefix}/${this._opt.version}/${safeEndpoint}`;
const q = { ...params };
if (q.id != null) {
url = `${url}/${encodeURIComponent(String(q.id))}`;
delete q.id;
}
const queryKeys = Object.keys(q);
if (queryKeys.length > 0) {
const qs = queryKeys.sort().map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(q[k]))}`).join("&");
url = `${url}?${qs}`;
}
if (this._opt.port) {
try {
const u = new URL(url);
u.port = String(this._opt.port);
url = u.toString();
} catch {
const hostname = new Url(url).hostname;
url = url.replace(hostname, `${hostname}:${this._opt.port}`);
}
}
return url;
}
/**
* Create Hmac was deprecated fot this version at 16.11.2022
* Get OAuth 1.0a since it is mandatory for WooCommerce REST API
* You must use OAuth 1.0a "one-legged" authentication to ensure REST API credentials cannot be intercepted by an attacker.
* Reference: https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication-over-http
* @return {Object}
*/
_getOAuth() {
const data = {
consumer: {
key: this._opt.consumerKey,
secret: this._opt.consumerSecret
},
signature_method: "HMAC-SHA256",
hash_function: (base, key) => {
return crypto.createHmac("sha256", key).update(base).digest("base64");
}
};
return new OAuth(data);
}
/**
* Axios request
* Mount the options to send to axios and send the request.
*
* Implements:
* - Resource limits (maxContentLength / maxBodyLength) to fully mitigate CVE-2026-44488
* - Default timeout enforcement (30s) for safety
* - Client-side request throttling via maxConcurrentRequests
* - Exponential backoff retries with rate-limit (429 / Retry-After) awareness
*
* All via _request core + axiosConfig support. Backward compatible.
*
* @param {String} method
* @param {String} endpoint
* @param {Object} data
* @param {Object} params
*
* @return {Object}
*/
async _request(method, endpoint, data, params = {}) {
const url = this._getUrl(endpoint, params);
const header = {
Accept: "application/json"
};
if (typeof process !== "undefined" && Object.prototype.toString.call(process) === "[object process]") {
header["User-Agent"] = "WooCommerce REST API - TS Client/" + this._opt.classVersion;
}
const DEFAULT_MAX_CONTENT_LENGTH = 10 * 1024 * 1024;
const DEFAULT_MAX_BODY_LENGTH = 10 * 1024 * 1024;
const DEFAULT_TIMEOUT = 3e4;
const axCfg = this._opt.axiosConfig ?? {};
const explicitMaxContent = "maxContentLength" in axCfg ? axCfg.maxContentLength : void 0;
const explicitMaxBody = "maxBodyLength" in axCfg ? axCfg.maxBodyLength : void 0;
const explicitTimeout = "timeout" in axCfg ? axCfg.timeout : void 0;
const axiosUrl = this._opt.isHttps ? url.split("?")[0] : url;
let options = {
url: axiosUrl,
method,
responseEncoding: this._opt.encoding,
timeout: explicitTimeout !== void 0 ? explicitTimeout : this._opt.timeout ?? DEFAULT_TIMEOUT,
responseType: "json",
headers: { ...header },
params: {},
data: data ? JSON.stringify(data) : null,
// Pre-set limits; may be overridden by explicit axiosConfig below
maxContentLength: explicitMaxContent !== void 0 ? explicitMaxContent : this._opt.maxContentLength ?? DEFAULT_MAX_CONTENT_LENGTH,
maxBodyLength: explicitMaxBody !== void 0 ? explicitMaxBody : this._opt.maxBodyLength ?? DEFAULT_MAX_BODY_LENGTH
};
if (this._opt.isHttps) {
if (this._opt.queryStringAuth) {
options.params = {
consumer_key: this._opt.consumerKey,
consumer_secret: this._opt.consumerSecret
};
} else {
options.auth = {
username: this._opt.consumerKey,
password: this._opt.consumerSecret
};
}
const queryParams = { ...params };
if (queryParams.id != null) {
delete queryParams.id;
}
options.params = { ...options.params, ...queryParams };
} else {
options.params = this._getOAuth().authorize({
url,
// full url (with qs) for correct OAuth signature
method
});
}
if (options.data) {
options.headers = {
...header,
"Content-Type": `application/json; charset=${this._opt.encoding}`
};
}
options = { ...options, ...this._opt.axiosConfig };
if (!("httpAgent" in options) && !("httpsAgent" in options) && !("agent" in options)) {
const isHttpsRequest = /^https:/i.test(String(options.url || ""));
if (isHttpsRequest) {
options.httpsAgent = options.httpsAgent ?? DEFAULT_HTTPS_AGENT;
} else {
options.httpAgent = options.httpAgent ?? DEFAULT_HTTP_AGENT;
}
}
if (options.timeout == null || options.timeout <= 0) {
options.timeout = DEFAULT_TIMEOUT;
}
if (options.maxContentLength == null || options.maxContentLength < 0) {
if (explicitMaxContent === void 0) {
options.maxContentLength = this._opt.maxContentLength ?? DEFAULT_MAX_CONTENT_LENGTH;
}
}
if (options.maxBodyLength == null || options.maxBodyLength < 0) {
if (explicitMaxBody === void 0) {
options.maxBodyLength = this._opt.maxBodyLength ?? DEFAULT_MAX_BODY_LENGTH;
}
}
await this._acquireSlot();
try {
return await this._executeWithRetry(options);
} catch (error) {
throw normalizeAxiosError(error, { endpoint });
} finally {
this._releaseSlot();
}
}
/**
* GET requests
*
* @param {String} endpoint
* @param {Object} params
*
* @return {Object}
*/
get(endpoint, params) {
return this._request("GET", endpoint, void 0, params).then(
(response) => ({
data: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers
})
);
}
post(endpoint, data, params) {
return this._request("POST", endpoint, data, params).then((response) => ({
data: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers
}));
}
put(endpoint, data, params) {
return this._request("PUT", endpoint, data, params).then((response) => ({
data: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers
}));
}
delete(endpoint, data, params) {
return this._request("DELETE", endpoint, data, params).then((response) => ({
data: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers
}));
}
options(endpoint, params) {
return this._request("OPTIONS", endpoint, {}, params).then((response) => ({
data: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers
}));
}
// Convenience methods (still available for DX; fully typed)
async getProducts(params) {
return this.get("products", params);
}
async getProduct(id) {
return this.get("products", { id });
}
async createProduct(productData) {
return this.post("products", productData);
}
async updateProduct(id, productData) {
return this.put("products", productData, { id });
}
async getOrders(params) {
return this.get("orders", params);
}
async getOrder(id) {
return this.get("orders", { id });
}
async createOrder(orderData) {
return this.post("orders", orderData);
}
async getCustomers(params) {
return this.get("customers", params);
}
async getCustomer(id) {
return this.get("customers", { id });
}
async getCoupons(params) {
return this.get("coupons", params);
}
async getSystemStatus() {
return this.get("system_status");
}
};
export {
AuthenticationError,
OptionsException,
WooCommerceApiError,
collectAllPages,
WooCommerceRestApi as default,
parsePaginationHeaders
};
//# sourceMappingURL=index.mjs.map