【Node.js】Stripeライブラリのlist系のAPIでoffsetが使えないの無理くりどうにかする

## はじめに

[【Node.js】Stripeライブラリの入出力がsnake_caseになっているのをどうにかしたい](https://qiita.com/yuta-katayama-23/items/0cf633f0f81c52f9422c)では、入出力をcamelCaseにできるようにするための独自拡張をやってみた。

今回は、StripeのAPIに`offset`がない事に気づき、SQLに親しんでいる身としては使いにくい（`starting_after`はどちらかというと、NoSQL等で使われるデータの取得を行う際のデータ位置を指定するものになっている）ので、無理くり`offset`を使えるようにする実装をやってみたいと思う。

※以下の実装を見ればわかるが、loopでデータを取得しているだけなので、StripeのAPIの[レート上限](https://stripe.com/docs/rate-limits?locale=ja-JP)に引っかかる可能性が高いので、PoCではいいかもだが、頻繁にlist取得するような実装をするのであれば、今回試しにやってみた実装はNGになると思われる。

ソースコード全体は以下。

https://github.com/yuta-katayama-23/node-express/commit/1e2a5943b19b1c6b88bd3c4046976c89c015201d

一応、動くかのテストも書いてみたが、そのコードは以下。

https://github.com/yuta-katayama-23/node-express/blob/main/__tests__/lib/custom-stripe.test.offset.js

- 参考：[List all customers]()

## 実際にやってみる

```js
import Stripe from 'stripe';
import { strict as assert } from 'assert';
import snakecaseKeys from 'snakecase-keys';
import camelcaseKeys from 'camelcase-keys';
import config from 'config';

const execute = async (options = {}) => {
	// 省略
	const { stripe, category, func, args } = options;

	const snakecaseArgs = snakecaseKeys(args, {
		deep: true,
		exclude: ['stripeAccount']
	});
	const result = await stripe[category][func](...snakecaseArgs);
	return camelcaseKeys(result, { deep: true });
};

export default () => {
	assert.ok(
		process.env.STRIPE_KEY_SECRET,
		'env STRIPE_KEY_SECRET must be required'
	);
	const stripe = Stripe(process.env.STRIPE_KEY_SECRET);

	// IF（入出力）をcamelCaseにした関数を別途定義（_〇〇 のように利用）
	Object.keys(stripe)
		.filter(
			(category) => !category.startsWith('_') && !category.match(/^[A-Z].+/)
		)
		.forEach((category) => {
			Object.keys(Object.getPrototypeOf(stripe[category]))
				.filter((func) => typeof stripe[category][func] === 'function')
				.forEach((func) => {
					stripe[category][`$${func}`] = async (...origin) => {
						const args = origin;
						if (!func.startsWith('list')) {
							const v = await execute({ stripe, category, func, args });
							return v;
						}

						let limit = config.get('stripe.limit');
						let offset = 0;

						// 想定 [ 'hoge' ]
						if (typeof args[0] === 'string') {
							const option = args[1];

							// 想定 [ 'hoge', {} ]
							if (option) {
								// 想定 [ 'hoge', { stripeAccount: 'connectAccountId' } ]
								if ('stripeAccount' in option) {
									args.splice(1, 0, { limit }); // [ 'hoge', { limit: 10 }, { stripeAccount: 'connectAccountId' } ]
								}

								// 想定 [ 'hoge', { } ] or [ 'hoge', { limit: 1 } ]
								else {
									limit = option.limit || limit;
									option.limit = limit; // [ 'hoge', { limit: 10 } ]

									offset = option.offset || offset;
									delete option.offset;
								}
							}

							// 想定 [ 'hoge' ]
							else args.push({ limit }); // [ 'hoge', { limit: 10 } ]
						}

						// 想定 [ {} ]
						if (typeof args[0] === 'object') {
							const option = args[0];

							// 想定 [ { stripeAccount: 'connectAccountId' } ]
							if ('stripeAccount' in option) {
								args.unshift({ limit }); // [ { limit: 10 }, { stripeAccount: 'connectAccountId' } ]
							}

							// 想定 [ {} ] or [ { limit: 1, offset: 1} ]
							else {
								limit = option.limit || limit;
								option.limit = limit; // [ { limit: 10 } ]

								offset = option.offset || offset;
								delete option.offset;
							}
						}

						// 想定 [ ]
						if (!args[0]) args.push({ limit });

						const datas = [];
						for (;;) {
							// eslint-disable-next-line no-await-in-loop
							const { object, data, hasMore } = await execute({
								stripe,
								category,
								func,
								args
							});
							if (object !== 'list') throw new Error('expected list object');
							data.forEach((v) => {
								datas.push(v);
							});

							if (!data.length || !hasMore || datas.length >= offset + limit)
								break;

							// [ {} ] -> args[0], [ 'hoge' ] -> args[1]
							const index = typeof args[0] === 'object' ? 0 : 1;
							args[index].startingAfter = data[data.length - 1].id;
						}

						return datas.slice(offset).slice(0, limit);
					};
				});
		});

	return stripe;
};
```

### 考え方・どう実現しているか？

大枠の考え方としては単純で、データが取得したい数よりも少ない場合には、[starting_after](https://stripe.com/docs/api/customers/list?lang=node#list_customers-starting_after)でその後続のデータを取得し、配列にpushし続けるというもの。

そして、呼び出し側から`limit`・`offset`を以下のようにしてできるようにしている。

```js
const customers = await stripe.customers.$list({ offset: 9, limit: 1 });
```

ただ、StripeのAPIを見ると分かるが、以下のようにパターン化されるので、それに応じて`limit`・`offset`の値を取り出したり、設定したりする必要があり、そこで変に複雑なif文を実装している。

- 第1引数
  - stringが設定されるパターン
  - object（JSON）が設定されるパターン
  - なし（指定なしのパターン）

具体的には、以下のあたりのコードがそれ。

```js
						// 想定 [ 'hoge' ]
						if (typeof args[0] === 'string') {
							const option = args[1];

							// 想定 [ 'hoge', {} ]
							if (option) {
								// 想定 [ 'hoge', { stripeAccount: 'connectAccountId' } ]
								if ('stripeAccount' in option) {
									args.splice(1, 0, { limit }); // [ 'hoge', { limit: 10 }, { stripeAccount: 'connectAccountId' } ]
								}

								// 想定 [ 'hoge', { } ] or [ 'hoge', { limit: 1 } ]
								else {
									limit = option.limit || limit;
									option.limit = limit; // [ 'hoge', { limit: 10 } ]

									offset = option.offset || offset;
									delete option.offset;
								}
							}

							// 想定 [ 'hoge' ]
							else args.push({ limit }); // [ 'hoge', { limit: 10 } ]
						}

						// 想定 [ {} ]
						if (typeof args[0] === 'object') {
							const option = args[0];

							// 想定 [ { stripeAccount: 'connectAccountId' } ]
							if ('stripeAccount' in option) {
								args.unshift({ limit }); // [ { limit: 10 }, { stripeAccount: 'connectAccountId' } ]
							}

							// 想定 [ {} ] or [ { limit: 1, offset: 1} ]
							else {
								limit = option.limit || limit;
								option.limit = limit; // [ { limit: 10 } ]

								offset = option.offset || offset;
								delete option.offset;
							}
						}

						// 想定 [ ]
						if (!args[0]) args.push({ limit });
```

## まとめとして

今回は無理くり、StripeのNode.jsのライブラリを拡張し、list系のAPIでoffsetを利用できるように実装してみた。実装自体はできるが、これがStripeのレート制限や実際の処理速度などを鑑みて、時にはNGになる事もある気がした（paymentMethods程度なら、各csutomerに対する支払方法が100になるような事は考えにくいにので、問題ないだろうが、customer自体は規模が大きくなれば万単位になりそうなので、そこで今回のような実装をしていたらNGだろう）。