shopify-admin-api
Version:
Shopify Admin API is a NodeJS library built to help developers easily authenticate and make calls against the Shopify API. It was inspired by and borrows heavily from ShopifySharp.
194 lines (193 loc) • 8.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseService = void 0;
const url_join_1 = __importDefault(require("url-join"));
const node_fetch_1 = __importDefault(require("node-fetch"));
const shopify_error_1 = __importDefault(require("./shopify_error"));
const jsuri_1 = __importDefault(require("jsuri"));
// TODO use https://www.npmjs.com/package/bottleneck
// used on https://github.com/dcworldwide/Shopify-Prime/blob/master/infrastructure/base_service.ts?
const p_queue_1 = __importDefault(require("p-queue"));
class ApiInfo {
constructor(accessToken) {
/**
* As a reasonable default, assume the bucket is half full and the limit is 40.
* This is updated after the first response from Shopify.
*/
this._callLimits = {
timestamp: Date.now(),
remaining: 20,
current: 20,
max: 40,
retryAfter: null,
};
this.accessToken = accessToken;
// A concurrency of 1 guarantees that responses come in the same order as requested.
this.requestQueue = new p_queue_1.default({ concurrency: 1 });
}
/**
* Sets the call-limits from the last Shopify response header x-shopify-shop-api-call-limit'.
* This is a string of the form `${current}/${max}`, indicating how full the "leaky bucket" of requests already is.
* In case of a 429 (too many requests) error, Shopify may additionally provide a 'retry-after' header, indicating
* the number of seconds when the request should be retried.
* This is all taken into account for timing the next API call.
*
* @param val string
* @param timestamp number | null
* @param retryAfter number | null
* @returns CallLimits
*
* TODO: Certain endpoints have limits that differ from the normal bucket size. For example, order.create is limited
* to 5 per minute for development stores. These individual limits should be added to the individual API service
* classes where they apply.
*/
setCallLimits(limits, retryAfter = null, timestamp = null) {
const [current, max] = limits.split('/').map((s) => parseInt(s));
const oldTimestamp = this._callLimits.timestamp;
timestamp = timestamp || Date.now();
this._callLimits.timestamp = timestamp;
this._callLimits.remaining = max - current;
this._callLimits.max = max;
this._callLimits.current = current;
this._callLimits.retryAfter =
(retryAfter && parseFloat(retryAfter)) ||
(this._callLimits.retryAfter &&
Math.max(0, (this._callLimits.retryAfter - timestamp + oldTimestamp) / 1000));
return this._callLimits;
}
/**
* Gets the current call limits, calculated from the last response by Shopify and the time passed since then.
* Optional parameter `increaseCurrent` increments the stored bucket fill state pre-emptively. This is done before making a request,
* so that the limit is already updated to the higher fill state before a response comes back.
*
* @param increaseCurrent number
* @returns CallLimits
*/
getCallLimits(increaseCurrent = 0) {
const now = Date.now();
const limits = { ...this._callLimits };
const secondsPassed = (now - limits.timestamp) / 1000;
limits.current = Math.max(0, increaseCurrent + limits.current - 2 * secondsPassed);
limits.remaining = limits.max - limits.current;
limits.timestamp = now;
if (limits.retryAfter) {
limits.retryAfter = Math.max(0, limits.retryAfter - secondsPassed);
}
// If we increase the current fill state, we must update the base for our calculations.
if (increaseCurrent) {
this._callLimits = { ...limits };
}
return limits;
}
}
class BaseService {
constructor(shopDomain, accessToken, resource) {
this.shopDomain = shopDomain;
this.accessToken = accessToken;
this.resource = resource;
this.apiInfo = BaseService._apiInfo;
//Ensure resource starts with admin/
if (!/^[\/]?admin\//gi.test(resource)) {
this.resource = 'admin/api/2021-07/' + resource;
}
if (!this.apiInfo[shopDomain] ||
this.apiInfo[shopDomain].accessToken !== accessToken) {
this.apiInfo[shopDomain] = new ApiInfo(accessToken);
}
}
getCallLimits(increaseCurrent = 0) {
return this.apiInfo[this.shopDomain].getCallLimits(increaseCurrent);
}
static buildDefaultHeaders() {
const headers = {
Accept: 'application/json',
'User-Agent': `Shopify Admin API (https://github.com/ArtCodeStudio/shopify-admin-api)`,
};
return headers;
}
/**
* Joins URI paths into one single string, replacing bad slashes and ensuring the path doesn't end in /.json.
*/
joinUriPaths(...paths) {
return (0, url_join_1.default)(...paths).replace(/\/\.json/gi, '.json');
}
async createRequest(method, path, rootElement, payload) {
method = method.toUpperCase();
const options = {
headers: BaseService.buildDefaultHeaders(),
method: method,
body: undefined,
};
if (this.accessToken) {
options.headers['X-Shopify-Access-Token'] = this.accessToken;
}
const url = new jsuri_1.default(this.shopDomain);
url.protocol('https');
url.path(this.joinUriPaths(this.resource, path));
if ((method === 'GET' || method === 'DELETE') && payload) {
for (const prop in payload) {
const value = payload[prop];
// Shopify expects qs array values to be joined by a comma, e.g. fields=field1,field2,field3
url.addQueryParam(prop, Array.isArray(value) ? value.join(',') : value);
}
}
else if (payload) {
options.body = JSON.stringify(payload);
options.headers['Content-Type'] = 'application/json';
}
// Fetch will only throw an exception when there is a network-related error, not when Shopify returns a non-200 response.
// We re-queue the request while there are 429 errors (too many requests / rate limit exceeded)
/**
* Queue requests and keep the call limit in check.
*
* NOTE: this method is provisory. There are two problems with it:
*
* 1. the queue can become arbitrary large, with longer and longer wait times and
* eventually memory problems, if an app just keeps on adding request after request.
*
* 2. There is still no guarantee that a `too many requests` (code 429) will not happen if a big number of requests is added at once.
*/
const result = await BaseService._apiInfo[this.shopDomain].requestQueue.add(async () => {
do {
// Check that we don't hit call limit
let { remaining, retryAfter } = this.getCallLimits();
while (remaining < 5 || (retryAfter && retryAfter > 0)) {
({ remaining, retryAfter } = this.getCallLimits());
await new Promise((res) => setTimeout(res, Math.max((5 - remaining) * 500), (retryAfter || 0) * 1000));
}
const res = await (0, node_fetch_1.default)(url.toString(), options);
const headerCallLimits = res.headers.get('x-shopify-shop-api-call-limit');
const headerRetryAfter = res.headers.get('retry-after');
if (headerCallLimits) {
this.apiInfo[this.shopDomain].setCallLimits(headerCallLimits, headerRetryAfter);
}
// Continue the loop while we get 429 errors
if (res.status === 429) {
continue;
}
return res;
} while (true);
});
// Shopify implement 204 - no content for DELETE requests
if (method === 'DELETE' && result.status === 204) {
return;
}
let json = (await result.text());
try {
json = JSON.parse(json);
}
catch (e) {
throw new shopify_error_1.default(result, json);
}
if (!result.ok) {
throw new shopify_error_1.default(result, json);
}
return rootElement ? json[rootElement] : json;
}
}
exports.BaseService = BaseService;
BaseService._apiInfo = {};
exports.default = BaseService;