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 };