Source: data/undercomplicate/adapter.js

import {LRUCache} from './lru_cache';
import {clone} from './util';

/**
 * @param {boolean} [config.cache_enabled=true] Whether to enable the LRU cache, and store a copy of the normalized and parsed response data.
 *  Turned on by default for most remote requests; turn off if you are using another datastore (like Vuex) or if the response body uses too much memory.
 * @param {number} [config.cache_size=3] How many requests to cache. Track-dependent annotations like LD might benefit
 *   from caching more items, while very large payloads (like, um, TOPMED LD) might benefit from a smaller cache size.
 *   For most LocusZoom usages, the cache is "region aware": zooming in will use cached data, not a separate request
 * @inheritDoc
 * @memberOf module:undercomplicate
 */
class BaseAdapter {
    constructor(config = {}) {
        this._config = config;
        const {
            // Cache control
            cache_enabled = true,
            cache_size = 3,
        } = config;
        this._enable_cache = cache_enabled;
        this._cache = new LRUCache(cache_size);
    }

    /**
     * Build an object with options that control the request. This can take into account both explicit options, and prior data.
     * @param {Object} options Any global options passed in via `getData`. Eg, in locuszoom, every request is passed a copy of `plot.state` as the options object, in which case every adapter would expect certain basic information like `chr, start, end` to be available.
     * @param {Object[]} dependent_data If the source is called with dependencies, this function will receive one argument with the fully parsed response data from each other source it depends on. Eg, `ld(assoc)` means that the LD adapter would be called with the data from an association request as a function argument. Each dependency is its own argument: there can be 0, 1, 2, ...N arguments.
     * @returns {*} An options object containing initial options, plus any calculated values relevant to the request.
     * @public
     */
    _buildRequestOptions(options, dependent_data) {
        // Perform any pre-processing required that may influence the request. Receives an array with the payloads
        //  for each request that preceded this one in the dependency chain
        // This method may optionally take dependent data into account. For many simple adapters, there won't be any dependent data!
        return Object.assign({}, options);
    }

    /**
     * Determine how this request is uniquely identified in cache. Usually this is an exact match for the same key, but it doesn't have to be.
     * The LRU cache implements a `find` method, which means that a cache item can optionally be identified by its node
     * `metadata` (instead of exact key match).
     *  This is useful for situations where the user zooms in to a smaller region and wants the original request to
     *  count as a cache hit. See subclasses for example.
     * @param {object} options Request options from `_buildRequestOptions`
     * @returns {*} This is often a string concatenating unique values for a compound cache key, like `chr_start_end`. If null, it is treated as a cache miss.
     * @public
     */
    _getCacheKey(options) {
        /* istanbul ignore next */
        if (this._enable_cache) {
            throw new Error('Method not implemented');
        }
        return null;
    }

    /**
     * Perform the act of data retrieval (eg from a URL, blob, or JSON entity)
     * @param {object} options Request options from `_buildRequestOptions`
     * @returns {Promise}
     * @public
     */
    _performRequest(options) {
        /* istanbul ignore next */
        throw new Error('Not implemented');
    }

    /**
     * Convert the response format into a list of objects, one per datapoint. Eg split lines of a text file, or parse a blob of json.
     * @param {*} response_text The raw response from performRequest, be it text, binary, etc. For most web based APIs, it is assumed to be text, and often JSON.
     * @param {Object} options Request options. These are not typically used when normalizing a response, but the object is available.
     * @returns {*} A list of objects, each object representing one row of data `{column_name: value_for_row}`
     * @public
     */
    _normalizeResponse(response_text, options) {
        return response_text;
    }

    /**
     * Perform custom client-side operations on the retrieved data. For example, add calculated fields or
     *  perform rapid client-side filtering on cached data. Annotations are applied after cache, which means
     *  that the same network request can be dynamically annotated/filtered in different ways in response to user interactions.
     *
     * This result is currently not cached, but it may become so in the future as responsibility for dynamic UI
     *   behavior moves to other layers of the application.
     * @param {Object[]} records
     * @param {Object} options
     * @returns {*}
     * @public
     */
    _annotateRecords(records, options) {
        return records;
    }

    /**
     * A hook to transform the response after all operations are done. For example, this can be used to prefix fields
     *  with a namespace unique to the request, like `log_pvalue` -> `assoc:log_pvalue`. (by applying namespace prefixes to field names last,
     *  annotations and validation can happen on the actual API payload, without having to guess what the fields were renamed to).
     * @param records
     * @param options
     * @public
     */
    _postProcessResponse(records, options) {
        return records;
    }

    /**
     * All adapters must implement this method to asynchronously return data. All other methods are simply internal hooks to customize the actual request for data.
     * @param {object} options Shared options for this request. In LocusZoom, this is typically a copy of `plot.state`.
     * @param {Array[]} dependent_data Zero or more recordsets corresponding to each individual adapter that this one depends on.
     *  Can be used to build a request that takes into account prior data.
     * @returns {Promise<*>}
     */
    getData(options = {}, ...dependent_data) {
        // Public facing method to define, perform, and process the request
        options = this._buildRequestOptions(options, ...dependent_data);

        const cache_key = this._getCacheKey(options);

        // Then retrieval and parse steps: parse + normalize response, annotate
        let result;
        if (this._enable_cache && this._cache.has(cache_key)) {
            result = this._cache.get(cache_key);
        } else {
            // Cache the promise (to avoid race conditions in conditional fetch). If anything (like `_getCacheKey`)
            //  sets a special option value called `_cache_meta`, this will be used to annotate the cache entry
            // For example, this can be used to decide whether zooming into a view could be satisfied by a cache entry,
            //  even if the actual cache key wasn't an exact match. (see subclasses for an example; this class is generic)
            result = Promise.resolve(this._performRequest(options))
                // Note: we cache the normalized (parsed) response
                .then((text) => this._normalizeResponse(text, options));
            this._cache.add(cache_key, result, options._cache_meta);
            // We are caching a promise, which means we want to *un*cache a promise that rejects, eg a failed or interrupted request
            //  Otherwise, temporary failures couldn't be resolved by trying again in a moment
            // TODO: In the future, consider providing a way to skip requests (eg, a sentinel value to flag something
            //  as not cacheable, like "no dependent data means no request... but maybe in another place this is used, there will be different dependent data and a request would make sense")
            result.catch((e) => this._cache.remove(cache_key));
        }

        return result
            // Return a deep clone of the data, so that there are no shared mutable references to a parsed object in cache
            .then((data) => clone(data))
            .then((records) => this._annotateRecords(records, options))
            .then((records) => this._postProcessResponse(records, options));
    }
}


/**
 * Fetch data over the web, usually from a REST API that returns JSON
 * @param {string} config.url The URL to request
 * @extends module:undercomplicate.BaseAdapter
 * @inheritDoc
 * @memberOf module:undercomplicate
 */
class BaseUrlAdapter extends BaseAdapter {
    constructor(config = {}) {
        super(config);
        this._url = config.url;
    }


    /**
     * Default cache key is the URL for this request
     * @public
     */
    _getCacheKey(options) {
        return this._getURL(options);
    }

    /**
     * In many cases, the base url should be modified with query parameters based on request options.
     * @param options
     * @returns {*}
     * @private
     */
    _getURL(options) {
        return this._url;
    }

    _performRequest(options) {
        const url = this._getURL(options);
        // Many resources will modify the URL to add query or segment parameters. Base method provides option validation.
        //  (not validating in constructor allows URL adapter to be used as more generic parent class)
        if (!this._url) {
            throw new Error('Web based resources must specify a resource URL as option "url"');
        }
        return fetch(url).then((response) => {
            if (!response.ok) {
                throw new Error(response.statusText);
            }
            return response.text();
        });
    }

    _normalizeResponse(response_text, options) {
        if (typeof response_text === 'string') {
            return JSON.parse(response_text);
        }
        // Some custom usages will return other datatypes. These would need to be handled by custom normalization logic in a subclass.
        return response_text;
    }
}

export { BaseAdapter, BaseUrlAdapter };