inventora-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.
235 lines (234 loc) • 11.1 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseService = void 0;
const joinPaths = require('url-join');
const node_fetch_1 = require("node-fetch");
const shopify_error_1 = require("./shopify_error");
const uri = 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 = require("p-queue");
class ApiInfo {
constructor(accessToken) {
this._callLimits = {
timestamp: undefined,
remaining: undefined,
current: undefined,
max: undefined,
};
this.accessToken = accessToken;
this.requestQueue = new p_queue_1.default({ concurrency: 30 });
}
setCallLimits(val) {
const [current, max] = val.split('/').map(s => parseInt(s));
this._callLimits.timestamp = Date.now();
this._callLimits.remaining = max - current;
this._callLimits.max = max;
this._callLimits.current = current;
return this._callLimits;
}
getCallLimits() {
let limits = Object.assign({}, this._callLimits);
const secondsPassed = (Date.now() - limits.timestamp) / 1000;
limits.current = Math.max(0, limits.current - 2 * (secondsPassed));
limits.remaining = limits.max - limits.current;
return limits;
}
}
class BaseService {
constructor(shopDomain, accessToken, resource) {
this.shopDomain = shopDomain;
this.accessToken = accessToken;
this.resource = resource;
//Ensure resource starts with admin/
if (!/^[\/]?admin\//ig.test(resource)) {
this.resource = "admin/api/2024-07/" + resource;
}
if (!BaseService.apiInfo[shopDomain] || BaseService.apiInfo[shopDomain].accessToken !== accessToken) {
BaseService.apiInfo[shopDomain] = new ApiInfo(accessToken);
}
}
getCallLimits() {
return BaseService.apiInfo[this.shopDomain].getCallLimits();
}
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 joinPaths(...paths).replace(/\/\.json/ig, ".json");
}
createRequest(method, path, rootElement, payload) {
return __awaiter(this, void 0, void 0, function* () {
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 uri(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.
/**
* 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 = yield BaseService.apiInfo[this.shopDomain].requestQueue.add(() => __awaiter(this, void 0, void 0, function* () {
// Check that we don't hit call limit
let remaining = this.getCallLimits().remaining;
if (remaining < 5) {
return (new Promise(res => setTimeout(res, 10000 - remaining)))
.then(() => (0, node_fetch_1.default)(url.toString(), options));
}
// console.log('Fetch url:', url.toString());
// console.log('options:', options)
return (0, node_fetch_1.default)(url.toString(), options);
}));
const callLimits = result.headers.get('x-shopify-shop-api-call-limit');
if (callLimits) {
BaseService.apiInfo[this.shopDomain].setCallLimits(callLimits);
}
// Shopify implement 204 - no content for DELETE requests
if (result.status == 204) {
return;
}
let json = yield 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;
});
}
createPaginatedRequest(path, rootElement, payload) {
return __awaiter(this, void 0, void 0, function* () {
const options = {
headers: BaseService.buildDefaultHeaders(),
method: "GET",
body: undefined,
};
if (this.accessToken) {
options.headers["X-Shopify-Access-Token"] = this.accessToken;
}
let url = new uri(this.shopDomain);
url.protocol("https");
url.path(this.joinUriPaths(this.resource, path));
if (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);
}
}
//Fetch will only throw an exception when there is a network-related error, not when Shopify returns a non-200 response.
/**
* 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.
*
* 3. Page links inside of the headers are circular, with the final page link pointing at the first page of results.
*/
let result = [];
// The final page of results will be linked back to the first page as the "next" page.
const visited = new Set();
while (url) {
const response = yield BaseService.apiInfo[this.shopDomain].requestQueue.add(() => __awaiter(this, void 0, void 0, function* () {
// Check that we don't hit call limit
let remaining = this.getCallLimits().remaining;
if (remaining < 5) {
return (new Promise(res => setTimeout(res, 10000 - remaining)))
.then(() => (0, node_fetch_1.default)(url.toString(), options));
}
// console.log('Fetch url:', url.toString());
// console.log('options:', options)
return (0, node_fetch_1.default)(url.toString(), options);
}));
const callLimits = response.headers.get('x-shopify-shop-api-call-limit');
if (callLimits) {
BaseService.apiInfo[this.shopDomain].setCallLimits(callLimits);
}
// Shopify implement 204 - no content for DELETE requests
if (response.status == 204) {
break;
}
let json = yield response.text();
try {
json = JSON.parse(json);
}
catch (e) {
throw new shopify_error_1.default(response, json);
}
if (!response.ok) {
throw new shopify_error_1.default(response, json);
}
result = result.concat(rootElement ? json[rootElement] : json);
const link = response.headers.get('link');
if (!link) {
break;
}
const links = link.match(/(?<=\<)(.*?)(?=\>)/) || [];
// If the request is on the first page, the "next" link will be the first element of the result.
// If a "previous" link is present, it will be the first element of the result and the "next" link will be the second element.
const nextLink = links.length === 2 ? links[1] : links[0];
// The final page of results will be linked back to the first page as the "next" page.
if (visited.has(nextLink)) {
break;
}
else {
url = new uri(nextLink);
visited.add(nextLink);
}
}
return result;
});
}
}
exports.BaseService = BaseService;
BaseService.apiInfo = {};
exports.default = BaseService;