Source: data/adapters.js

/**
 * Define standard data adapters used to retrieve data (usually from REST APIs)
 *
 * ## Adapters are responsible for retrieving data
 * In LocusZoom, the act of fetching data (from API, JSON file, or Tabix) is separate from the act of rendering data.
 * Adapters are used to handle retrieving from different sources, and can provide various advanced functionality such
 *  as caching, data harmonization, and annotating API responses with calculated fields. They can also be used to join
 *  two data sources, such as annotating association summary statistics with LD information.
 *
 * Most of LocusZoom's builtin layouts and adapters are written for the field names and data formats of the
 *  UMich [PortalDev API](https://portaldev.sph.umich.edu/docs/api/v1/#introduction):
 *  if your data is in a different format, an adapter can be used to coerce or rename fields.
 *  Although it is possible to change every part of a rendering layout to expect different fields, this is often much
 *  more work than providing data in the expected format.
 *
 * ## Creating data adapters
 * The documentation in this section describes the available data types and adapters. Real LocusZoom usage almost never
 *  creates these classes directly: rather, they are defined from configuration objects that ask for a source by name.
 *
 * The below example creates an object responsible for fetching two different GWAS summary statistics datasets from two different API endpoints, for any data
 *  layer that asks for fields from `trait1:fieldname` or `trait2:fieldname`.
 *
 *  ```
 *  const data_sources = new LocusZoom.DataSources();
 *  data_sources.add("trait1", ["AssociationLZ", { url: "http://server.com/api/single/", source: 1 }]);
 *  data_sources.add("trait2", ["AssociationLZ", { url: "http://server.com/api/single/", source: 2 }]);
 *  ```
 *
 *  These data sources are then passed to the plot when data is to be rendered:
 *  `const plot = LocusZoom.populate("#lz-plot", data_sources, layout);`
 *
 * @module LocusZoom_Adapters
 */

import {BaseUrlAdapter} from './undercomplicate';

import {parseMarker} from '../helpers/parse';

/**
 * Replaced with the BaseLZAdapter class.
 * @public
 * @deprecated
 */
class BaseAdapter {
    constructor() {
        throw new Error('The "BaseAdapter" and "BaseApiAdapter" classes have been replaced in LocusZoom 0.14. See migration guide for details.');
    }
}

/**
 * Removed class for LocusZoom data adapters that receive their data over the web. Adds default config parameters
 *  (and potentially other behavior) that are relevant to URL-based requests.
 * @extends module:LocusZoom_Adapters~BaseAdapter
 * @deprecated
 * @param {string} config.url The URL for the remote dataset. By default, most adapters perform a GET request.
 * @inheritDoc
 */
class BaseApiAdapter extends BaseAdapter {}


/**
 * @extends module:undercomplicate.BaseUrlAdapter
 * @inheritDoc
 */
class BaseLZAdapter extends BaseUrlAdapter {
    /**
     * @param [config.cache_enabled=true]
     * @param [config.cache_size=3]
     * @param [config.url]
     * @param [config.prefix_namespace=true] Whether to modify the API response by prepending namespace to each field name.
     *   Most adapters will do this by default, so that each field is unambiguously defined based on where it comes from. (this helps to disambiguate two providers that return similar field names, like assoc:variant and catalog:variant)
     *   Typically, this is only disabled if the response payload is very unusual
     * @param {String[]} [config.limit_fields=null] If an API returns far more data than is needed, this can be used to simplify
     *   the payload by excluding unused fields. This can help to reduce memory usage for really big server responses like LD.
     */
    constructor(config = {}) {
        if (config.params) {
            // Backwards-compat: prevent old sources from breaking in subtle ways because config options are no longer split between two places.
            console.warn('Deprecation warning: all options in "config.params" should now be specified as top level keys.');
            Object.assign(config, config.params || {});
            delete config.params; // fields are moved, not just copied in both places; Custom code will need to reflect new reality!
        }
        super(config);

        // Prefix the namespace for this source to all fieldnames: id -> assoc.id
        // This is useful for almost all layers because the layout object says where to find every field, exactly.
        // For some very complex data structure- mainly the Genes API payload- the datalayer might want to operate on
        //   that complex set of fields directly. Disable _prefix_namespace to get field names as they appear
        //   in the response. (gene_name instead of genes.gene_name)
        const { prefix_namespace = true, limit_fields } = config;
        this._prefix_namespace = prefix_namespace;
        this._limit_fields = limit_fields ? new Set(limit_fields) : false;  // Optional and typically only used for very standard datasets like LD or catalog, where API returns >> what is displayed. People want to show their own custom annos for assoc plots pretty often, so the most-often-customized adapters don't specify limit_fields
    }

    /**
     * Determine how a particular request will be identified in cache. Most LZ requests are region based,
     *   so the default is a string concatenation of `chr_start_end`. This adapter is "region aware"- if the user
     *   zooms in, it won't trigger a network request because we alread have the data needed.
     * @param options Receives plot.state plus any other request options defined by this source
     * @returns {string}
     * @public
     */
    _getCacheKey(options) {
        // Most LZ adapters are fetching REGION data, and it makes sense to treat zooming as a cache hit by default
        let {chr, start, end} = options;  // Current view: plot.state

        // Does a prior cache hit qualify as a superset of the ROI?
        const superset = this._cache.find(({metadata: md}) => chr === md.chr && start >= md.start && end <= md.end);
        if (superset) {
            ({ chr, start, end } = superset.metadata);
        }

        // The default cache key is region-based, and this method only returns the region-based part of the cache hit
        //  That way, methods that override the key can extend the base value and still get the benefits of region-overlap-check
        options._cache_meta = { chr, start, end };
        return `${chr}_${start}_${end}`;
    }

    /**
     * Add the "local namespace" as a prefix for every field returned for this request. Eg if the association api
     *   returns a field called variant, and the source is referred to as "assoc" within a particular data layer, then
     *   the returned records will have a field called "assoc:variant"
     *
     * @param records
     * @param options
     * @returns {*}
     * @public
     */
    _postProcessResponse(records, options) {
        if (!this._prefix_namespace || !Array.isArray(records)) {
            return records;
        }

        // Transform fieldnames to include the namespace name as a prefix. For example, a data layer that asks for
        //   assoc data might see "variant" as "assoc.variant"
        const { _limit_fields } = this;
        const { _provider_name } = options;

        return records.map((row) => {
            return Object.entries(row).reduce(
                (acc, [label, value]) => {
                    // Rename API fields to format `namespace:fieldname`. If an adapter specifies limit_fields, then remove any unused API fields from the final payload.
                    if (!_limit_fields || _limit_fields.has(label)) {
                        acc[`${_provider_name}:${label}`] = value;
                    }
                    return acc;
                },
                {},
            );
        });
    }

    /**
     * Convenience method, manually called in LZ sources that deal with dependent data.
     *
     * In the last step of fetching data, LZ adds a prefix to each field name.
     * This means that operations like "build query based on prior data" can't just ask for "log_pvalue" because
     *  they are receiving "assoc:log_pvalue" or some such unknown prefix.
     *
     * This helper lets us use dependent data more easily. Not every adapter needs to use this method.
     *
     * @param {Object} a_record One record (often the first one in a set of records)
     * @param {String} fieldname The desired fieldname, eg "log_pvalue"
     */
    _findPrefixedKey(a_record, fieldname) {
        const suffixer = new RegExp(`:${fieldname}$`);
        const match = Object.keys(a_record).find((key) => suffixer.test(key));
        if (!match) {
            throw new Error(`Could not locate the required key name: ${fieldname} in dependent data`);
        }
        return match;
    }
}


/**
 * The base adapter for the UMich Portaldev API server. This adds a few custom behaviors that handle idiosyncrasies
 *   of one particular web server.
 * @extends module:LocusZoom_Adapters~BaseLZAdapter
 * @inheritDoc
 */
class BaseUMAdapter extends BaseLZAdapter {
    /**
     * @param {Object} config
     * @param {String} [config.build] The genome build to be used by all requests for this adapter. (UMich APIs are all genome build aware). "GRCh37" or "GRCh38"
     */
    constructor(config = {}) {
        super(config);
        // The UM portaldev API accepts an (optional) parameter "genome_build"
        this._genome_build = config.genome_build || config.build;
    }

    _validateBuildSource(build, source) {
        // Build OR Source, not both
        if ((build && source) || !(build || source)) {
            throw new Error(`${this.constructor.name} must provide a parameter specifying either "build" or "source". It should not specify both.`);
        }
        // If the build isn't recognized, our APIs can't transparently select a source to match
        if (build && !['GRCh37', 'GRCh38'].includes(build)) {
            throw new Error(`${this.constructor.name} must specify a valid 'genome_build'`);
        }
    }

    // Special behavior for the UM portaldev API: col -> row format normalization
    /**
     * Some endpoints in the UM portaldev API returns columns of data, rather than rows. Convert the response to record objects, each row of a table being represented as an object of {field:value} pairs.
     * @param response_text
     * @param options
     * @returns {Object[]}
     * @public
     */
    _normalizeResponse(response_text, options) {
        let data = super._normalizeResponse(...arguments);
        // Most portaldev endpoints (though not all) store the desired response in just one specific part of the payload
        data = data.data || data;

        if (Array.isArray(data)) {
            // Already in the desired form
            return data;
        }
        // Otherwise, assume the server response is an object representing columns of data.
        // Each array should have the same length (verify), and a given array index corresponds to a single row.
        const keys = Object.keys(data);
        const N = data[keys[0]].length;
        const sameLength = keys.every(function (key) {
            const item = data[key];
            return item.length === N;
        });
        if (!sameLength) {
            throw new Error(`${this.constructor.name} expects a response in which all arrays of data are the same length`);
        }

        // Go down the columns, and create an object for each row record
        const records = [];
        const fields = Object.keys(data);
        for (let i = 0; i < N; i++) {
            const record = {};
            for (let j = 0; j < fields.length; j++) {
                record[fields[j]] = data[fields[j]][i];
            }
            records.push(record);
        }
        return records;
    }
}


/**
 * Retrieve Association Data from the LocusZoom/ Portaldev API (or compatible). Defines how to make a request
 *  to a specific REST API.
 * @public
 * @see module:LocusZoom_Adapters~BaseUMAdapter
 * @inheritDoc
 *
 * @param {Number} config.source The source ID for the dataset of interest, used to construct the request URL
 */
class AssociationLZ extends BaseUMAdapter {
    constructor(config = {}) {
        super(config);

        // We don't validate the source option because a depressing number of people use AssociationLZ to serve non-dynamic JSON files
        const { source } = config;
        this._source_id = source;
    }

    _getURL (request_options) {
        const {chr, start, end} = request_options;
        const base = super._getURL(request_options);
        return `${base}results/?filter=analysis in ${this._source_id} and chromosome in  '${chr}' and position ge ${start} and position le ${end}`;
    }
}


/**
 * Fetch GWAS catalog data for a list of known variants, and align the data with previously fetched association data.
 * There can be more than one claim per variant; this adapter is written to support a visualization in which each
 * association variant is labeled with the single most significant hit in the GWAS catalog. (and enough information to link to the external catalog for more information)
 *
 * Sometimes the GWAS catalog uses rsIDs that could refer to more than one variant (eg multiple alt alleles are
 *  possible for the same rsID). To avoid missing possible hits due to ambiguous meaning, we connect the assoc
 *  and catalog data via the position field, not the full variant specifier. This source will auto-detect the matching
 *  field in association data by looking for the field name `position` or `pos`.
 *
 * @public
 * @see module:LocusZoom_Adapters~BaseUMAdapter
 */
class GwasCatalogLZ extends BaseUMAdapter {
    /**
     * @param {string} config.url The base URL for the remote data.
     * @param [config.build] The genome build to use when requesting the specific genomic region.
     *  May be overridden by a global parameter `plot.state.genome_build` so that all datasets can be fetched for the appropriate build in a consistent way.
     * @param {Number} [config.source] The ID of the chosen catalog. Most usages should omit this parameter and
     *  let LocusZoom choose the newest available dataset to use based on the genome build: defaults to recent EBI GWAS catalog, GRCh37.
     */
    constructor(config) {
        if (!config.limit_fields) {
            config.limit_fields = ['log_pvalue', 'pos', 'rsid', 'trait', 'variant'];
        }
        super(config);
    }

    /**
     * Add query parameters to the URL to construct a query for the specified region
     */
    _getURL(request_options) {
        const build = request_options.genome_build || this._config.build;
        const source = this._config.source;
        this._validateBuildSource(build, source);

        // If a build name is provided, it takes precedence (the API will attempt to auto-select newest dataset based on the requested genome build).
        //  Build and source are mutually exclusive, because hard-coded source IDs tend to be out of date
        const source_query = build ? `&build=${build}` : ` and id eq ${source}`;

        const base = super._getURL(request_options);
        return `${base}?format=objects&sort=pos&filter=chrom eq '${request_options.chr}' and pos ge ${request_options.start} and pos le ${request_options.end}${source_query}`;
    }
}


/**
 * Retrieve Gene Data, as fetched from the LocusZoom/Portaldev API server (or compatible format)
 * @public
 * @see module:LocusZoom_Adapters~BaseUMAdapter
 * @param {string} config.url The base URL for the remote data
 * @param [config.build] The genome build to use
 *  May be overridden by a global parameter `plot.state.genome_build` so that all datasets can be fetched for the appropriate build in a consistent way.
 * @param {Number} [config.source] The ID of the chosen gene dataset. Most usages should omit this parameter and
 *  let LocusZoom choose the newest available dataset to use based on the genome build: defaults to recent GENCODE data, GRCh37.
 */
class GeneLZ extends BaseUMAdapter {
    constructor(config = {}) {
        super(config);

        // The UM Genes API has a very complex internal format and the genes layer is written to work with it exactly as given.
        //  We will avoid transforming or modifying the payload.
        this._prefix_namespace = false;
    }

    /**
     * Add query parameters to the URL to construct a query for the specified region
     */
    _getURL(request_options) {
        const build = request_options.genome_build || this._config.build;
        let source = this._config.source;
        this._validateBuildSource(build, source);

        // If a build name is provided, it takes precedence (the API will attempt to auto-select newest dataset based on the requested genome build).
        //  Build and source are mutually exclusive, because hard-coded source IDs tend to be out of date
        const source_query = build ? `&build=${build}` : ` and source in ${source}`;

        const base = super._getURL(request_options);
        return `${base}?filter=chrom eq '${request_options.chr}' and start le ${request_options.end} and end ge ${request_options.start}${source_query}`;
    }
}


/**
 * Retrieve Gene Constraint Data, as fetched from the gnomAD server (or compatible graphQL api endpoint)
 *
 * This is intended to be the second request in a chain, with special logic that connects it to Genes data
 *  already fetched. It assumes that the genes data is returned from the UM API, and thus the logic involves
 *  matching on specific assumptions about `gene_name` format.
 *
 * @public
 * @see module:LocusZoom_Adapters~BaseUMAdapter
 */
class GeneConstraintLZ extends BaseLZAdapter {
    /**
     * @param {string} config.url The base URL for the remote data
     * @param [config.build] The genome build to use
     *   May be overridden by a global parameter `plot.state.genome_build` so that all datasets can be fetched for the appropriate build in a consistent way.
     */
    constructor(config = {}) {
        super(config);
        this._prefix_namespace = false;
    }

    _buildRequestOptions(state, genes_data) {
        const build = state.genome_build || this._config.build;
        if (!build) {
            throw new Error(`Adapter ${this.constructor.name} must specify a 'genome_build' option`);
        }

        const unique_gene_names = new Set();
        for (let gene of genes_data) {
            // In rare cases, the same gene symbol may appear at multiple positions. (issue #179) We de-duplicate the
            //  gene names to avoid issuing a malformed GraphQL query.
            unique_gene_names.add(gene.gene_name);
        }

        state.query = [...unique_gene_names.values()].map(function (gene_name) {
            // GraphQL alias names must match a specific set of allowed characters: https://stackoverflow.com/a/45757065/1422268
            const alias = `_${gene_name.replace(/[^A-Za-z0-9_]/g, '_')}`;
            // Each gene symbol is a separate graphQL query, grouped into one request using aliases
            return `${alias}: gene(gene_symbol: "${gene_name}", reference_genome: ${build}) { gnomad_constraint { exp_syn obs_syn syn_z oe_syn oe_syn_lower oe_syn_upper exp_mis obs_mis mis_z oe_mis oe_mis_lower oe_mis_upper exp_lof obs_lof pLI oe_lof oe_lof_lower oe_lof_upper } } `;
        });
        state.build = build;
        return Object.assign({}, state);
    }

    _performRequest(options) {
        let {query, build} = options;
        if (!query.length || query.length > 25 || build === 'GRCh38') {
            // Skip the API request when it would make no sense:
            // - Build 38 (gnomAD supports build GRCh37 only; don't hit server when invalid. This isn't future proof, but we try to be good neighbors.)
            // - Too many genes (gnomAD appears to set max cost ~25 genes)
            // - No genes in region (hence no constraint info)
            return Promise.resolve([]);
        }
        query = `{${query.join(' ')} }`; // GraphQL isn't quite JSON; items are separated by spaces but not commas

        const url = this._getURL(options);

        // See: https://graphql.org/learn/serving-over-http/
        const body = JSON.stringify({ query: query });
        const headers = { 'Content-Type': 'application/json' };

        // Note: The gnomAD API sometimes fails randomly.
        // If request blocked, return  a fake "no data" signal so the genes track can still render w/o constraint info
        return fetch(url, { method: 'POST', body, headers }).then((response) => {
            if (!response.ok) {
                return [];
            }
            return response.text();
        }).catch((err) => []);
    }

    /**
     * The gnomAD API has a very complex internal data format. Bypass any record parsing or transform steps.
     */
    _normalizeResponse(response_text) {
        if (typeof response_text !== 'string') {
            // If the query short-circuits, we receive an empty list instead of a string
            return response_text;
        }
        const data = JSON.parse(response_text);
        return data.data;
    }
}


/**
 * Fetch linkage disequilibrium information from a UMich LDServer-compatible API, relative to a reference variant.
 *  If no `plot.state.ldrefvar` is explicitly provided, this source will attempt to find the most significant GWAS
 *  variant and yse that as the LD reference variant.
 *
 * THIS ADAPTER EXPECTS TO RECEIVE ASSOCIATION DATA WITH FIELDS `variant` and `log_pvalue`. It may not work correctly
 *   if this information is not provided.
 *
 * This source is designed to connect its results to association data, and therefore depends on association data having
 *  been loaded by a previous request. For custom association APIs, some additional options might
 *  need to be be specified in order to locate the most significant SNP. Variant IDs of the form `chrom:pos_ref/alt`
 *  are preferred, but this source will attempt to harmonize other common data formats into something that the LD
 *  server can understand.
 *
 * @public
 * @see module:LocusZoom_Adapters~BaseUMAdapter
 */
class LDServer extends BaseUMAdapter {
    /**
     * @param {string} config.url The base URL for the remote data.
     * @param [config.build='GRCh37'] The genome build to use when calculating LD relative to a specified reference variant.
     *  May be overridden by a global parameter `plot.state.genome_build` so that all datasets can be fetched for the appropriate build in a consistent way.
     * @param [config.source='1000G'] The name of the reference panel to use, as specified in the LD server instance.
     *  May be overridden by a global parameter `plot.state.ld_source` to implement widgets that alter LD display.
     * @param [config.population='ALL'] The sample population used to calculate LD for a specified source;
     *  population names vary depending on the reference panel and how the server was populated wth data.
     *  May be overridden by a global parameter `plot.state.ld_pop` to implement widgets that alter LD display.
     * @param [config.method='rsquare'] The metric used to calculate LD
     */
    constructor(config) {
        if (!config.limit_fields) {
            config.limit_fields = ['variant2', 'position2', 'correlation'];
        }
        super(config);
    }

    __find_ld_refvar(state, assoc_data) {
        const assoc_variant_name = this._findPrefixedKey(assoc_data[0], 'variant');
        const assoc_logp_name = this._findPrefixedKey(assoc_data[0], 'log_pvalue');

        // Determine the reference variant (via user selected OR automatic-per-track)
        let refvar;
        let best_hit = {};
        if (state.ldrefvar) {
            // State/ldrefvar would store the variant in the format used by assoc data, so no need to clean up to match in data
            refvar = state.ldrefvar;
            best_hit = assoc_data.find((item) => item[assoc_variant_name] === refvar) || {};
        } else {
            // find highest log-value and associated var spec
            let best_logp = 0;
            for (let item of assoc_data) {
                const { [assoc_variant_name]: variant, [assoc_logp_name]: log_pvalue} = item;
                if (log_pvalue > best_logp) {
                    best_logp = log_pvalue;
                    refvar = variant;
                    best_hit = item;
                }
            }
        }

        // Add a special field that is not part of the assoc or LD data from the server, but has significance for plotting.
        //  Since we already know the best hit, it's easier to do this here rather than in annotate or join phase.
        best_hit.lz_is_ld_refvar = true;

        // Above, we compared the ldrefvar to the assoc data. But for talking to the LD server,
        //   the variant fields must be normalized to a specific format. All later LD operations will use that format.
        const match = parseMarker(refvar, true);
        if (!match) {
            throw new Error('Could not request LD for a missing or incomplete marker format');
        }

        const [chrom, pos, ref, alt] = match;
        // Currently, the LD server only accepts full variant specs; it won't return LD w/o ref+alt. Allowing
        //  a partial match at most leaves room for potential future features.
        refvar = `${chrom}:${pos}`; // FIXME: is this a server request that we can skip?
        if (ref && alt) {
            refvar += `_${ref}/${alt}`;
        }

        const coord = +pos;
        // Last step: sanity check the proposed reference variant. Is it inside the view region? If not, we're probably
        //  remembering a user choice from before user jumped to a new region. LD should be relative to something nearby.
        if ((coord && state.ldrefvar && state.chr) && (chrom !== String(state.chr) || coord < state.start || coord > state.end)) {
            // Rerun this method, after clearing out the proposed reference variant. NOTE: Adapter call receives a
            //   *copy* of plot.state, so wiping here doesn't remove the original value.
            state.ldrefvar = null;
            return this.__find_ld_refvar(state, assoc_data);
        }

        // Return the reference variant, in a normalized format suitable for LDServer queries
        return refvar;
    }

    _buildRequestOptions(state, assoc_data) {
        if (!assoc_data) {
            throw new Error('LD request must depend on association data');
        }

        // If no state refvar is provided, find the most significant variant in any provided assoc data.
        //   Assumes that assoc satisfies the "assoc" fields contract, eg has fields variant and log_pvalue
        const base = super._buildRequestOptions(...arguments);
        if (!assoc_data.length) {
            // No variants, so no need to annotate association data with LD!
            // NOTE: Revisit. This could have odd cache implications (eg, when joining two assoc datasets to LD, and only the second dataset has data in the region)
            base._skip_request = true;
            return base;
        }

        base.ld_refvar = this.__find_ld_refvar(state, assoc_data);

        // The LD source/pop can be overridden from plot.state, so that user choices can override initial source config
        const genome_build = state.genome_build || this._config.build || 'GRCh37'; // This isn't expected to change after the data is plotted.
        let ld_source = state.ld_source || this._config.source || '1000G';
        const ld_population = state.ld_pop || this._config.population || 'ALL';  // LDServer panels will always have an ALL

        if (ld_source === '1000G' && genome_build === 'GRCh38') {
            // For build 38 (only), there is a newer/improved 1000G LD panel available that uses WGS data. Auto upgrade by default.
            ld_source = '1000G-FRZ09';
        }

        this._validateBuildSource(genome_build, null);  // LD doesn't need to validate `source` option
        return Object.assign({}, base, { genome_build, ld_source, ld_population });
    }

    _getURL(request_options) {
        const method = this._config.method || 'rsquare';
        const {
            chr, start, end,
            ld_refvar,
            genome_build, ld_source, ld_population,
        } = request_options;

        const base = super._getURL(request_options);

        return  [
            base, 'genome_builds/', genome_build, '/references/', ld_source, '/populations/', ld_population, '/variants',
            '?correlation=', method,
            '&variant=', encodeURIComponent(ld_refvar),
            '&chrom=', encodeURIComponent(chr),
            '&start=', encodeURIComponent(start),
            '&stop=', encodeURIComponent(end),
        ].join('');
    }

    _getCacheKey(options) {
        // LD is keyed by more than just region; append other parameters to the base cache key
        const base = super._getCacheKey(options);
        const { ld_refvar, ld_source, ld_population } = options;
        return `${base}_${ld_refvar}_${ld_source}_${ld_population}`;
    }

    _performRequest(options) {
        // Skip request if this one depends on other data, and we are in a region with no data
        if (options._skip_request) {
            // TODO: A skipped request leads to a cache value; possible edge cases where this could get weird.
            return Promise.resolve([]);
        }

        const url = this._getURL(options);

        // The UM LDServer uses API pagination; fetch all data by chaining requests when pages are detected
        let combined = { data: {} };
        let chainRequests = function (url) {
            return fetch(url).then().then((response) => {
                if (!response.ok) {
                    throw new Error(response.statusText);
                }
                return response.text();
            }).then(function(payload) {
                payload = JSON.parse(payload);
                Object.keys(payload.data).forEach(function (key) {
                    combined.data[key] = (combined.data[key] || []).concat(payload.data[key]);
                });
                if (payload.next) {
                    return chainRequests(payload.next);
                }
                return combined;
            });
        };
        return chainRequests(url);
    }
}


/**
 * Retrieve Recombination Rate Data, as fetched from the LocusZoom API server (or compatible)
 * @public
 * @see module:LocusZoom_Adapters~BaseUMAdapter
 * @param {string} config.url The base URL for the remote data
 * @param [config.build] The genome build to use
 *  May be overridden by a global parameter `plot.state.genome_build` so that all datasets can be fetched for the appropriate build in a consistent way.
 * @param {Number} [config.source] The ID of the chosen dataset. Most usages should omit this parameter and
 *  let LocusZoom choose the newest available dataset to use based on the genome build: defaults to recent HAPMAP recombination rate, GRCh37.
 */
class RecombLZ extends BaseUMAdapter {
    constructor(config) {
        if (!config.limit_fields) {
            config.limit_fields = ['position', 'recomb_rate'];
        }
        super(config);
    }

    /**
     * Add query parameters to the URL to construct a query for the specified region
     */
    _getURL(request_options) {
        const build = request_options.genome_build || this._config.build;
        let source = this._config.source;
        this._validateBuildSource(build, source);

        // If a build name is provided, it takes precedence (the API will attempt to auto-select newest dataset based on the requested genome build).
        //  Build and source are mutually exclusive, because hard-coded source IDs tend to be out of date
        const source_query = build ? `&build=${build}` : ` and id in ${source}`;

        const base = super._getURL(request_options);
        return `${base}?filter=chromosome eq '${request_options.chr}' and position le ${request_options.end} and position ge ${request_options.start}${source_query}`;
    }
}


/**
 * Retrieve static blobs of data as raw JS objects. This does not perform additional parsing, which is required
 *  for some sources (eg it does not know how to join together LD and association data).
 *
 * Therefore it is the responsibility of the user to pass information in a format that can be read and
 * understood by the chosen plot- a StaticJSON source is rarely a drop-in replacement for existing layouts.
 *
 * This source is largely here for legacy reasons. More often, a convenient way to serve static data is as separate
 *  JSON files to an existing source (with the JSON url in place of an API).
 *
 *  Note: The name is a bit misleading. It receives JS objects, not strings serialized as "json".
 * @public
 * @see module:LocusZoom_Adapters~BaseLZAdapter
 * @param {object} config.data The data to be returned by this source (subject to namespacing rules)
 */
class StaticSource extends BaseLZAdapter {
    constructor(config = {}) {
        // Does not receive any config; the only argument is the raw data, embedded when source is created
        super(...arguments);
        const { data } = config;
        if (!data || Array.isArray(config)) { // old usages may provide an array directly instead of as config key
            throw new Error("'StaticSource' must provide data as required option 'config.data'");
        }
        this._data = data;
    }

    _performRequest(options) {
        return Promise.resolve(this._data);
    }
}


/**
 * Retrieve PheWAS data retrieved from a LocusZoom/PortalDev compatible API
 * @public
 * @see module:LocusZoom_Adapters~BaseUMAdapter
 * @param {string} config.url The base URL for the remote data
 * @param {String[]} config.build This datasource expects to be provided the name of the genome build that will
 *   be used to provide PheWAS results for this position. Note positions may not translate between builds.
 */
class PheWASLZ extends BaseUMAdapter {
    _getURL(request_options) {
        const build = (request_options.genome_build ? [request_options.genome_build] : null) || this._config.build;
        if (!build || !Array.isArray(build) || !build.length) {
            throw new Error(['Adapter', this.constructor.name, 'requires that you specify array of one or more desired genome build names'].join(' '));
        }
        const base = super._getURL(request_options);
        const url = [
            base,
            "?filter=variant eq '", encodeURIComponent(request_options.variant), "'&format=objects&",
            build.map(function (item) {
                return `build=${encodeURIComponent(item)}`;
            }).join('&'),
        ];
        return url.join('');
    }

    _getCacheKey(options) {
        // Not a region based source; don't do anything smart for cache check
        return this._getURL(options);
    }
}

// Deprecated symbols
export { BaseAdapter, BaseApiAdapter };

// Usually used as a parent class for custom code
export { BaseLZAdapter, BaseUMAdapter };

// Usually used as a standalone class
export {
    AssociationLZ,
    GeneConstraintLZ,
    GeneLZ,
    GwasCatalogLZ,
    LDServer,
    PheWASLZ,
    RecombLZ,
    StaticSource,
};