DEV Community

Nicolas Torres
Nicolas Torres

Posted on • Edited on

Bypassing Shopify Admin REST API limitations with a custom JS client

Quick article to give away my custom Shopify Admin REST API client (based on Axios), that integrates a retry strategy along with a pagination sugar to overcome the very restrictive rate limit policy of Shopify. Note that it also removes the need to wraps the data sent under the resource name like { customer: { data } }.

Usage

const shopifyAdmin = require('./shopifyAdmin');

const myShop = shopifyAdmin({
  shopName: 'your-shop-name',
  version: '2021-01',
  apiKey: '**********',
  password: '**********',
});

myShop('POST /customers', { email: 'you@example.com' });
myShop('GET /customers/search', { query: 'email:"you@example.com"' });
myShop('GET[ALL] /orders'); // paginate and return all
myShop('GET[500] /orders'); // paginate and return first 500
Enter fullscreen mode Exit fullscreen mode

Consider the performance drag of pagination and retry (whenever the API returns a 429). The retry strategy adds a 1 second delay before each call. Better use this in async jobs (like with Bull) whenever possible.

Client source code

const axios = require('axios');
const parseHeaderLink = require('parse-link-header');

/**
 * Custom Axios client for Shopify Admin.
 *
 * Implements pagination, retry strategy, and call frequency control as a workaround for Shopify Admin API call limits.
 *
 * @arg {Object} credentials Shopify Admin API credentials.
 * @arg {string} credentials.shopName Shopify shop name
 * @arg {string} credentials.version Shopify Admin API version
 * @arg {string} credentials.apiKey Shopify Admin API key
 * @arg {string} credentials.password Shopify Admin API password
 * @return {Function} Axios request wrapper.
 */
export default ({ shopName, version, apiKey, password }) => {
  if (!shopName || !version || !apiKey || !password) {
    throw Error('Shopify Admin init: missing credentials.');
  }

  const baseURL = `https://${shopName}.myshopify.com/admin/api/${version}`;
  const token = Buffer.from(`${apiKey}:${password}`).toString('base64');
  const client = axios.create({
    baseURL,
    headers: {
      Authorization: `Basic ${token}`,
    },
  });

  /**
   * Retry if too many requests.
   * @arg {Function} fn Function to execute.
   * @return {Promise<Object[]>} Axios response Promise.
   */
  const retry = async (fn) => new Promise((resolve, reject) => {
    setTimeout(() => {
      fn().then(resolve).catch((err) => {
        if (err.message.includes('429')) {
          retry(fn).then(resolve).catch(reject);
        } else {
          reject(err);
        }
      });
    }, 1000);
  });

  /**
   * Returns concatenated results after running pagination.
   * @arg {Object} config Axios request parameters
   * @arg {number} limit Number of results to return ('undefined' returns all)
   * @return {Promise<Object[]>} Axios response Promise.
   */
  const paginate = async (config, limit) => {
    let output = [];
    do {
      const res = await retry(() => client.request(config));
      output = output.concat(Object.values(res.data)[0]);
      const links = parseHeaderLink(res.headers.link);
      config.params = links && links.next ? {
        limit: links.next.limit,
        page_info: links.next.page_info,
      } : undefined;
    } while (config.params && (!limit || output.length <= limit));
    return output.slice(0, limit);
  };

  /**
   * Axios request wrapper.
   * @arg {string} action Method + URL combined (e.g. "GET /orders").
   * @arg {Object} [data] Body data.
   * @arg {Object} [params] Query parameters.
   * @return {Promise} Axios response Promise.
   * @example
   * // Get products
   * shopifyAdmin('/products')
   * shopifyAdmin('GET /products')
   * @example
   * // Add customer
   * shopifyAdmin('POST /customers', { email: 'you@example.com' })
   * @example
   * // Search customers
   * shopifyAdmin('GET /customers/search', { query: 'email:"you@example.com"' })
   * @example
   * // Get all orders (uses pagination)
   * shopifyAdmin('GET[ALL] /orders')
   * @example
   * // Get 500 orders (uses pagination)
   * shopifyAdmin('GET[500] /orders')
   */
  return (action, data = {}, params = {}) => {
    if (!action) {
      throw Error('Shopify Admin client: missing request URL.');
    }

    const [method, url] = action.trim().split(/\s+/);
    const config = { url: `${url || method}.json` };
    const superGetRegexp = /get\[(all|[0-9]+)\]/i;
    config.method = (url ? method.replace(superGetRegexp, 'GET') : 'GET').toUpperCase();
    if (['DELETE', 'GET'].includes(config.method)) {
      // pass data as query string
      config.params = {
        ...data,
        ...params,
      };
    } else {
      // wrap data under resource (ex: `{customer: data}`)
      const resource = url.replace(/^\/?(?:[a-z_0-9]+\/)*([a-z_]+)s(?:\/[0-9]+)*$/, '$1');
      config.data = { [resource]: data };
      config.params = params;
    }
    const paginated = method.match(superGetRegexp);
    return paginated ?
      paginate(config, parseInt(paginated[1], 10) || undefined) :
      retry(() => client.request(config).then((res) => Object.values(res.data)[0]));
  };
};
Enter fullscreen mode Exit fullscreen mode

Hope this helps!

Top comments (1)

Collapse
 
noclat profile image
Nicolas Torres • Edited

I edited the code multiple times to make it more robust, there were some bugs going on because I translated back from my Typescript instance and now I've tested this JS one and fixed it. If you had copied this code before Feb 24, you need to update :).