API制限を乗り越えるプログラミングテクニック: Node.jsでの秒間N件パラメータ送信と結果統合のアプローチ

2023.05.18

はじめに

外部APIをコールする際に、一回のAPIコールで受け付けられるパラメータ数に制限がかかっていることがあります。例えば、検索用のAPIを呼ぶ時に、検索キーの値を最大N件/秒(Nは100とか)に制限している、などです。この場合、N件を超えるパラメータを渡して検索したい場合、N件単位にパラメータを分割し、秒間隔で複数回コールし、結果を結合する必要があります。

やりたいこと

当社ではSalesforceとHubSpotを運用しています。

先日、Salesforceに保持している情報(主に請求に関する情報)とHubSpotに保持している情報(主に案件受注までの情報)を横断して取得したいことがありました。幸い、HubSpotの「会社」にはSalesforceの「取引先」のIDを保持していましたので、Salesforce APIから取得したい「取引先」のIDを取得し、このID群をパラメータにHubSpotのAPIをコールすることで、「取引先」にマッチする「会社」を取得できそうでした。

しかし、ここで一つ問題がありました。HubSpotの会社検索のAPIは秒間100件を超えるパラメータを検索に渡すとエラーになってしまうのです。

エラーサンプル

Invalid input JSON on line 1, column 19650: too many IN list values (count: 931, max allowed: 100)

上記エラーサンプルでは、検索APIのINには最大100件までしか渡せないところに、931件も渡していてエラーになっています。

本エントリーでは、この問題のNode.jsプログラムでの解決法を示します。

解決法

以下のステップの組み合わせでAPIへのN件パラメータ送信/秒を実現します。

  1. N件より多くの件数が存在するパラメータの配列を最大N件ずつに分割する
  2. bluebirdPromise.delayを使って秒単位にAPIを逐次コールする
  3. APIを複数回逐次コールした結果を結合する

1. N件より多くの件数が存在するパラメータの配列を最大N件ずつに分割する

変数accountIdsには検索をかけたいSalesforceの取引先ID(18桁)がN件より多く格納されているとします。 これを、最大N件ずつの配列に分割した配列、つまり、配列の配列に変換するには次のsliceByNumber関数を使います。

// Nの値をここでは100とする
const N = 100;

/* 配列をnumber個ずつの配列に分割する関数 */
const sliceByNumber = (array, number) => {
  const length = Math.ceil(array.length / number)
  return new Array(length).fill().map((_, i) =>
    array.slice(i * number, (i + 1) * number)
  )
};

// accountIdsを最大N件ずつに分割
const slicedAccountIds = sliceByNumber(accountIds, N);

2. bluebirdのPromise.delayを使って秒単位にAPIを逐次コールする

bluebirdはNode.jsの標準のPromiseに便利な拡張を付与してくれます。 ここでは秒単位にAPIを逐次コールするために、Promise.delayを利用して、次のdelayPromise関数を用意します。

import Promise from "bluebird";

/* myDelayミリ秒遅延して実行するPromiseを返す関数 */
const delayPromise = (myPromise, myDelay) => {
  return Promise.delay(myDelay).then(function() {
    return myPromise;
  });
};

今、APIをコールする関数であるcallHubSpotSearchAPIがあるとすると、1で得た分割した取引先IDの配列の配列(slicedAccountIds)とdelayPromiseを利用して秒単位のAPI逐次コールは次のように書けます。

const callAPISequential = async (accountIds) => {
  const slicedAccountIds = sliceByNumber(accountIds, N);
  const promises = [];
  for ( let i = 0; i < slicedAccountIds.length; i++ ) {
    promises.push(delayPromise(await callHubSpotSearchAPI(slicedAccountIds[i]), i * 1000));
  }
  const results = await Promise.all(promises);
  return results;
};

5行目(ハイライト箇所)のdelayPromiseにループインデックスiに対して1000をかけることで0秒, 1秒, 2秒, ...と秒単位に遅延して実行するPromiseを生成し、7行目(ハイライト箇所)のPromise.allで実行しています。

3. APIを複数回逐次コールした結果を結合する

ここまでのコードだと、callAPISequentialの結果も配列の配列になってしまいます。これを分割せずに一気に実行したかのような結果にするためには、Array.prototype.flat()を使います。

const callAPISequential = async (accountIds) => {
  const slicedAccountIds = sliceByNumber(accountIds, N);
  const promises = [];
  for ( let i = 0; i < slicedAccountIds.length; i++ ) {
    promises.push(delayPromise(await callHubSpotSearchAPI(slicedAccountIds[i]), i * 1000));
  }
  const results = await Promise.all(promises);
  return results.flat();
};

これで配列の配列の階層がフラット化されて、単純な結果の配列になります。

// Array.prototype.flat()で以下の変換が行われる。
// [ [a,b,c], [d,e,f], [g,h,i] ] => [a,b,c,d,e,f,g,h,i]

ちなみに、callHubSpotSearchAPIのサンプル(Salesforceの取引先IDでHubSpotの会社を検索する例)を示しておくと次のようになります。会社のsalesforce_account_idプロパティにSalesforceの取引先IDが入っているものとします。

import { Client } from "@hubspot/api-client";

const hubspotClient = new Client({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN });

const callHubSpotSearchAPI = async (accountIds) => {
  // HubSpot API ではINで検索する場合は、valuesに指定する値は全て小文字でなければいけない仕様なので toLowerCase() で変換している
  const filter = { propertyName: 'salesforce_account_id', operator: 'IN', values: accountIds.map(accountId => accountId.toLowerCase()) };
  const filterGroup = { filters: [filter] };
  const publicObjectSearchRequest = {
    filterGroups: [filterGroup],
    properties: ["name", "salesforce_account_id"],
    limit: 100
  };
  const result = await hubspotClient.apiRequest({
    method: 'POST',
    path: '/crm/v3/objects/companies/search',
    body: publicObjectSearchRequest
  });
  const json = await result.json();
  const companies = json.results.map((result) => {
    return {
      id: result.id,
      name: result.properties.name,
      sfid: result.properties.salesforce_account_id
    };
  });
  return companies;
};

ハマりポイントは、HubSpot APIではINで検索する場合は、valuesに指定する値は全て小文字でなければいけない仕様なのでtoLowerCase()で小文字に変換しているところです。

まとめ

一回のAPIコールで受け付けられるパラメータ数に制限がかかっている外部APIをコールするNode.jsでの実装方法を示しました。bluebirdのPromise.delayを使うことで比較的簡単に実現できることがおわかりいただけたなら嬉しいです。