UNPKG

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
"use strict"; 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-10/" + 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;