UNPKG

woocommerce-rest-ts-api

Version:

WooCommerce REST API - Type Script Library

613 lines (604 loc) 21.1 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { AuthenticationError: () => AuthenticationError, OptionsException: () => OptionsException, WooCommerceApiError: () => WooCommerceApiError, collectAllPages: () => collectAllPages, default: () => WooCommerceRestApi, parsePaginationHeaders: () => parsePaginationHeaders }); module.exports = __toCommonJS(index_exports); var import_node_crypto = __toESM(require("node:crypto")); var import_node_http = __toESM(require("node:http")); var import_node_https = __toESM(require("node:https")); var import_oauth_1 = __toESM(require("oauth-1.0a")); var import_url_parse = __toESM(require("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 var import_axios = __toESM(require("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 (0, import_axios.default)(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 import_node_http.default.Agent({ keepAlive: true, maxSockets: 32 }); var DEFAULT_HTTPS_AGENT = new import_node_https.default.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 import_url_parse.default(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 import_url_parse.default(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 import_node_crypto.default.createHmac("sha256", key).update(base).digest("base64"); } }; return new import_oauth_1.default(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"); } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { AuthenticationError, OptionsException, WooCommerceApiError, collectAllPages, parsePaginationHeaders }); //# sourceMappingURL=index.js.map