Source: util/paging-iterator.ts

/**
 * @fileoverview Iterator for paged responses
 */

import * as qs from 'querystring';
import { Promise } from 'bluebird';
import PromiseQueue = require('promise-queue');

// -----------------------------------------------------------------------------
// Typedefs
// -----------------------------------------------------------------------------

/**
 * The iterator response object
 * @typedef {Object} IteratorData
 * @property {Array} [value] - The next set of values from the iterator
 * @property {boolean} done - Whether the iterator is completed
 */

/**
 * Iterator callback
 * @callback IteratorCallback
 * @param {?Error} err - An error if the iterator encountered one
 * @param {IteratorData} [data] - New data from the iterator
 * @returns {void}
 */

// -----------------------------------------------------------------------------
// Requirements
// -----------------------------------------------------------------------------

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

PromiseQueue.configure(Promise as any);

// -----------------------------------------------------------------------------
// Private
// -----------------------------------------------------------------------------

const PAGING_MODES = Object.freeze({
	MARKER: 'marker',
	OFFSET: 'offset',
});

// -----------------------------------------------------------------------------
// Public
// -----------------------------------------------------------------------------

/**
 * Asynchronous iterator for paged collections
 */
class PagingIterator {
	/**
	 * Determine if a response is iterable
	 * @param {Object} response - The API response
	 * @returns {boolean} Whether the response is iterable
	 */
	static isIterable(response: any /* FIXME */) {
		// POST responses for uploading a file are explicitly excluded here because, while the response is iterable,
		// it always contains only a single entry and historically has not been handled as iterable in the SDK.
		// This behavior is being preserved here to avoid a breaking change.
		let UPLOAD_PATTERN = /.*upload\.box\.com.*\/content/;
		var isGetOrPostRequest =
				response.request &&
				(response.request.method === 'GET' ||
					(response.request.method === 'POST' &&
						!UPLOAD_PATTERN.test(response.request.uri.href))),
			hasEntries = response.body && Array.isArray(response.body.entries),
			notEventStream = response.body && !response.body.next_stream_position;

		return Boolean(isGetOrPostRequest && hasEntries && notEventStream);
	}

	nextField: any /* FIXME */;
	nextValue: any /* FIXME */;
	limit: any /* FIXME */;
	done: boolean;
	options: Record<string, any> /* FIMXE */;

	fetch: any /* FIXME */;
	buffer: any /* FIXME */;
	queue: any /* FIXME */;

	/**
	 * @constructor
	 * @param {Object} response - The original API response
	 * @param {BoxClient} client - An API client to make further requests
	 * @returns {void}
	 * @throws {Error} Will throw when collection cannot be paged
	 */
	constructor(response: any /* FIXME */, client: any /* FIXME */) {
		if (!PagingIterator.isIterable(response)) {
			throw new Error('Cannot create paging iterator for non-paged response!');
		}

		var data = response.body;
		if (Number.isSafeInteger(data.offset)) {
			this.nextField = PAGING_MODES.OFFSET;
			this.nextValue = data.offset;
		} else if (typeof data.next_marker === 'undefined') {
			// Default to a finished marker collection when there's no field present,
			// since some endpoints indicate completed paging this way
			this.nextField = PAGING_MODES.MARKER;
			this.nextValue = null;
		} else {
			this.nextField = PAGING_MODES.MARKER;
			this.nextValue = data.next_marker;
		}

		this.limit = data.limit || data.entries.length;
		this.done = false;

		var href = response.request.href.split('?')[0];
		this.options = {
			headers: response.request.headers,
			qs: qs.parse(response.request.uri.query),
		};
		if (response.request.body) {
			if (
				Object.prototype.toString.call(response.request.body) ===
				'[object Object]'
			) {
				this.options.body = response.request.body;
			} else {
				this.options.body = JSON.parse(response.request.body);
			}
		}

		// querystring.parse() makes everything a string, ensure numeric params are the correct type
		if (this.options.qs.limit) {
			this.options.qs.limit = parseInt(this.options.qs.limit, 10);
		}
		if (this.options.qs.offset) {
			this.options.qs.offset = parseInt(this.options.qs.offset, 10);
		}

		delete this.options.headers.Authorization;
		if (response.request.method === 'GET') {
			this.fetch = client.get.bind(client, href);
		}
		if (response.request.method === 'POST') {
			this.fetch = client.post.bind(client, href);
		}
		this.buffer = response.body.entries;
		this.queue = new PromiseQueue(1, Infinity);
		this._updatePaging(response);
	}

	/**
	 * Update the paging parameters for the iterator
	 * @private
	 * @param {Object} response - The latest API response
	 * @returns {void}
	 */
	_updatePaging(response: any /* FIXME */) {
		var data = response.body;

		if (this.nextField === PAGING_MODES.OFFSET) {
			this.nextValue += this.limit;

			if (Number.isSafeInteger(data.total_count)) {
				this.done = data.offset + this.limit >= data.total_count;
			} else {
				this.done = data.entries.length === 0;
			}
		} else if (this.nextField === PAGING_MODES.MARKER) {
			if (data.next_marker) {
				this.nextValue = data.next_marker;
			} else {
				this.nextValue = null;
				this.done = true;
			}
		}
		if (response.request.method === 'GET') {
			this.options.qs[this.nextField] = this.nextValue;
		} else if (response.request.method === 'POST') {
			if (!this.options.body) {
				this.options.body = {};
			}
			this.options.body[this.nextField] = this.nextValue;
			let bodyString = JSON.stringify(this.options.body);
			this.options.headers['content-length'] = bodyString.length;
		}
	}

	/**
	 * Fetch the next page of results
	 * @returns {Promise} Promise resolving to iterator state
	 */
	_getData() {
		return this.fetch(this.options).then((response: any /* FIXME */) => {
			if (response.statusCode !== 200) {
				throw errors.buildUnexpectedResponseError(response);
			}

			this._updatePaging(response);

			this.buffer = this.buffer.concat(response.body.entries);

			if (this.buffer.length === 0) {
				if (this.done) {
					return {
						value: undefined,
						done: true,
					};
				}

				// If we didn't get any data in this page, but the paging
				// parameters indicate that there is more data, attempt
				// to fetch more.  This occurs in multiple places in the API
				return this._getData();
			}

			return {
				value: this.buffer.shift(),
				done: false,
			};
		});
	}

	/**
	 * Fetch the next page of the collection
	 * @returns {Promise} Promise resolving to iterator state
	 */
	next() {
		if (this.buffer.length > 0) {
			return Promise.resolve({
				value: this.buffer.shift(),
				done: false,
			});
		}

		if (this.done) {
			return Promise.resolve({
				value: undefined,
				done: true,
			});
		}

		return this.queue.add(this._getData.bind(this));
	}

	/**
	 * Fetch the next marker
	 * @returns {string|int} String that is the next marker or int that is the next offset
	 */
	getNextMarker() {
		return this.nextValue;
	}
}

export = PagingIterator;